sql.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. from __future__ import annotations
  2. from collections.abc import Mapping, Sequence
  3. from typing import Any, Protocol
  4. from sqlalchemy import text
  5. from app.core.knowledge.access import KnowledgeAccessContext
  6. from app.core.knowledge.retrieval.contracts import KnowledgeEvidence
  7. class QueryEmbeddingProvider(Protocol):
  8. def embed(self, texts: list[str]) -> list[list[float]]: ...
  9. def _vector(value: Sequence[float]) -> str:
  10. return "[" + ",".join(str(float(item)) for item in value) + "]"
  11. def _as_tuple(value: Any) -> tuple:
  12. if value is None:
  13. return ()
  14. if isinstance(value, (list, tuple)):
  15. return tuple(value)
  16. return tuple(value)
  17. def _evidence(row: Mapping[str, Any], retriever: str) -> KnowledgeEvidence:
  18. updated_at = row["source_updated_at"]
  19. return KnowledgeEvidence(
  20. chunk_id=str(row["chunk_id"]),
  21. content=row["content"],
  22. score=float(row["score"]),
  23. retriever=retriever,
  24. object_uid=str(row["object_uid"]),
  25. object_type=row["object_type"],
  26. object_version=int(row["object_version"]),
  27. business_domain_uid=(
  28. str(row["business_domain_uid"])
  29. if row["business_domain_uid"] is not None
  30. else None
  31. ),
  32. point_keys=tuple(str(value) for value in _as_tuple(row["point_keys"])),
  33. point_revisions=tuple(
  34. int(value) for value in _as_tuple(row["point_revisions"])
  35. ),
  36. generation=int(row["generation"]),
  37. source_updated_at=updated_at.isoformat() if updated_at else None,
  38. freshness_status=row["freshness_status"],
  39. section_path=row["section_path"],
  40. )
  41. _SELECT = """
  42. SELECT c.id AS chunk_id, c.content, c.section_path,
  43. d.object_uid, d.object_type, d.object_version,
  44. d.business_domain_uid, d.active_generation AS generation,
  45. d.source_updated_at, c.point_keys,
  46. COALESCE(points.point_revisions, ARRAY[]::bigint[]) AS point_revisions,
  47. CASE WHEN pending.source_revision > d.object_version
  48. THEN 'updating' ELSE 'fresh' END AS freshness_status,
  49. {score} AS score
  50. FROM public.governance_chunks c
  51. JOIN public.governance_documents d ON d.id = c.document_id
  52. LEFT JOIN LATERAL (
  53. SELECT array_agg(p.point_revision ORDER BY keys.ordinality) AS point_revisions
  54. FROM jsonb_array_elements_text(c.point_keys) WITH ORDINALITY AS keys(point_key, ordinality)
  55. JOIN public.knowledge_points p
  56. ON p.point_key = keys.point_key AND p.status = 'active'
  57. ) points ON TRUE
  58. LEFT JOIN LATERAL (
  59. SELECT MAX(cs.source_revision) AS source_revision
  60. FROM public.knowledge_change_sets cs
  61. WHERE cs.source_uid = d.object_uid
  62. AND cs.status NOT IN ('canonical_active','complete','failed','rolled_back')
  63. ) pending ON TRUE
  64. {embedding_join}
  65. WHERE d.status = 'active'
  66. AND d.active_generation IS NOT NULL
  67. AND (:global_access OR d.business_domain_uid = ANY(CAST(:domains AS uuid[])))
  68. {predicate}
  69. """
  70. class SqlLexicalRetriever:
  71. def __init__(self, session) -> None:
  72. self._session = session
  73. def retrieve(
  74. self, query: str, context: KnowledgeAccessContext, limit: int
  75. ) -> tuple[KnowledgeEvidence, ...]:
  76. score = """
  77. GREATEST(
  78. CASE WHEN lower(c.lexical_text) = lower(:query) THEN 1.0 ELSE 0.0 END,
  79. CASE WHEN lower(c.lexical_text) LIKE lower(:query) || '%' THEN 0.9 ELSE 0.0 END,
  80. similarity(COALESCE(c.lexical_text, ''), :query)
  81. )
  82. """
  83. statement = text(
  84. _SELECT.format(
  85. score=score,
  86. embedding_join="",
  87. predicate="""
  88. AND (
  89. lower(c.lexical_text) = lower(:query)
  90. OR lower(c.lexical_text) LIKE lower(:query) || '%'
  91. OR c.lexical_text % :query
  92. OR c.search_vector @@ plainto_tsquery('simple', :query)
  93. )
  94. ORDER BY score DESC, c.id
  95. LIMIT :limit
  96. """,
  97. )
  98. )
  99. rows = self._session.execute(
  100. statement,
  101. {
  102. "query": query.strip(),
  103. "global_access": context.global_access,
  104. "domains": list(context.business_domain_uids),
  105. "limit": limit,
  106. },
  107. ).mappings()
  108. return tuple(_evidence(row, "lexical") for row in rows)
  109. class SqlVectorRetriever:
  110. def __init__(self, session, embedder: QueryEmbeddingProvider) -> None:
  111. self._session = session
  112. self._embedder = embedder
  113. def retrieve(
  114. self, query: str, context: KnowledgeAccessContext, limit: int
  115. ) -> tuple[KnowledgeEvidence, ...]:
  116. vectors = self._embedder.embed([query])
  117. if len(vectors) != 1:
  118. raise RuntimeError("query embedder returned an unexpected vector count")
  119. statement = text(
  120. _SELECT.format(
  121. score="1 - (e.embedding <=> CAST(:query_embedding AS vector))",
  122. embedding_join="""
  123. JOIN public.knowledge_chunk_embeddings e ON e.chunk_id = c.id
  124. JOIN public.knowledge_embedding_profiles profile
  125. ON profile.id = e.profile_id AND profile.status = 'active'
  126. """,
  127. predicate="""
  128. ORDER BY e.embedding <=> CAST(:query_embedding AS vector), c.id
  129. LIMIT :limit
  130. """,
  131. )
  132. )
  133. rows = self._session.execute(
  134. statement,
  135. {
  136. "query_embedding": _vector(vectors[0]),
  137. "global_access": context.global_access,
  138. "domains": list(context.business_domain_uids),
  139. "limit": limit,
  140. },
  141. ).mappings()
  142. return tuple(_evidence(row, "vector") for row in rows)
  143. class UnavailableVectorRetriever:
  144. def __init__(self, reason: str = "query embedding is not configured") -> None:
  145. self._reason = reason
  146. def retrieve(self, _query, _context, _limit):
  147. raise RuntimeError(self._reason)