Merge dev_new to main (improve retriever and add querying) (#13)
Browse files- [noticket] add gitignore (c87f27f458854787102eebc110b178c2572ef8a6)
- [NOTICKET]: add document pipeline, simplify document API (fb871f3d7db9c5a9c95a012761c4180a432e7e4b)
- [NOTICKET]: update folder document_pipelines after pipelines (a4cf97ab3890e35831c0043a1d411c17d84f374b)
- [NOTICKET][DB] refactor code to new repo (7f3bb9782bb9b6ace69e6be35377c12757a2f81d)
- [KM-441] add mean and median (9b5933420821187e73bb72a896e28d7adf6d6137)
- [NOTICKET] new metadata format for cleaner code (6b590d94c398ac4966a0e376f2809aa52c5ba022)
- update document (5a69e0eecf80dd807565dbc369374e41450c37fc)
- delete duplicate file (3848d7b262745b758069e3ab8089437ced42a45f)
- edit document for new pipeline (425e021053c4de65e2598b0f5554b7c1044a904c)
- [NOTICKET]: add CSV and XLSX file type (31920c3bc6f9faf9e07095a02fc1faf08adc246e)
- [DB] fix/rename db_pipeline.py (d913315c356d741cc7a5a5b1f8e36ba41397f2ab)
- [NOTICKET][DB] menyesuaikan format struktur db_pipeline sesuai dengan file lain (e13a9017e0f7fd26ef9b8ad5981d6c0799c01661)
- [NOTICKET][DB] pisahin db credential ke folder model. add ingestion endpoint at db_client to use db pipeline. add router db_client di main. (347a73aa5597cb28d1f43940eb60db619e4a10be)
- [NOTICKET]: use tesseract for extract PDF (6b9a13d417f6ffffed61a44567a3acbee822e173)
- [NOTICKET]: add Tesseract and Poppler binaries via Git LFS (0a9101a16c713c3facd8c06279da1f01403306a0)
- [NOTICKET]: update uv.lock (bb79f64b69c3bdcb2c98867c5d4945bf2311394d)
- [NOTICKET][DB] update credential & databaseclient. update settings (0e079550f8524da66df365638167dfe42b70ae62)
- [NOTICKET] update settings (65a5c6b1c1539de5d66e700aa5bef011e3c2b2f6)
- [KM-437][DB] add mysql, sqlserver, bigquery, snowflake connections (43539293eab9328d1d6f22f2f837c0a0085485a5)
- [NOTICKET]: adjusted pyproject.toml for OCR PDF (a00e2ad5306306ff73c30d085f126fab7bd1e78c)
- [NOTICKET]: fix merge conflict (6c8734607da68151ab574d0d186b17ac0d8ec460)
- [NOTICKET][DB] fix mysql pipeline (060c8cc81d4de2e85d2f6b4d3de178307e211ad0)
- [NOTICKET] edit imports (b145c06e354030dc38a550cd53030ba4864870ae)
- [NOTICKET] minor code refactor (52415b6a52f08900d27b548459a15c1113c1b892)
- [NOTICKET] add duplicate check for storing database (d310770ffcbcf75cb6b952f86bc6947ab2c7bb6a)
- [NOTICKET][DB] add supported dbtype for frontend (a531fcc7f53fe56a634ebba3b0fd4ff7ad51a33d)
- [NOTICKET]: add doctypes endpoint & 10MB file size limit (9debae56609158434faf47bf2a4a7b70c4137290)
- [NOTICKET]: add comments to flag that file type lists must stay in sync (023b7cfeb1dd6f6c54df748feb1130e4a994bc26)
- [NOTICKET]: add to gitignore (bbc8c584ef676c15d913d04933b9b8ff2b8d98d8)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (7757da18fa23f89a4d731cf2648727a97c49b88c)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (9c090a04cf8a78322836d04e245632bbf1f04555)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (5398fec4138f7f2b944c0a3ddbdf1f858bac7e16)
- Merge branch 'dev_new' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (20bf3f8fa4393217cb50f9e330196b26bb138fa1)
- [NOTICKET] add total token logging (b9703fc5cfad1ec51ef67497559ef4a5235ea3d5)
- [NOTICKET] add updated_at field for metadata & delete old embedding before appending (cb5ab3273516de824aa254ff7c9fed0c5f8098aa)
- [NO TICKET][document]: add updated_at on metadata (d2f7a4833e1069abc3d3831381661def3af4ad8a)
- [NO TICKET][document]: delete vector embedding on table langchain_pg_embedding if user delete document on knowledge (ac3d8c19b4edb19fad7630b7ec5dabc39f9948ac)
- [NOTICKET][document]: make a clean output to status error unsupported file type (2814813f836a095af5b20552ec047cbc29c95821)
- [KM-438][KM-439] framework for knowledge retriever (d1e12641c43e217c684389dd6697f29ffc489226)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (a701ac3732bf0eb3da2b13f1801c89537986174e)
- [NOTICKET] fix single source to multiple sources (589ca324a5fec304a2d3652b65311e494c5a44b9)
- [NOTICKET] fixed multiple sources (e9f2a26317988ef59031c32726cae793ad135755)
- [KM-507] add multiple retrieval method to compare (dense, mmr, bm25, hybrid) (ac6b78d1a5448aab8a210416374bd3ffad4300dd)
- [KM-507] add changes to methods (82186504125f65d3767fee5371451847f435a9ea)
- [NOTICKET] add db_client for querying (e49db6011cc3be00efeb0807c02db0bbffa56999)
- add to gitignore (83ed7447e869f2d884d9cd341b7e20bac00f48cd)
- [KM-507] add different methods, now using dense cosine (145bca392470b81e16060feb040ab652f7185976)
- [KM-512] create folder for querying from bd/tabular docs (2c8a3e89bb68fe046beac5dc9934a8d6daaba15d)
- [NOTICKET] minor fix in chat.py, add package for query, change schema used to hybrid (cosine+bm25) (15cd3a7f887abe8994b45d80d4d7ade688120b88)
- [KM-512] add Pydantic model the LLM fills via function calling in sql_query, and add same signature for db and tabular (220f59eb60b88891e476c989b9549cc180cc08b7)
- [NOTICKET] rename file name, updated after uv sync (948d6ddaa2b4bdafef45a219169832d6c7b602bc)
- [NOTICKET] update .gitignore (240251c4050c9a6457522d0024e87f99dbfbbb13)
- Merge branch 'dev_new' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (29efec6750e81688c99f62f52b0fdc137da8fee8)
- [KM-513][document] add convert to parquet if type file is XLSX and CSV (770f26b143a795987628fbdb4716ee8c574a6e5c)
- add to gitignore (1fef470b29037577cde6246ec7f417f164fed72e)
- [KM-512] connect query executor to user question. add logging for db_executor (abc494f97718a67af0d237ebac88fb240f3b46d3)
- [NOTICKET] fix delete, now can filter by user (f273db057a8158fe7eac526688ffd52d9ebaf911)
- [NOTICKET] db_executor: CTE DML check now walks entire AST root, schema: cast instead of string interpolation (bd2b1d9ddf6664c2739563fbd04eaca279771ec0)
- [NOTICKET] fix-revert string change (110ee3438ef171e177c53f38b437e6dc8e927a42)
- [KM-520] Integrate db query executor pipeline with existing rag retrieve pipeline (a25febe2e68afe14a0c75352fe746dc9d0157643)
- [KM-516][KM-517] add new feature; ai can now see table & column names that have fk relationship with retrieved result (f86da27bdc2a60f0b38b61db90008094ee4d7146)
- [NOTICKET] fix query now use orchestrator msg, rework db pipeline replace ingestion logic (be9bbd9d4451e320e923627eca36f1442cae1932)
- [NOTICKET] edit settings (9e16c227411e7093aea1bd09b79c5bf60fd0431b)
- [KM 436-439] adjust endpoint for new features (0707f2ba972cd3b62aadd4cee8b445ba9aeb6ede)
- [KM-436-437] edit knowledge handler pipeline (767625e5fb1b1371135439c7b9c6cf1e6389d218)
- [KM-438-439] add retriever feature (ba550a5a0e6cd7a64dc471711116e4ce81402049)
Co-authored-by: Sofhia Az-Zahra <sofhiaazzhr@users.noreply.huggingface.co>
- .gitignore +2 -0
- pyproject.toml +2 -0
- src/agents/orchestration.py +5 -0
- src/api/v1/chat.py +50 -1
- src/api/v1/db_client.py +1 -1
- src/db/postgres/init_db.py +35 -0
- src/document/document_service.py +17 -1
- src/knowledge/parquet_service.py +77 -0
- src/knowledge/processing_service.py +21 -7
- src/models/sql_query.py +8 -0
- src/models/structured_output.py +4 -0
- src/pipeline/db_pipeline/db_pipeline_service.py +35 -18
- src/pipeline/document_pipeline/document_pipeline.py +5 -1
- src/query/__init__.py +0 -0
- src/query/base.py +32 -0
- src/query/executors/__init__.py +0 -0
- src/query/executors/db_executor.py +409 -0
- src/query/executors/tabular.py +39 -0
- src/query/query_executor.py +52 -0
- src/rag/base.py +20 -0
- src/rag/retriever.py +22 -48
- src/rag/retrievers/__init__.py +0 -0
- src/rag/retrievers/baseline.py +70 -0
- src/rag/retrievers/document.py +32 -0
- src/rag/retrievers/schema.py +349 -0
- src/rag/router.py +75 -0
- src/storage/az_blob/az_blob.py +34 -0
- uv.lock +11 -0
|
@@ -28,6 +28,8 @@ test/users/user_accounts.csv
|
|
| 28 |
|
| 29 |
CLAUDE.md
|
| 30 |
|
|
|
|
|
|
|
| 31 |
erd/
|
| 32 |
playground/
|
| 33 |
playground_retriever.py
|
|
|
|
| 28 |
|
| 29 |
CLAUDE.md
|
| 30 |
|
| 31 |
+
/experiments
|
| 32 |
+
src/rag/experiments/
|
| 33 |
erd/
|
| 34 |
playground/
|
| 35 |
playground_retriever.py
|
|
@@ -79,6 +79,8 @@ dependencies = [
|
|
| 79 |
"jsonpatch>=1.33",
|
| 80 |
"pymongo>=4.14.0",
|
| 81 |
"psycopg2>=2.9.11",
|
|
|
|
|
|
|
| 82 |
# --- User-DB connectors (db_pipeline) ---
|
| 83 |
"pymysql>=1.1.1",
|
| 84 |
"pymssql>=2.3.0",
|
|
|
|
| 79 |
"jsonpatch>=1.33",
|
| 80 |
"pymongo>=4.14.0",
|
| 81 |
"psycopg2>=2.9.11",
|
| 82 |
+
# --- SQL parsing / guardrails ---
|
| 83 |
+
"sqlglot>=25.0.0",
|
| 84 |
# --- User-DB connectors (db_pipeline) ---
|
| 85 |
"pymysql>=1.1.1",
|
| 86 |
"pymssql>=2.3.0",
|
|
@@ -35,6 +35,11 @@ Intent Routing:
|
|
| 35 |
- greeting -> needs_search=False, direct_response="Hello! How can I assist you today?"
|
| 36 |
- goodbye -> needs_search=False, direct_response="Goodbye! Have a great day!"
|
| 37 |
- other -> needs_search=True, search_query=<standalone rewritten query>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
"""),
|
| 39 |
MessagesPlaceholder(variable_name="history"),
|
| 40 |
("user", "{message}")
|
|
|
|
| 35 |
- greeting -> needs_search=False, direct_response="Hello! How can I assist you today?"
|
| 36 |
- goodbye -> needs_search=False, direct_response="Goodbye! Have a great day!"
|
| 37 |
- other -> needs_search=True, search_query=<standalone rewritten query>
|
| 38 |
+
|
| 39 |
+
Source Routing (set source_hint):
|
| 40 |
+
- Columns, tables, sheets, data types, schema, row counts, statistics -> source_hint=schema
|
| 41 |
+
- Document content, paragraphs, reports, articles, text -> source_hint=document
|
| 42 |
+
- Unclear or spans both -> source_hint=both
|
| 43 |
"""),
|
| 44 |
MessagesPlaceholder(variable_name="history"),
|
| 45 |
("user", "{message}")
|
|
@@ -9,6 +9,9 @@ from src.db.postgres.models import ChatMessage, MessageSource
|
|
| 9 |
from src.agents.orchestration import orchestrator
|
| 10 |
from src.agents.chatbot import chatbot
|
| 11 |
from src.rag.retriever import retriever
|
|
|
|
|
|
|
|
|
|
| 12 |
from src.db.redis.connection import get_redis
|
| 13 |
from src.config.settings import settings
|
| 14 |
from src.middlewares.logging import get_logger, log_execution
|
|
@@ -61,7 +64,7 @@ def _extract_sources(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
| 61 |
seen = set()
|
| 62 |
sources = []
|
| 63 |
for result in results:
|
| 64 |
-
if "document_id" in result["metadata"]
|
| 65 |
meta = result["metadata"]
|
| 66 |
key = (meta.get("data", {}).get("document_id"), meta.get("data", {}).get("page_label"))
|
| 67 |
if key not in seen:
|
|
@@ -88,6 +91,22 @@ def _extract_sources(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
| 88 |
return sources
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
async def get_cached_response(redis, cache_key: str) -> Optional[str]:
|
| 92 |
cached = await redis.get(cache_key)
|
| 93 |
if cached:
|
|
@@ -182,16 +201,25 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
|
|
| 182 |
|
| 183 |
if not intent_result.get("needs_search"):
|
| 184 |
retrieval_task.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
raw_results = []
|
| 186 |
else:
|
| 187 |
search_query = intent_result.get("search_query", request.message)
|
| 188 |
logger.info(f"Searching for: {search_query}")
|
| 189 |
if search_query != request.message:
|
| 190 |
retrieval_task.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
raw_results = await retriever.retrieve(
|
| 192 |
query=search_query,
|
| 193 |
user_id=request.user_id,
|
| 194 |
db=db,
|
|
|
|
| 195 |
)
|
| 196 |
else:
|
| 197 |
raw_results = await retrieval_task
|
|
@@ -199,6 +227,27 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
|
|
| 199 |
context = _format_context(raw_results)
|
| 200 |
sources = _extract_sources(raw_results)
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
# Step 3: Direct response for greetings / non-document intents
|
| 203 |
if intent_result.get("direct_response"):
|
| 204 |
response = intent_result["direct_response"]
|
|
|
|
| 9 |
from src.agents.orchestration import orchestrator
|
| 10 |
from src.agents.chatbot import chatbot
|
| 11 |
from src.rag.retriever import retriever
|
| 12 |
+
from src.rag.base import RetrievalResult
|
| 13 |
+
from src.query.query_executor import query_executor
|
| 14 |
+
from src.query.base import QueryResult
|
| 15 |
from src.db.redis.connection import get_redis
|
| 16 |
from src.config.settings import settings
|
| 17 |
from src.middlewares.logging import get_logger, log_execution
|
|
|
|
| 64 |
seen = set()
|
| 65 |
sources = []
|
| 66 |
for result in results:
|
| 67 |
+
if "document_id" in result["metadata"].get("data", {}):
|
| 68 |
meta = result["metadata"]
|
| 69 |
key = (meta.get("data", {}).get("document_id"), meta.get("data", {}).get("page_label"))
|
| 70 |
if key not in seen:
|
|
|
|
| 91 |
return sources
|
| 92 |
|
| 93 |
|
| 94 |
+
def _format_query_results(results: list[QueryResult]) -> str:
|
| 95 |
+
if not results:
|
| 96 |
+
return ""
|
| 97 |
+
lines = []
|
| 98 |
+
for r in results:
|
| 99 |
+
name = r.metadata.get("client_name", r.source_id)
|
| 100 |
+
lines.append(f"[Query result — {name}, tables: {r.table_or_file}]")
|
| 101 |
+
lines.append(f"SQL: {r.metadata.get('sql', '')}")
|
| 102 |
+
if r.columns and r.rows:
|
| 103 |
+
lines.append(" | ".join(r.columns))
|
| 104 |
+
for row in r.rows[:20]:
|
| 105 |
+
lines.append(" | ".join(str(row.get(c, "")) for c in r.columns))
|
| 106 |
+
lines.append(f"({r.row_count} rows total)\n")
|
| 107 |
+
return "\n".join(lines)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
async def get_cached_response(redis, cache_key: str) -> Optional[str]:
|
| 111 |
cached = await redis.get(cache_key)
|
| 112 |
if cached:
|
|
|
|
| 201 |
|
| 202 |
if not intent_result.get("needs_search"):
|
| 203 |
retrieval_task.cancel()
|
| 204 |
+
try:
|
| 205 |
+
await retrieval_task
|
| 206 |
+
except asyncio.CancelledError:
|
| 207 |
+
pass
|
| 208 |
raw_results = []
|
| 209 |
else:
|
| 210 |
search_query = intent_result.get("search_query", request.message)
|
| 211 |
logger.info(f"Searching for: {search_query}")
|
| 212 |
if search_query != request.message:
|
| 213 |
retrieval_task.cancel()
|
| 214 |
+
try:
|
| 215 |
+
await retrieval_task
|
| 216 |
+
except asyncio.CancelledError:
|
| 217 |
+
pass
|
| 218 |
raw_results = await retriever.retrieve(
|
| 219 |
query=search_query,
|
| 220 |
user_id=request.user_id,
|
| 221 |
db=db,
|
| 222 |
+
source_hint=intent_result.get("source_hint", "both"),
|
| 223 |
)
|
| 224 |
else:
|
| 225 |
raw_results = await retrieval_task
|
|
|
|
| 227 |
context = _format_context(raw_results)
|
| 228 |
sources = _extract_sources(raw_results)
|
| 229 |
|
| 230 |
+
source_hint = intent_result.get("source_hint", "both")
|
| 231 |
+
if source_hint in ("schema", "both"):
|
| 232 |
+
retrieval_objects = [
|
| 233 |
+
RetrievalResult(
|
| 234 |
+
content=r["content"],
|
| 235 |
+
metadata=r["metadata"],
|
| 236 |
+
score=0.0,
|
| 237 |
+
source_type=r["metadata"].get("source_type", ""),
|
| 238 |
+
)
|
| 239 |
+
for r in raw_results
|
| 240 |
+
]
|
| 241 |
+
query_results = await query_executor.execute(
|
| 242 |
+
results=retrieval_objects,
|
| 243 |
+
user_id=request.user_id,
|
| 244 |
+
db=db,
|
| 245 |
+
question=intent_result.get("search_query") or request.message,
|
| 246 |
+
)
|
| 247 |
+
query_context = _format_query_results(query_results)
|
| 248 |
+
if query_context:
|
| 249 |
+
context = query_context + "\n\n" + context
|
| 250 |
+
|
| 251 |
# Step 3: Direct response for greetings / non-document intents
|
| 252 |
if intent_result.get("direct_response"):
|
| 253 |
response = intent_result["direct_response"]
|
|
@@ -458,7 +458,7 @@ async def ingest_database_client(
|
|
| 458 |
db_type=client.db_type,
|
| 459 |
credentials=creds,
|
| 460 |
) as engine:
|
| 461 |
-
total = await db_pipeline_service.run(user_id=user_id, engine=engine)
|
| 462 |
except NotImplementedError as e:
|
| 463 |
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(e))
|
| 464 |
except Exception as e:
|
|
|
|
| 458 |
db_type=client.db_type,
|
| 459 |
credentials=creds,
|
| 460 |
) as engine:
|
| 461 |
+
total = await db_pipeline_service.run(user_id=user_id, client_id=client_id, engine=engine)
|
| 462 |
except NotImplementedError as e:
|
| 463 |
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(e))
|
| 464 |
except Exception as e:
|
|
@@ -28,3 +28,38 @@ async def init_db():
|
|
| 28 |
await conn.execute(text(
|
| 29 |
"ALTER TABLE rooms ADD COLUMN IF NOT EXISTS status VARCHAR NOT NULL DEFAULT 'active'"
|
| 30 |
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
await conn.execute(text(
|
| 29 |
"ALTER TABLE rooms ADD COLUMN IF NOT EXISTS status VARCHAR NOT NULL DEFAULT 'active'"
|
| 30 |
))
|
| 31 |
+
|
| 32 |
+
# HNSW index for fast approximate vector similarity search
|
| 33 |
+
# Only created when the embedding column has explicit dimensions (HNSW requirement).
|
| 34 |
+
# atttypmod > 0 means the vector column was created with a dimension (e.g. vector(1536));
|
| 35 |
+
# atttypmod = -1 means dimensionless — HNSW would fail with "column does not have dimensions".
|
| 36 |
+
await conn.execute(text("""
|
| 37 |
+
DO $$
|
| 38 |
+
BEGIN
|
| 39 |
+
IF EXISTS (
|
| 40 |
+
SELECT FROM pg_attribute a
|
| 41 |
+
JOIN pg_class c ON c.oid = a.attrelid
|
| 42 |
+
WHERE c.relname = 'langchain_pg_embedding'
|
| 43 |
+
AND a.attname = 'embedding'
|
| 44 |
+
AND a.atttypmod > 0
|
| 45 |
+
) THEN
|
| 46 |
+
CREATE INDEX IF NOT EXISTS idx_langchain_pg_embedding_hnsw
|
| 47 |
+
ON langchain_pg_embedding USING hnsw (embedding vector_cosine_ops);
|
| 48 |
+
END IF;
|
| 49 |
+
END $$
|
| 50 |
+
"""))
|
| 51 |
+
|
| 52 |
+
# GIN index for FTS on schema chunks — only created if table exists
|
| 53 |
+
# (langchain_pg_embedding is created by PGVector on first use, not by create_all)
|
| 54 |
+
await conn.execute(text("""
|
| 55 |
+
DO $$
|
| 56 |
+
BEGIN
|
| 57 |
+
IF EXISTS (
|
| 58 |
+
SELECT FROM information_schema.tables
|
| 59 |
+
WHERE table_name = 'langchain_pg_embedding'
|
| 60 |
+
) THEN
|
| 61 |
+
CREATE INDEX IF NOT EXISTS idx_langchain_pg_embedding_fts
|
| 62 |
+
ON langchain_pg_embedding USING GIN (to_tsvector('english', document));
|
| 63 |
+
END IF;
|
| 64 |
+
END $$
|
| 65 |
+
"""))
|
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""Service for managing documents."""
|
| 2 |
|
| 3 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
-
from sqlalchemy import select, delete
|
| 5 |
from src.db.postgres.models import Document
|
|
|
|
| 6 |
from src.storage.az_blob.az_blob import blob_storage
|
| 7 |
from src.middlewares.logging import get_logger
|
| 8 |
from typing import List, Optional
|
|
@@ -77,6 +78,21 @@ class DocumentService:
|
|
| 77 |
# Delete from blob storage
|
| 78 |
await blob_storage.delete_file(document.blob_name)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
# Delete from database
|
| 81 |
await db.execute(
|
| 82 |
delete(Document).where(Document.id == document_id)
|
|
|
|
| 1 |
"""Service for managing documents."""
|
| 2 |
|
| 3 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy import select, delete, text
|
| 5 |
from src.db.postgres.models import Document
|
| 6 |
+
from src.db.postgres.connection import _pgvector_engine
|
| 7 |
from src.storage.az_blob.az_blob import blob_storage
|
| 8 |
from src.middlewares.logging import get_logger
|
| 9 |
from typing import List, Optional
|
|
|
|
| 78 |
# Delete from blob storage
|
| 79 |
await blob_storage.delete_file(document.blob_name)
|
| 80 |
|
| 81 |
+
# Delete vector embeddings from pgvector (scoped to user + collection to avoid cross-user over-delete)
|
| 82 |
+
async with _pgvector_engine.begin() as conn:
|
| 83 |
+
await conn.execute(
|
| 84 |
+
text("""
|
| 85 |
+
DELETE FROM langchain_pg_embedding
|
| 86 |
+
WHERE cmetadata->>'user_id' = :user_id
|
| 87 |
+
AND cmetadata->>'source_type' = 'document'
|
| 88 |
+
AND cmetadata->'data'->>'document_id' = :doc_id
|
| 89 |
+
AND collection_id = (
|
| 90 |
+
SELECT uuid FROM langchain_pg_collection WHERE name = 'document_embeddings'
|
| 91 |
+
)
|
| 92 |
+
"""),
|
| 93 |
+
{"user_id": document.user_id, "doc_id": document_id},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
# Delete from database
|
| 97 |
await db.execute(
|
| 98 |
delete(Document).where(Document.id == document_id)
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parquet service — converts, uploads, downloads, and deletes Parquet files for CSV/XLSX.
|
| 2 |
+
|
| 3 |
+
Parquet files are stored in Azure Blob alongside the original document using
|
| 4 |
+
a deterministic naming convention based on document_id:
|
| 5 |
+
|
| 6 |
+
CSV: {user_id}/{document_id}.parquet
|
| 7 |
+
XLSX sheet: {user_id}/{document_id}__{safe_sheet_name}.parquet
|
| 8 |
+
|
| 9 |
+
This allows tabular.py to construct the correct blob name at retrieval time
|
| 10 |
+
without needing to store it separately, and allows document_pipeline.py to
|
| 11 |
+
delete all Parquet files for a document using a prefix delete.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import io
|
| 15 |
+
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from src.middlewares.logging import get_logger
|
| 19 |
+
from src.storage.az_blob.az_blob import blob_storage
|
| 20 |
+
|
| 21 |
+
logger = get_logger("parquet_service")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _safe_sheet_name(sheet_name: str) -> str:
|
| 25 |
+
return sheet_name.replace("/", "_").replace(" ", "_").replace("\\", "_")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parquet_blob_name(user_id: str, document_id: str, sheet_name: str | None = None) -> str:
|
| 29 |
+
"""Construct deterministic Parquet blob name."""
|
| 30 |
+
if sheet_name:
|
| 31 |
+
return f"{user_id}/{document_id}__{_safe_sheet_name(sheet_name)}.parquet"
|
| 32 |
+
return f"{user_id}/{document_id}.parquet"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _to_parquet_bytes(df: pd.DataFrame) -> bytes:
|
| 36 |
+
buf = io.BytesIO()
|
| 37 |
+
df.to_parquet(buf, index=False)
|
| 38 |
+
return buf.getvalue()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def upload_parquet(
|
| 42 |
+
df: pd.DataFrame,
|
| 43 |
+
user_id: str,
|
| 44 |
+
document_id: str,
|
| 45 |
+
sheet_name: str | None = None,
|
| 46 |
+
) -> str:
|
| 47 |
+
"""Convert DataFrame to Parquet and upload to Azure Blob. Returns blob_name."""
|
| 48 |
+
blob_name = parquet_blob_name(user_id, document_id, sheet_name)
|
| 49 |
+
parquet_bytes = _to_parquet_bytes(df)
|
| 50 |
+
await blob_storage.upload_bytes(parquet_bytes, blob_name)
|
| 51 |
+
logger.info(f"Uploaded Parquet {blob_name} ({len(parquet_bytes)} bytes)")
|
| 52 |
+
return blob_name
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def download_parquet(
|
| 56 |
+
user_id: str,
|
| 57 |
+
document_id: str,
|
| 58 |
+
sheet_name: str | None = None,
|
| 59 |
+
) -> pd.DataFrame:
|
| 60 |
+
"""Download Parquet from Azure Blob and return as DataFrame."""
|
| 61 |
+
blob_name = parquet_blob_name(user_id, document_id, sheet_name)
|
| 62 |
+
content = await blob_storage.download_file(blob_name)
|
| 63 |
+
df = pd.read_parquet(io.BytesIO(content))
|
| 64 |
+
logger.info(f"Downloaded Parquet {blob_name}: {len(df)} rows, {len(df.columns)} columns")
|
| 65 |
+
return df
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def delete_document_parquets(user_id: str, document_id: str) -> int:
|
| 69 |
+
"""Delete all Parquet files for a document (CSV = 1 file, XLSX = one per sheet).
|
| 70 |
+
|
| 71 |
+
Uses prefix delete: {user_id}/{document_id} matches all Parquet variants
|
| 72 |
+
for this document without touching the original blob (which uses a random UUID name).
|
| 73 |
+
"""
|
| 74 |
+
prefix = f"{user_id}/{document_id}"
|
| 75 |
+
deleted = await blob_storage.delete_blobs_with_prefix(prefix)
|
| 76 |
+
logger.info(f"Deleted {deleted} Parquet file(s) for document {document_id}")
|
| 77 |
+
return deleted
|
|
@@ -7,7 +7,9 @@ from src.storage.az_blob.az_blob import blob_storage
|
|
| 7 |
from src.db.postgres.models import Document as DBDocument
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
from src.middlewares.logging import get_logger
|
|
|
|
| 10 |
from typing import List
|
|
|
|
| 11 |
import sys
|
| 12 |
import docx
|
| 13 |
import pandas as pd
|
|
@@ -15,6 +17,8 @@ import pytesseract
|
|
| 15 |
from pdf2image import convert_from_bytes
|
| 16 |
from io import BytesIO
|
| 17 |
|
|
|
|
|
|
|
| 18 |
logger = get_logger("knowledge_processing")
|
| 19 |
|
| 20 |
|
|
@@ -41,9 +45,9 @@ class KnowledgeProcessingService:
|
|
| 41 |
if db_doc.file_type == "pdf":
|
| 42 |
documents = await self._build_pdf_documents(content, db_doc)
|
| 43 |
elif db_doc.file_type == "csv":
|
| 44 |
-
documents = self._build_csv_documents(content, db_doc)
|
| 45 |
elif db_doc.file_type == "xlsx":
|
| 46 |
-
documents = self._build_excel_documents(content, db_doc)
|
| 47 |
else:
|
| 48 |
text = self._extract_text(content, db_doc.file_type)
|
| 49 |
if not text.strip():
|
|
@@ -55,6 +59,7 @@ class KnowledgeProcessingService:
|
|
| 55 |
metadata={
|
| 56 |
"user_id": db_doc.user_id,
|
| 57 |
"source_type": "document",
|
|
|
|
| 58 |
"data": {
|
| 59 |
"document_id": db_doc.id,
|
| 60 |
"filename": db_doc.filename,
|
|
@@ -103,6 +108,7 @@ class KnowledgeProcessingService:
|
|
| 103 |
metadata={
|
| 104 |
"user_id": db_doc.user_id,
|
| 105 |
"source_type": "document",
|
|
|
|
| 106 |
"data": {
|
| 107 |
"document_id": db_doc.id,
|
| 108 |
"filename": db_doc.filename,
|
|
@@ -150,6 +156,7 @@ class KnowledgeProcessingService:
|
|
| 150 |
metadata={
|
| 151 |
"user_id": db_doc.user_id,
|
| 152 |
"source_type": "document",
|
|
|
|
| 153 |
"data": {
|
| 154 |
"document_id": db_doc.id,
|
| 155 |
"filename": db_doc.filename,
|
|
@@ -162,18 +169,25 @@ class KnowledgeProcessingService:
|
|
| 162 |
))
|
| 163 |
return documents
|
| 164 |
|
| 165 |
-
def _build_csv_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
|
| 166 |
-
"""Profile each column of a CSV file."""
|
| 167 |
df = pd.read_csv(BytesIO(content))
|
|
|
|
|
|
|
| 168 |
return self._profile_dataframe(df, db_doc.filename, db_doc)
|
| 169 |
|
| 170 |
-
def _build_excel_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
|
| 171 |
-
"""Profile each column of every sheet in an Excel file."""
|
| 172 |
sheets = pd.read_excel(BytesIO(content), sheet_name=None)
|
| 173 |
documents = []
|
| 174 |
for sheet_name, df in sheets.items():
|
| 175 |
source_name = f"{db_doc.filename} / sheet: {sheet_name}"
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
return documents
|
| 178 |
|
| 179 |
def _extract_text(self, content: bytes, file_type: str) -> str:
|
|
|
|
| 7 |
from src.db.postgres.models import Document as DBDocument
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
from src.middlewares.logging import get_logger
|
| 10 |
+
from src.knowledge.parquet_service import upload_parquet
|
| 11 |
from typing import List
|
| 12 |
+
from datetime import datetime, timezone, timedelta
|
| 13 |
import sys
|
| 14 |
import docx
|
| 15 |
import pandas as pd
|
|
|
|
| 17 |
from pdf2image import convert_from_bytes
|
| 18 |
from io import BytesIO
|
| 19 |
|
| 20 |
+
_JAKARTA_TZ = timezone(timedelta(hours=7))
|
| 21 |
+
|
| 22 |
logger = get_logger("knowledge_processing")
|
| 23 |
|
| 24 |
|
|
|
|
| 45 |
if db_doc.file_type == "pdf":
|
| 46 |
documents = await self._build_pdf_documents(content, db_doc)
|
| 47 |
elif db_doc.file_type == "csv":
|
| 48 |
+
documents = await self._build_csv_documents(content, db_doc)
|
| 49 |
elif db_doc.file_type == "xlsx":
|
| 50 |
+
documents = await self._build_excel_documents(content, db_doc)
|
| 51 |
else:
|
| 52 |
text = self._extract_text(content, db_doc.file_type)
|
| 53 |
if not text.strip():
|
|
|
|
| 59 |
metadata={
|
| 60 |
"user_id": db_doc.user_id,
|
| 61 |
"source_type": "document",
|
| 62 |
+
"updated_at": datetime.now(_JAKARTA_TZ).isoformat(),
|
| 63 |
"data": {
|
| 64 |
"document_id": db_doc.id,
|
| 65 |
"filename": db_doc.filename,
|
|
|
|
| 108 |
metadata={
|
| 109 |
"user_id": db_doc.user_id,
|
| 110 |
"source_type": "document",
|
| 111 |
+
"updated_at": datetime.now(_JAKARTA_TZ).isoformat(),
|
| 112 |
"data": {
|
| 113 |
"document_id": db_doc.id,
|
| 114 |
"filename": db_doc.filename,
|
|
|
|
| 156 |
metadata={
|
| 157 |
"user_id": db_doc.user_id,
|
| 158 |
"source_type": "document",
|
| 159 |
+
"updated_at": datetime.now(_JAKARTA_TZ).isoformat(),
|
| 160 |
"data": {
|
| 161 |
"document_id": db_doc.id,
|
| 162 |
"filename": db_doc.filename,
|
|
|
|
| 169 |
))
|
| 170 |
return documents
|
| 171 |
|
| 172 |
+
async def _build_csv_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
|
| 173 |
+
"""Profile each column of a CSV file and upload Parquet to Azure Blob."""
|
| 174 |
df = pd.read_csv(BytesIO(content))
|
| 175 |
+
await upload_parquet(df, db_doc.user_id, db_doc.id)
|
| 176 |
+
logger.info(f"Uploaded Parquet for CSV {db_doc.id}")
|
| 177 |
return self._profile_dataframe(df, db_doc.filename, db_doc)
|
| 178 |
|
| 179 |
+
async def _build_excel_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
|
| 180 |
+
"""Profile each column of every sheet in an Excel file and upload one Parquet per sheet."""
|
| 181 |
sheets = pd.read_excel(BytesIO(content), sheet_name=None)
|
| 182 |
documents = []
|
| 183 |
for sheet_name, df in sheets.items():
|
| 184 |
source_name = f"{db_doc.filename} / sheet: {sheet_name}"
|
| 185 |
+
docs = self._profile_dataframe(df, source_name, db_doc)
|
| 186 |
+
for doc in docs:
|
| 187 |
+
doc.metadata["data"]["sheet_name"] = sheet_name
|
| 188 |
+
documents.extend(docs)
|
| 189 |
+
await upload_parquet(df, db_doc.user_id, db_doc.id, sheet_name)
|
| 190 |
+
logger.info(f"Uploaded Parquet for sheet '{sheet_name}' of {db_doc.id}")
|
| 191 |
return documents
|
| 192 |
|
| 193 |
def _extract_text(self, content: bytes, file_type: str) -> str:
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured output model for LLM-generated SQL queries."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SQLQuery(BaseModel):
|
| 7 |
+
sql: str = Field(description="A single SQL SELECT statement. No markdown, no explanation inline.")
|
| 8 |
+
reasoning: str = Field(description="One sentence: what this query answers.")
|
|
@@ -19,3 +19,7 @@ class IntentClassification(BaseModel):
|
|
| 19 |
default="",
|
| 20 |
description="Direct response if no search needed (for greetings, etc.)"
|
| 21 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
default="",
|
| 20 |
description="Direct response if no search needed (for greetings, etc.)"
|
| 21 |
)
|
| 22 |
+
source_hint: str = Field(
|
| 23 |
+
default="both",
|
| 24 |
+
description="Which sources to search: 'document' (PDF/DOCX/TXT), 'schema' (DB/CSV/XLSX), or 'both'"
|
| 25 |
+
)
|
|
@@ -148,7 +148,7 @@ class DbPipelineService:
|
|
| 148 |
engine.dispose()
|
| 149 |
|
| 150 |
def _to_document(
|
| 151 |
-
self, user_id: str, table_name: str, entry: dict, updated_at: str
|
| 152 |
) -> LangChainDocument:
|
| 153 |
col = entry["col"]
|
| 154 |
return LangChainDocument(
|
|
@@ -156,6 +156,7 @@ class DbPipelineService:
|
|
| 156 |
metadata={
|
| 157 |
"user_id": user_id,
|
| 158 |
"source_type": "database",
|
|
|
|
| 159 |
"updated_at": updated_at,
|
| 160 |
"data": {
|
| 161 |
"table_name": table_name,
|
|
@@ -170,6 +171,7 @@ class DbPipelineService:
|
|
| 170 |
async def run(
|
| 171 |
self,
|
| 172 |
user_id: str,
|
|
|
|
| 173 |
engine: Engine,
|
| 174 |
exclude_tables: Optional[frozenset[str]] = None,
|
| 175 |
) -> int:
|
|
@@ -181,35 +183,50 @@ class DbPipelineService:
|
|
| 181 |
vector_store = get_vector_store()
|
| 182 |
logger.info("db pipeline start", user_id=user_id)
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
async with _pgvector_engine.begin() as conn:
|
| 185 |
result = await conn.execute(
|
| 186 |
text(
|
| 187 |
"DELETE FROM langchain_pg_embedding "
|
| 188 |
"WHERE cmetadata->>'user_id' = :user_id "
|
| 189 |
" AND cmetadata->>'source_type' = 'database' "
|
|
|
|
|
|
|
| 190 |
" AND collection_id = ("
|
| 191 |
" SELECT uuid FROM langchain_pg_collection WHERE name = 'document_embeddings'"
|
| 192 |
" )"
|
| 193 |
),
|
| 194 |
-
{"user_id": user_id},
|
| 195 |
)
|
| 196 |
-
logger.info("cleared
|
| 197 |
-
|
| 198 |
-
schema = await asyncio.to_thread(get_schema, engine, exclude_tables)
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
for table_name, columns in schema.items():
|
| 203 |
-
logger.info("profiling table", table=table_name, columns=len(columns))
|
| 204 |
-
entries = await asyncio.to_thread(profile_table, engine, table_name, columns)
|
| 205 |
-
docs = [self._to_document(user_id, table_name, e, updated_at) for e in entries]
|
| 206 |
-
if docs:
|
| 207 |
-
await vector_store.aadd_documents(docs)
|
| 208 |
-
total += len(docs)
|
| 209 |
-
logger.info("ingested chunks", table=table_name, count=len(docs))
|
| 210 |
-
|
| 211 |
-
logger.info("db pipeline complete", user_id=user_id, total=total)
|
| 212 |
-
return total
|
| 213 |
|
| 214 |
|
| 215 |
db_pipeline_service = DbPipelineService()
|
|
|
|
| 148 |
engine.dispose()
|
| 149 |
|
| 150 |
def _to_document(
|
| 151 |
+
self, user_id: str, client_id: str, table_name: str, entry: dict, updated_at: str
|
| 152 |
) -> LangChainDocument:
|
| 153 |
col = entry["col"]
|
| 154 |
return LangChainDocument(
|
|
|
|
| 156 |
metadata={
|
| 157 |
"user_id": user_id,
|
| 158 |
"source_type": "database",
|
| 159 |
+
"database_client_id": client_id,
|
| 160 |
"updated_at": updated_at,
|
| 161 |
"data": {
|
| 162 |
"table_name": table_name,
|
|
|
|
| 171 |
async def run(
|
| 172 |
self,
|
| 173 |
user_id: str,
|
| 174 |
+
client_id: str,
|
| 175 |
engine: Engine,
|
| 176 |
exclude_tables: Optional[frozenset[str]] = None,
|
| 177 |
) -> int:
|
|
|
|
| 183 |
vector_store = get_vector_store()
|
| 184 |
logger.info("db pipeline start", user_id=user_id)
|
| 185 |
|
| 186 |
+
# Profile first — if this fails, old embeddings are untouched
|
| 187 |
+
schema = await asyncio.to_thread(get_schema, engine, exclude_tables)
|
| 188 |
+
|
| 189 |
+
updated_at = datetime.now(timezone(timedelta(hours=7))).isoformat()
|
| 190 |
+
all_docs: list = []
|
| 191 |
+
for table_name, columns in schema.items():
|
| 192 |
+
logger.info("profiling table", table=table_name, columns=len(columns))
|
| 193 |
+
entries = await asyncio.to_thread(profile_table, engine, table_name, columns)
|
| 194 |
+
docs = [self._to_document(user_id, client_id, table_name, e, updated_at) for e in entries]
|
| 195 |
+
all_docs.extend(docs)
|
| 196 |
+
logger.info("profiled table", table=table_name, count=len(docs))
|
| 197 |
+
|
| 198 |
+
# Insert new chunks first; only delete stale chunks after the insert succeeds.
|
| 199 |
+
# Prevents data loss if aadd_documents fails — old embeddings stay queryable
|
| 200 |
+
# until they're proven replaceable. Stale rows are identified by an older
|
| 201 |
+
# updated_at than this run.
|
| 202 |
+
if not all_docs:
|
| 203 |
+
logger.warning(
|
| 204 |
+
"no docs produced from schema; skipping delete to preserve existing embeddings",
|
| 205 |
+
user_id=user_id,
|
| 206 |
+
client_id=client_id,
|
| 207 |
+
)
|
| 208 |
+
return 0
|
| 209 |
+
|
| 210 |
+
await vector_store.aadd_documents(all_docs)
|
| 211 |
+
|
| 212 |
async with _pgvector_engine.begin() as conn:
|
| 213 |
result = await conn.execute(
|
| 214 |
text(
|
| 215 |
"DELETE FROM langchain_pg_embedding "
|
| 216 |
"WHERE cmetadata->>'user_id' = :user_id "
|
| 217 |
" AND cmetadata->>'source_type' = 'database' "
|
| 218 |
+
" AND cmetadata->>'database_client_id' = :client_id "
|
| 219 |
+
" AND cmetadata->>'updated_at' < :updated_at "
|
| 220 |
" AND collection_id = ("
|
| 221 |
" SELECT uuid FROM langchain_pg_collection WHERE name = 'document_embeddings'"
|
| 222 |
" )"
|
| 223 |
),
|
| 224 |
+
{"user_id": user_id, "client_id": client_id, "updated_at": updated_at},
|
| 225 |
)
|
| 226 |
+
logger.info("cleared stale db embeddings", user_id=user_id, deleted=result.rowcount)
|
|
|
|
|
|
|
| 227 |
|
| 228 |
+
logger.info("db pipeline complete", user_id=user_id, total=len(all_docs))
|
| 229 |
+
return len(all_docs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
db_pipeline_service = DbPipelineService()
|
|
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 5 |
|
| 6 |
from src.document.document_service import document_service
|
| 7 |
from src.knowledge.processing_service import knowledge_processor
|
|
|
|
| 8 |
from src.middlewares.logging import get_logger
|
| 9 |
from src.storage.az_blob.az_blob import blob_storage
|
| 10 |
|
|
@@ -32,7 +33,7 @@ class DocumentPipeline:
|
|
| 32 |
if file_type not in SUPPORTED_FILE_TYPES:
|
| 33 |
raise HTTPException(
|
| 34 |
status_code=400,
|
| 35 |
-
detail=f"Unsupported file type. Supported: {SUPPORTED_FILE_TYPES}",
|
| 36 |
)
|
| 37 |
|
| 38 |
blob_name = await blob_storage.upload_file(content, file.filename, user_id)
|
|
@@ -81,6 +82,9 @@ class DocumentPipeline:
|
|
| 81 |
|
| 82 |
await document_service.delete_document(db, document_id)
|
| 83 |
|
|
|
|
|
|
|
|
|
|
| 84 |
logger.info(f"Deleted document {document_id} for user {user_id}")
|
| 85 |
return {"document_id": document_id}
|
| 86 |
|
|
|
|
| 5 |
|
| 6 |
from src.document.document_service import document_service
|
| 7 |
from src.knowledge.processing_service import knowledge_processor
|
| 8 |
+
from src.knowledge.parquet_service import delete_document_parquets
|
| 9 |
from src.middlewares.logging import get_logger
|
| 10 |
from src.storage.az_blob.az_blob import blob_storage
|
| 11 |
|
|
|
|
| 33 |
if file_type not in SUPPORTED_FILE_TYPES:
|
| 34 |
raise HTTPException(
|
| 35 |
status_code=400,
|
| 36 |
+
detail=f"Unsupported file type. Supported: {', '.join(SUPPORTED_FILE_TYPES)}",
|
| 37 |
)
|
| 38 |
|
| 39 |
blob_name = await blob_storage.upload_file(content, file.filename, user_id)
|
|
|
|
| 82 |
|
| 83 |
await document_service.delete_document(db, document_id)
|
| 84 |
|
| 85 |
+
if document.file_type in ("csv", "xlsx"):
|
| 86 |
+
await delete_document_parquets(user_id, document_id)
|
| 87 |
+
|
| 88 |
logger.info(f"Deleted document {document_id} for user {user_id}")
|
| 89 |
return {"document_id": document_id}
|
| 90 |
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared contract for query executors."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from src.rag.base import RetrievalResult
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class QueryResult:
|
| 13 |
+
source_type: str # "database" or "document"
|
| 14 |
+
source_id: str # database_client_id or document_id
|
| 15 |
+
table_or_file: str
|
| 16 |
+
columns: list[str]
|
| 17 |
+
rows: list[dict]
|
| 18 |
+
row_count: int
|
| 19 |
+
metadata: dict = field(default_factory=dict)
|
| 20 |
+
# metadata should include "column_types": {"col_name": "dtype"} when available
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class BaseExecutor(ABC):
|
| 24 |
+
@abstractmethod
|
| 25 |
+
async def execute(
|
| 26 |
+
self,
|
| 27 |
+
results: list[RetrievalResult],
|
| 28 |
+
user_id: str,
|
| 29 |
+
db: AsyncSession,
|
| 30 |
+
question: str,
|
| 31 |
+
limit: int = 100,
|
| 32 |
+
) -> list[QueryResult]: ...
|
|
File without changes
|
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Executor for registered database sources (source_type="database").
|
| 2 |
+
|
| 3 |
+
Flow per (client_id, question):
|
| 4 |
+
1. Collect all relevant (table_name, column_name) pairs from retrieval results.
|
| 5 |
+
2. Fetch the FULL schema for those tables from PGVector (not just top-k columns).
|
| 6 |
+
3. Build a schema context string and send to LLM → structured SQLQuery output.
|
| 7 |
+
4. Validate via sqlglot: SELECT-only, schema-grounded, LIMIT enforced.
|
| 8 |
+
5. Execute on the user's DB via engine_scope + asyncio.to_thread.
|
| 9 |
+
6. Return QueryResult per client_id (may span multiple tables via JOINs).
|
| 10 |
+
|
| 11 |
+
Supported db_types: postgres, supabase, mysql.
|
| 12 |
+
Other types are skipped with a warning — they do not raise.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import sqlglot
|
| 20 |
+
import sqlglot.expressions as exp
|
| 21 |
+
import tiktoken
|
| 22 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 23 |
+
from langchain_openai import AzureChatOpenAI
|
| 24 |
+
from sqlalchemy import text
|
| 25 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 26 |
+
|
| 27 |
+
from src.config.settings import settings
|
| 28 |
+
from src.database_client.database_client_service import database_client_service
|
| 29 |
+
from src.db.postgres.connection import _pgvector_engine
|
| 30 |
+
from src.middlewares.logging import get_logger
|
| 31 |
+
from src.models.sql_query import SQLQuery
|
| 32 |
+
from src.pipeline.db_pipeline import db_pipeline_service
|
| 33 |
+
from src.query.base import BaseExecutor, QueryResult
|
| 34 |
+
from src.rag.base import RetrievalResult
|
| 35 |
+
from src.utils.db_credential_encryption import decrypt_credentials_dict
|
| 36 |
+
|
| 37 |
+
logger = get_logger("db_executor")
|
| 38 |
+
|
| 39 |
+
_enc = tiktoken.get_encoding("cl100k_base")
|
| 40 |
+
|
| 41 |
+
_SUPPORTED_DB_TYPES = {"postgres", "supabase", "mysql"}
|
| 42 |
+
_MAX_RETRIES = 3
|
| 43 |
+
_MAX_LIMIT = 500
|
| 44 |
+
|
| 45 |
+
_SQL_SYSTEM_PROMPT = """\
|
| 46 |
+
You are a SQL data analyst working with a user's database.
|
| 47 |
+
Generate a single SQL SELECT statement that answers the user's question.
|
| 48 |
+
|
| 49 |
+
Database dialect: {dialect}
|
| 50 |
+
|
| 51 |
+
Rules:
|
| 52 |
+
- ONLY reference tables and columns listed in the schema below. Do not invent names.
|
| 53 |
+
- Always include a LIMIT clause (max {limit}).
|
| 54 |
+
- Do not use DELETE, UPDATE, INSERT, DROP, TRUNCATE, ALTER, CREATE, or any DDL.
|
| 55 |
+
- Prefer explicit JOINs over subqueries when combining tables.
|
| 56 |
+
- For aggregations, always alias the result column (e.g. COUNT(*) AS order_count).
|
| 57 |
+
- For date filtering, use dialect-appropriate functions ({dialect} syntax).
|
| 58 |
+
|
| 59 |
+
Schema:
|
| 60 |
+
{schema}
|
| 61 |
+
|
| 62 |
+
{error_section}"""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class DbExecutor(BaseExecutor):
|
| 66 |
+
def __init__(self) -> None:
|
| 67 |
+
self._llm = AzureChatOpenAI(
|
| 68 |
+
azure_deployment=settings.azureai_deployment_name_4o,
|
| 69 |
+
openai_api_version=settings.azureai_api_version_4o,
|
| 70 |
+
azure_endpoint=settings.azureai_endpoint_url_4o,
|
| 71 |
+
api_key=settings.azureai_api_key_4o,
|
| 72 |
+
temperature=0,
|
| 73 |
+
)
|
| 74 |
+
self._prompt = ChatPromptTemplate.from_messages([
|
| 75 |
+
("system", _SQL_SYSTEM_PROMPT),
|
| 76 |
+
("human", "{question}"),
|
| 77 |
+
])
|
| 78 |
+
self._chain = self._prompt | self._llm.with_structured_output(SQLQuery)
|
| 79 |
+
|
| 80 |
+
# ------------------------------------------------------------------
|
| 81 |
+
# Public interface
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
async def execute(
|
| 85 |
+
self,
|
| 86 |
+
results: list[RetrievalResult],
|
| 87 |
+
user_id: str,
|
| 88 |
+
db: AsyncSession,
|
| 89 |
+
question: str,
|
| 90 |
+
limit: int = 100,
|
| 91 |
+
) -> list[QueryResult]:
|
| 92 |
+
db_results = [r for r in results if r.source_type == "database"]
|
| 93 |
+
if not db_results:
|
| 94 |
+
return []
|
| 95 |
+
|
| 96 |
+
# Group by client_id — one SQL generation + execution pass per client
|
| 97 |
+
by_client: dict[str, list[RetrievalResult]] = defaultdict(list)
|
| 98 |
+
for r in db_results:
|
| 99 |
+
client_id = r.metadata.get("database_client_id", "")
|
| 100 |
+
if client_id:
|
| 101 |
+
by_client[client_id].append(r)
|
| 102 |
+
else:
|
| 103 |
+
logger.warning("db result missing database_client_id, skipping")
|
| 104 |
+
|
| 105 |
+
query_results: list[QueryResult] = []
|
| 106 |
+
for client_id, client_results in by_client.items():
|
| 107 |
+
try:
|
| 108 |
+
qr = await self._execute_for_client(client_id, client_results, user_id, db, question, limit)
|
| 109 |
+
if qr:
|
| 110 |
+
query_results.append(qr)
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error("db executor failed for client", client_id=client_id, error=str(e))
|
| 113 |
+
|
| 114 |
+
return query_results
|
| 115 |
+
|
| 116 |
+
# ------------------------------------------------------------------
|
| 117 |
+
# Per-client execution
|
| 118 |
+
# ------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
async def _execute_for_client(
|
| 121 |
+
self,
|
| 122 |
+
client_id: str,
|
| 123 |
+
results: list[RetrievalResult],
|
| 124 |
+
user_id: str,
|
| 125 |
+
db: AsyncSession,
|
| 126 |
+
question: str,
|
| 127 |
+
limit: int,
|
| 128 |
+
) -> QueryResult | None:
|
| 129 |
+
client = await database_client_service.get(db, client_id)
|
| 130 |
+
if not client:
|
| 131 |
+
logger.warning("database client not found", client_id=client_id)
|
| 132 |
+
return None
|
| 133 |
+
if client.user_id != user_id:
|
| 134 |
+
logger.warning("client ownership mismatch", client_id=client_id)
|
| 135 |
+
return None
|
| 136 |
+
if client.db_type not in _SUPPORTED_DB_TYPES:
|
| 137 |
+
logger.warning("unsupported db_type for query execution", db_type=client.db_type)
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
# Distinct table names from retrieval results, expanded via FK relationships
|
| 141 |
+
table_names = list({
|
| 142 |
+
r.metadata.get("data", {}).get("table_name")
|
| 143 |
+
for r in results
|
| 144 |
+
if r.metadata.get("data", {}).get("table_name")
|
| 145 |
+
})
|
| 146 |
+
table_names = await self._expand_with_fk_tables(client_id, user_id, table_names)
|
| 147 |
+
|
| 148 |
+
full_schema = await self._fetch_full_schema(client_id, table_names, user_id)
|
| 149 |
+
if not full_schema:
|
| 150 |
+
logger.warning("no schema found in vector store", client_id=client_id, tables=table_names)
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
schema_ctx = self._build_schema_context(full_schema)
|
| 154 |
+
capped_limit = min(limit, _MAX_LIMIT)
|
| 155 |
+
dialect = client.db_type
|
| 156 |
+
|
| 157 |
+
# SQL generation with retry
|
| 158 |
+
validated_sql: str | None = None
|
| 159 |
+
prev_error: str = ""
|
| 160 |
+
prev_reasoning: str = ""
|
| 161 |
+
for attempt in range(_MAX_RETRIES):
|
| 162 |
+
if prev_error:
|
| 163 |
+
error_section = (
|
| 164 |
+
f"Previous attempt reasoning: {prev_reasoning}\n"
|
| 165 |
+
f"Previous attempt failed: {prev_error}\n"
|
| 166 |
+
"Fix the issue above."
|
| 167 |
+
)
|
| 168 |
+
else:
|
| 169 |
+
error_section = ""
|
| 170 |
+
try:
|
| 171 |
+
prompt_text = schema_ctx + error_section + question
|
| 172 |
+
input_tokens = len(_enc.encode(prompt_text))
|
| 173 |
+
logger.info("sql generation input tokens", attempt=attempt + 1, tokens=input_tokens)
|
| 174 |
+
|
| 175 |
+
result: SQLQuery = await self._chain.ainvoke({
|
| 176 |
+
"schema": schema_ctx,
|
| 177 |
+
"dialect": dialect,
|
| 178 |
+
"limit": capped_limit,
|
| 179 |
+
"error_section": error_section,
|
| 180 |
+
"question": question,
|
| 181 |
+
})
|
| 182 |
+
sql = result.sql.strip()
|
| 183 |
+
validation_error = self._validate(sql, full_schema, capped_limit)
|
| 184 |
+
if validation_error:
|
| 185 |
+
prev_error = validation_error
|
| 186 |
+
prev_reasoning = result.reasoning
|
| 187 |
+
logger.warning("sql validation failed", attempt=attempt + 1, error=validation_error)
|
| 188 |
+
continue
|
| 189 |
+
validated_sql = self._enforce_limit(sql, capped_limit)
|
| 190 |
+
output_tokens = len(_enc.encode(result.sql)) + len(_enc.encode(result.reasoning))
|
| 191 |
+
logger.info(
|
| 192 |
+
"sql generated",
|
| 193 |
+
attempt=attempt + 1,
|
| 194 |
+
input_tokens=input_tokens,
|
| 195 |
+
output_tokens=output_tokens,
|
| 196 |
+
total_tokens=input_tokens + output_tokens,
|
| 197 |
+
reasoning=result.reasoning,
|
| 198 |
+
)
|
| 199 |
+
break
|
| 200 |
+
except Exception as e:
|
| 201 |
+
prev_error = str(e)
|
| 202 |
+
logger.warning("sql generation error", attempt=attempt + 1, error=prev_error)
|
| 203 |
+
|
| 204 |
+
if not validated_sql:
|
| 205 |
+
logger.error("sql generation failed after retries", client_id=client_id)
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
# Execute on user's DB
|
| 209 |
+
creds = decrypt_credentials_dict(client.credentials)
|
| 210 |
+
with db_pipeline_service.engine_scope(client.db_type, creds) as engine:
|
| 211 |
+
rows = await asyncio.to_thread(self._run_sql, engine, validated_sql)
|
| 212 |
+
|
| 213 |
+
column_types = {
|
| 214 |
+
col["name"]: col["type"]
|
| 215 |
+
for cols in full_schema.values()
|
| 216 |
+
for col in cols
|
| 217 |
+
}
|
| 218 |
+
columns = list(rows[0].keys()) if rows else []
|
| 219 |
+
|
| 220 |
+
return QueryResult(
|
| 221 |
+
source_type="database",
|
| 222 |
+
source_id=client_id,
|
| 223 |
+
table_or_file=", ".join(table_names),
|
| 224 |
+
columns=columns,
|
| 225 |
+
rows=rows,
|
| 226 |
+
row_count=len(rows),
|
| 227 |
+
metadata={
|
| 228 |
+
"db_type": client.db_type,
|
| 229 |
+
"client_name": client.name,
|
| 230 |
+
"sql": validated_sql,
|
| 231 |
+
"column_types": {c: column_types.get(c, "unknown") for c in columns},
|
| 232 |
+
},
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# ------------------------------------------------------------------
|
| 236 |
+
# Schema helpers
|
| 237 |
+
# ------------------------------------------------------------------
|
| 238 |
+
|
| 239 |
+
async def _expand_with_fk_tables(
|
| 240 |
+
self,
|
| 241 |
+
client_id: str,
|
| 242 |
+
user_id: str,
|
| 243 |
+
table_names: list[str],
|
| 244 |
+
) -> list[str]:
|
| 245 |
+
"""Expand table_names with any tables FK-referenced by the retrieved tables.
|
| 246 |
+
|
| 247 |
+
Prevents SQL generation failures when a required table (e.g. orders) wasn't
|
| 248 |
+
returned by retrieval but is referenced via FK from a table that was
|
| 249 |
+
(e.g. order_items.order_id -> orders.id).
|
| 250 |
+
"""
|
| 251 |
+
if not table_names:
|
| 252 |
+
return table_names
|
| 253 |
+
|
| 254 |
+
placeholders = ", ".join(f":t{i}" for i in range(len(table_names)))
|
| 255 |
+
sql = text(f"""
|
| 256 |
+
SELECT DISTINCT lpe.cmetadata->'data'->>'foreign_key' AS fk
|
| 257 |
+
FROM langchain_pg_embedding lpe
|
| 258 |
+
JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
|
| 259 |
+
WHERE lpc.name = 'document_embeddings'
|
| 260 |
+
AND lpe.cmetadata->>'user_id' = :user_id
|
| 261 |
+
AND lpe.cmetadata->>'source_type' = 'database'
|
| 262 |
+
AND lpe.cmetadata->>'database_client_id' = :client_id
|
| 263 |
+
AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders})
|
| 264 |
+
AND lpe.cmetadata->'data'->>'foreign_key' IS NOT NULL
|
| 265 |
+
""")
|
| 266 |
+
|
| 267 |
+
params: dict[str, Any] = {"user_id": user_id, "client_id": client_id}
|
| 268 |
+
for i, name in enumerate(table_names):
|
| 269 |
+
params[f"t{i}"] = name
|
| 270 |
+
|
| 271 |
+
async with _pgvector_engine.connect() as conn:
|
| 272 |
+
result = await conn.execute(sql, params)
|
| 273 |
+
rows = result.fetchall()
|
| 274 |
+
|
| 275 |
+
expanded = set(table_names)
|
| 276 |
+
for row in rows:
|
| 277 |
+
fk = row.fk # format: "referred_table.referred_column"
|
| 278 |
+
if fk:
|
| 279 |
+
referred_table = fk.split(".")[0]
|
| 280 |
+
expanded.add(referred_table)
|
| 281 |
+
|
| 282 |
+
if expanded != set(table_names):
|
| 283 |
+
logger.info(
|
| 284 |
+
"expanded tables via FK",
|
| 285 |
+
original=sorted(table_names),
|
| 286 |
+
expanded=sorted(expanded),
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
return list(expanded)
|
| 290 |
+
|
| 291 |
+
async def _fetch_full_schema(
|
| 292 |
+
self,
|
| 293 |
+
client_id: str,
|
| 294 |
+
table_names: list[str],
|
| 295 |
+
user_id: str,
|
| 296 |
+
) -> dict[str, list[dict[str, Any]]]:
|
| 297 |
+
"""Fetch ALL column chunks for the given tables from PGVector.
|
| 298 |
+
|
| 299 |
+
Returns {table_name: [{"name": ..., "type": ..., "is_primary_key": ...,
|
| 300 |
+
"foreign_key": ..., "content": ...}]}
|
| 301 |
+
"""
|
| 302 |
+
placeholders = ", ".join(f":t{i}" for i in range(len(table_names)))
|
| 303 |
+
sql = text(f"""
|
| 304 |
+
SELECT lpe.cmetadata, lpe.document
|
| 305 |
+
FROM langchain_pg_embedding lpe
|
| 306 |
+
JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
|
| 307 |
+
WHERE lpc.name = 'document_embeddings'
|
| 308 |
+
AND lpe.cmetadata->>'user_id' = :user_id
|
| 309 |
+
AND lpe.cmetadata->>'source_type' = 'database'
|
| 310 |
+
AND lpe.cmetadata->>'database_client_id' = :client_id
|
| 311 |
+
AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders})
|
| 312 |
+
ORDER BY lpe.cmetadata->'data'->>'table_name', lpe.cmetadata->'data'->>'column_name'
|
| 313 |
+
""")
|
| 314 |
+
|
| 315 |
+
params: dict[str, Any] = {"user_id": user_id, "client_id": client_id}
|
| 316 |
+
for i, name in enumerate(table_names):
|
| 317 |
+
params[f"t{i}"] = name
|
| 318 |
+
|
| 319 |
+
async with _pgvector_engine.connect() as conn:
|
| 320 |
+
result = await conn.execute(sql, params)
|
| 321 |
+
rows = result.fetchall()
|
| 322 |
+
|
| 323 |
+
schema: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 324 |
+
for row in rows:
|
| 325 |
+
data = row.cmetadata.get("data", {})
|
| 326 |
+
table = data.get("table_name")
|
| 327 |
+
if table:
|
| 328 |
+
schema[table].append({
|
| 329 |
+
"name": data.get("column_name", ""),
|
| 330 |
+
"type": data.get("column_type", ""),
|
| 331 |
+
"is_primary_key": data.get("is_primary_key", False),
|
| 332 |
+
"foreign_key": data.get("foreign_key"),
|
| 333 |
+
"content": row.document, # chunk text includes top values / samples
|
| 334 |
+
})
|
| 335 |
+
return dict(schema)
|
| 336 |
+
|
| 337 |
+
def _build_schema_context(self, schema: dict[str, list[dict[str, Any]]]) -> str:
|
| 338 |
+
lines: list[str] = []
|
| 339 |
+
for table, columns in schema.items():
|
| 340 |
+
lines.append(f"Table: {table}")
|
| 341 |
+
for col in columns:
|
| 342 |
+
flags = []
|
| 343 |
+
if col["is_primary_key"]:
|
| 344 |
+
flags.append("PRIMARY KEY")
|
| 345 |
+
if col["foreign_key"]:
|
| 346 |
+
flags.append(f"FK -> {col['foreign_key']}")
|
| 347 |
+
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
| 348 |
+
lines.append(f" - {col['name']} {col['type']}{flag_str}")
|
| 349 |
+
# Include sample/top-values line from chunk content if present
|
| 350 |
+
for line in col["content"].splitlines():
|
| 351 |
+
if line.startswith(("Top values:", "Sample values:")):
|
| 352 |
+
lines.append(f" {line}")
|
| 353 |
+
break
|
| 354 |
+
lines.append("")
|
| 355 |
+
return "\n".join(lines).strip()
|
| 356 |
+
|
| 357 |
+
# ------------------------------------------------------------------
|
| 358 |
+
# Guardrails
|
| 359 |
+
# ------------------------------------------------------------------
|
| 360 |
+
|
| 361 |
+
def _validate(self, sql: str, schema: dict[str, list[dict]], limit: int) -> str:
|
| 362 |
+
"""Return an error string if validation fails, empty string if OK."""
|
| 363 |
+
# Layer 1: sqlglot parse + SELECT-only check
|
| 364 |
+
try:
|
| 365 |
+
parsed = sqlglot.parse_one(sql)
|
| 366 |
+
except sqlglot.errors.ParseError as e:
|
| 367 |
+
return f"SQL parse error: {e}"
|
| 368 |
+
|
| 369 |
+
if not isinstance(parsed, exp.Select):
|
| 370 |
+
return f"Only SELECT statements are allowed. Got: {type(parsed).__name__}"
|
| 371 |
+
|
| 372 |
+
# Check for DML anywhere in the AST (including writeable CTEs)
|
| 373 |
+
for node in parsed.find_all((exp.Insert, exp.Update, exp.Delete)):
|
| 374 |
+
return f"DML ({type(node).__name__}) is not allowed."
|
| 375 |
+
|
| 376 |
+
# Layer 2: schema grounding — table names
|
| 377 |
+
known_tables = {t.lower() for t in schema}
|
| 378 |
+
for tbl in parsed.find_all(exp.Table):
|
| 379 |
+
name = tbl.name.lower()
|
| 380 |
+
if name and name not in known_tables:
|
| 381 |
+
return f"Unknown table '{tbl.name}'. Only use tables from the schema."
|
| 382 |
+
|
| 383 |
+
# Layer 3: LIMIT enforcement (inject if missing — done before execution)
|
| 384 |
+
return ""
|
| 385 |
+
|
| 386 |
+
# ------------------------------------------------------------------
|
| 387 |
+
# SQL execution
|
| 388 |
+
# ------------------------------------------------------------------
|
| 389 |
+
|
| 390 |
+
def _enforce_limit(self, sql: str, limit: int) -> str:
|
| 391 |
+
"""Inject or cap LIMIT using sqlglot AST manipulation."""
|
| 392 |
+
parsed = sqlglot.parse_one(sql)
|
| 393 |
+
existing = parsed.find(exp.Limit)
|
| 394 |
+
if existing:
|
| 395 |
+
current = int(existing.expression.this)
|
| 396 |
+
if current > limit:
|
| 397 |
+
existing.expression.set("this", limit)
|
| 398 |
+
else:
|
| 399 |
+
parsed = parsed.limit(limit)
|
| 400 |
+
return parsed.sql()
|
| 401 |
+
|
| 402 |
+
def _run_sql(self, engine: Any, sql: str) -> list[dict]:
|
| 403 |
+
# Ensure the user DB connection is a read-only credential — sqlglot validation alone is not sufficient.
|
| 404 |
+
with engine.connect() as conn:
|
| 405 |
+
result = conn.execute(text(sql))
|
| 406 |
+
return [dict(row) for row in result.mappings()]
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
db_executor = DbExecutor()
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Executor for tabular document sources (source_type="document", file_type csv/xlsx).
|
| 2 |
+
|
| 3 |
+
Flow:
|
| 4 |
+
1. Group RetrievalResult chunks by document_id.
|
| 5 |
+
2. For each document: download bytes from Azure Blob -> read with pandas.
|
| 6 |
+
3. Filter DataFrame to relevant columns identified by retrieval.
|
| 7 |
+
4. Return QueryResult per document.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 11 |
+
|
| 12 |
+
from src.middlewares.logging import get_logger
|
| 13 |
+
from src.query.base import BaseExecutor, QueryResult
|
| 14 |
+
from src.rag.base import RetrievalResult
|
| 15 |
+
|
| 16 |
+
logger = get_logger("tabular_executor")
|
| 17 |
+
|
| 18 |
+
_TABULAR_FILE_TYPES = ("csv", "xlsx")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TabularExecutor(BaseExecutor):
|
| 22 |
+
async def execute(
|
| 23 |
+
self,
|
| 24 |
+
results: list[RetrievalResult],
|
| 25 |
+
user_id: str,
|
| 26 |
+
db: AsyncSession,
|
| 27 |
+
limit: int = 100,
|
| 28 |
+
) -> list[QueryResult]:
|
| 29 |
+
# TODO: implement
|
| 30 |
+
# 1. filter results where source_type == "document" and file_type in _TABULAR_FILE_TYPES
|
| 31 |
+
# 2. group by document_id -> list of column_names
|
| 32 |
+
# 3. per group: look up Document by document_id -> get blob_name
|
| 33 |
+
# 4. blob_storage.download_file(blob_name) -> pd.read_csv / pd.read_excel
|
| 34 |
+
# 5. df[relevant_columns].head(limit) -> rows as list[dict]
|
| 35 |
+
# 6. return QueryResult per document
|
| 36 |
+
raise NotImplementedError
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
tabular_executor = TabularExecutor()
|
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""QueryExecutor — dispatches retrieval results to the appropriate executor by source_type."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
|
| 7 |
+
from src.middlewares.logging import get_logger
|
| 8 |
+
from src.query.base import QueryResult
|
| 9 |
+
from src.query.executors.db_executor import db_executor
|
| 10 |
+
from src.query.executors.tabular import tabular_executor
|
| 11 |
+
from src.rag.base import RetrievalResult
|
| 12 |
+
|
| 13 |
+
logger = get_logger("query_executor")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class QueryExecutor:
|
| 17 |
+
async def execute(
|
| 18 |
+
self,
|
| 19 |
+
results: list[RetrievalResult],
|
| 20 |
+
user_id: str,
|
| 21 |
+
db: AsyncSession,
|
| 22 |
+
question: str,
|
| 23 |
+
limit: int = 100,
|
| 24 |
+
) -> list[QueryResult]:
|
| 25 |
+
db_results = [r for r in results if r.source_type == "database"]
|
| 26 |
+
tabular_results = [
|
| 27 |
+
r for r in results
|
| 28 |
+
if r.source_type == "document"
|
| 29 |
+
and r.metadata.get("data", {}).get("file_type") in ("csv", "xlsx")
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
async def _empty() -> list[QueryResult]:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
batches = await asyncio.gather(
|
| 36 |
+
db_executor.execute(db_results, user_id, db, question, limit) if db_results else _empty(),
|
| 37 |
+
tabular_executor.execute(tabular_results, user_id, db, question, limit) if tabular_results else _empty(),
|
| 38 |
+
return_exceptions=True,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
query_results: list[QueryResult] = []
|
| 42 |
+
for batch in batches:
|
| 43 |
+
if isinstance(batch, Exception):
|
| 44 |
+
logger.error("executor failed", error=str(batch))
|
| 45 |
+
continue
|
| 46 |
+
query_results.extend(batch)
|
| 47 |
+
|
| 48 |
+
logger.info("query execution complete", total=len(query_results))
|
| 49 |
+
return query_results
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
query_executor = QueryExecutor()
|
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared contract for all retriever implementations."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class RetrievalResult:
|
| 10 |
+
content: str
|
| 11 |
+
metadata: dict[str, Any]
|
| 12 |
+
score: float
|
| 13 |
+
source_type: str # "document" | "database"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class BaseRetriever(ABC):
|
| 17 |
+
@abstractmethod
|
| 18 |
+
async def retrieve(
|
| 19 |
+
self, query: str, user_id: str, k: int = 5
|
| 20 |
+
) -> list[RetrievalResult]: ...
|
|
@@ -1,69 +1,43 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
import hashlib
|
| 4 |
-
import json
|
| 5 |
-
from src.db.postgres.vector_store import get_vector_store
|
| 6 |
-
from src.db.redis.connection import get_redis
|
| 7 |
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
| 8 |
from src.middlewares.logging import get_logger
|
| 9 |
-
from
|
|
|
|
|
|
|
| 10 |
|
| 11 |
logger = get_logger("retriever")
|
| 12 |
|
| 13 |
-
_RETRIEVAL_CACHE_TTL = 3600 # 1 hour
|
| 14 |
-
|
| 15 |
|
| 16 |
class RetrieverService:
|
| 17 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
def __init__(self):
|
| 20 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
async def retrieve(
|
| 23 |
self,
|
| 24 |
query: str,
|
| 25 |
user_id: str,
|
| 26 |
db: AsyncSession,
|
| 27 |
-
k: int = 5
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
Returns:
|
| 32 |
-
List of dicts with keys: content, metadata
|
| 33 |
-
metadata includes: document_id, user_id, filename, chunk_index, page_label (if PDF)
|
| 34 |
-
"""
|
| 35 |
try:
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
cache_key = f"retrieval:{user_id}:{query_hash}:{k}"
|
| 39 |
-
|
| 40 |
-
cached = await redis.get(cache_key)
|
| 41 |
-
if cached:
|
| 42 |
-
logger.info("Returning cached retrieval results")
|
| 43 |
-
return json.loads(cached)
|
| 44 |
-
|
| 45 |
-
logger.info(f"Retrieving for user {user_id}, query: {query[:50]}...")
|
| 46 |
-
|
| 47 |
-
docs = await self.vector_store.asimilarity_search(
|
| 48 |
-
query=query,
|
| 49 |
-
k=k,
|
| 50 |
-
filter={"user_id": user_id}
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
results = [
|
| 54 |
-
{
|
| 55 |
-
"content": doc.page_content,
|
| 56 |
-
"metadata": doc.metadata,
|
| 57 |
-
}
|
| 58 |
-
for doc in docs
|
| 59 |
-
]
|
| 60 |
-
|
| 61 |
-
logger.info(f"Retrieved {len(results)} chunks")
|
| 62 |
-
await redis.setex(cache_key, _RETRIEVAL_CACHE_TTL, json.dumps(results))
|
| 63 |
-
return results
|
| 64 |
-
|
| 65 |
except Exception as e:
|
| 66 |
-
logger.error("
|
| 67 |
return []
|
| 68 |
|
| 69 |
|
|
|
|
| 1 |
+
"""Public retrieval API — thin wrapper around RetrievalRouter."""
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
|
| 7 |
from src.middlewares.logging import get_logger
|
| 8 |
+
from src.rag.retrievers.document import document_retriever
|
| 9 |
+
from src.rag.retrievers.schema import schema_retriever
|
| 10 |
+
from src.rag.router import RetrievalRouter, SourceHint
|
| 11 |
|
| 12 |
logger = get_logger("retriever")
|
| 13 |
|
|
|
|
|
|
|
| 14 |
|
| 15 |
class RetrieverService:
|
| 16 |
+
"""Public retrieval service used by chat.py and search tools.
|
| 17 |
+
|
| 18 |
+
Delegates to RetrievalRouter which dispatches based on source_hint.
|
| 19 |
+
Returns List[Dict] to preserve backward compatibility with chat.py.
|
| 20 |
+
"""
|
| 21 |
|
| 22 |
def __init__(self):
|
| 23 |
+
self._router = RetrievalRouter(
|
| 24 |
+
schema_retriever=schema_retriever,
|
| 25 |
+
document_retriever=document_retriever,
|
| 26 |
+
)
|
| 27 |
|
| 28 |
async def retrieve(
|
| 29 |
self,
|
| 30 |
query: str,
|
| 31 |
user_id: str,
|
| 32 |
db: AsyncSession,
|
| 33 |
+
k: int = 5,
|
| 34 |
+
source_hint: SourceHint = "both",
|
| 35 |
+
) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
| 37 |
+
results = await self._router.retrieve(query, user_id, source_hint, k)
|
| 38 |
+
return [{"content": r.content, "metadata": r.metadata} for r in results]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
except Exception as e:
|
| 40 |
+
logger.error("retrieval failed", error=str(e))
|
| 41 |
return []
|
| 42 |
|
| 43 |
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service for retrieving relevant documents from vector store."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from src.db.postgres.vector_store import get_vector_store
|
| 6 |
+
from src.db.redis.connection import get_redis
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from src.middlewares.logging import get_logger
|
| 9 |
+
from typing import List, Dict, Any
|
| 10 |
+
|
| 11 |
+
logger = get_logger("retriever")
|
| 12 |
+
|
| 13 |
+
_RETRIEVAL_CACHE_TTL = 3600 # 1 hour
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RetrieverService:
|
| 17 |
+
"""Service for retrieving relevant documents."""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.vector_store = get_vector_store()
|
| 21 |
+
|
| 22 |
+
async def retrieve(
|
| 23 |
+
self,
|
| 24 |
+
query: str,
|
| 25 |
+
user_id: str,
|
| 26 |
+
db: AsyncSession,
|
| 27 |
+
k: int = 5
|
| 28 |
+
) -> List[Dict[str, Any]]:
|
| 29 |
+
"""Retrieve relevant chunks for a query, scoped to the user's documents.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
List of dicts with keys: content, metadata
|
| 33 |
+
metadata includes: document_id, user_id, filename, chunk_index, page_label (if PDF)
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
redis = await get_redis()
|
| 37 |
+
query_hash = hashlib.md5(query.encode()).hexdigest()
|
| 38 |
+
cache_key = f"retrieval:{user_id}:{query_hash}:{k}"
|
| 39 |
+
|
| 40 |
+
cached = await redis.get(cache_key)
|
| 41 |
+
if cached:
|
| 42 |
+
logger.info("Returning cached retrieval results")
|
| 43 |
+
return json.loads(cached)
|
| 44 |
+
|
| 45 |
+
logger.info(f"Retrieving for user {user_id}, query: {query[:50]}...")
|
| 46 |
+
|
| 47 |
+
docs = await self.vector_store.asimilarity_search(
|
| 48 |
+
query=query,
|
| 49 |
+
k=k,
|
| 50 |
+
filter={"user_id": user_id}
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
results = [
|
| 54 |
+
{
|
| 55 |
+
"content": doc.page_content,
|
| 56 |
+
"metadata": doc.metadata,
|
| 57 |
+
}
|
| 58 |
+
for doc in docs
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
logger.info(f"Retrieved {len(results)} chunks")
|
| 62 |
+
await redis.setex(cache_key, _RETRIEVAL_CACHE_TTL, json.dumps(results))
|
| 63 |
+
return results
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error("Retrieval failed", error=str(e))
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
retriever = RetrieverService()
|
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document retriever — handles PDF, DOCX, TXT chunks (source_type="document", non-tabular).
|
| 2 |
+
|
| 3 |
+
TEAMMATE: implement retrieve() below.
|
| 4 |
+
Strategy: MMR (amax_marginal_relevance_search) + score threshold to avoid returning
|
| 5 |
+
near-identical chunks from the same PDF page.
|
| 6 |
+
Filter: source_type="document" AND data->>'file_type' NOT IN ('csv', 'xlsx')
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from src.db.postgres.vector_store import get_vector_store
|
| 10 |
+
from src.middlewares.logging import get_logger
|
| 11 |
+
from src.rag.base import BaseRetriever, RetrievalResult
|
| 12 |
+
|
| 13 |
+
logger = get_logger("document_retriever")
|
| 14 |
+
|
| 15 |
+
_SCORE_THRESHOLD = 0.45 # discard chunks with cosine distance above this
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class DocumentRetriever(BaseRetriever):
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.vector_store = get_vector_store()
|
| 21 |
+
|
| 22 |
+
async def retrieve(
|
| 23 |
+
self, query: str, user_id: str, k: int = 5
|
| 24 |
+
) -> list[RetrievalResult]:
|
| 25 |
+
# TODO (teammate): implement MMR retrieval for prose documents
|
| 26 |
+
# Filter: {"user_id": user_id, "source_type": "document"}
|
| 27 |
+
# then post-filter to exclude file_type in ("csv", "xlsx")
|
| 28 |
+
logger.info("document retriever not yet implemented — returning empty")
|
| 29 |
+
return []
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
document_retriever = DocumentRetriever()
|
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Schema retriever — handles DB schemas (source_type="database") and tabular file
|
| 2 |
+
columns stored as source_type="document" with file_type in ("csv","xlsx").
|
| 3 |
+
|
| 4 |
+
Multiple retrieval strategies are exposed for benchmarking. The active strategy
|
| 5 |
+
used by the router is `retrieve()`, which dispatches to ACTIVE_STRATEGY.
|
| 6 |
+
Change ACTIVE_STRATEGY at module level to switch without touching the router.
|
| 7 |
+
|
| 8 |
+
All strategies embed the query exactly once, then fan out to parallel SQL legs.
|
| 9 |
+
|
| 10 |
+
Vector distance strategies:
|
| 11 |
+
dense_no_threshold — cosine (<=>), no score floor, always returns k chunks
|
| 12 |
+
dense_dot — inner product (<#>), equivalent to cosine for normalized embeddings
|
| 13 |
+
dense_l2 — L2/euclidean (<->), monotonic with cosine on unit-sphere vectors
|
| 14 |
+
hybrid — RRF merge of dense + FTS (database + tabular)
|
| 15 |
+
hybrid_bm25 — RRF merge of dense + FTS (database only)
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import time
|
| 20 |
+
from typing import Literal
|
| 21 |
+
|
| 22 |
+
from sqlalchemy import text
|
| 23 |
+
|
| 24 |
+
from src.db.postgres.connection import _pgvector_engine
|
| 25 |
+
from src.db.postgres.vector_store import get_vector_store
|
| 26 |
+
from src.middlewares.logging import get_logger
|
| 27 |
+
from src.rag.base import BaseRetriever, RetrievalResult
|
| 28 |
+
|
| 29 |
+
logger = get_logger("schema_retriever")
|
| 30 |
+
|
| 31 |
+
_TABULAR_FILE_TYPES = ("csv", "xlsx")
|
| 32 |
+
|
| 33 |
+
Strategy = Literal["dense_no_threshold", "dense_dot", "dense_l2", "hybrid", "hybrid_bm25"]
|
| 34 |
+
ACTIVE_STRATEGY: Strategy = "hybrid_bm25"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class SchemaRetriever(BaseRetriever):
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self.vector_store = get_vector_store()
|
| 40 |
+
|
| 41 |
+
# ------------------------------------------------------------------
|
| 42 |
+
# Internal helpers
|
| 43 |
+
# ------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
async def _embed_query(self, query: str) -> list[float]:
|
| 46 |
+
return await asyncio.to_thread(self.vector_store.embeddings.embed_query, query)
|
| 47 |
+
|
| 48 |
+
async def _search_db(
|
| 49 |
+
self, embedding: list[float], user_id: str, k: int, operator: str = "<=>"
|
| 50 |
+
) -> list[RetrievalResult]:
|
| 51 |
+
"""Vector search over database chunks. Accepts a pre-computed embedding."""
|
| 52 |
+
emb_str = "[" + ",".join(str(x) for x in embedding) + "]"
|
| 53 |
+
|
| 54 |
+
if operator == "<#>":
|
| 55 |
+
score_sql = f"(lpe.embedding <#> '{emb_str}'::vector) * -1"
|
| 56 |
+
elif operator == "<->":
|
| 57 |
+
score_sql = f"1.0 / (1.0 + (lpe.embedding <-> '{emb_str}'::vector))"
|
| 58 |
+
else:
|
| 59 |
+
score_sql = f"1.0 - (lpe.embedding <=> '{emb_str}'::vector)"
|
| 60 |
+
|
| 61 |
+
sql = text(f"""
|
| 62 |
+
SELECT lpe.document, lpe.cmetadata, {score_sql} AS score
|
| 63 |
+
FROM langchain_pg_embedding lpe
|
| 64 |
+
JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
|
| 65 |
+
WHERE lpc.name = 'document_embeddings'
|
| 66 |
+
AND lpe.cmetadata->>'user_id' = :user_id
|
| 67 |
+
AND lpe.cmetadata->>'source_type' = 'database'
|
| 68 |
+
ORDER BY lpe.embedding {operator} '{emb_str}'::vector ASC
|
| 69 |
+
LIMIT :k
|
| 70 |
+
""")
|
| 71 |
+
|
| 72 |
+
async with _pgvector_engine.connect() as conn:
|
| 73 |
+
result = await conn.execute(sql, {"user_id": user_id, "k": k * 4})
|
| 74 |
+
rows = result.fetchall()
|
| 75 |
+
|
| 76 |
+
return [
|
| 77 |
+
RetrievalResult(
|
| 78 |
+
content=row.document,
|
| 79 |
+
metadata=row.cmetadata,
|
| 80 |
+
score=float(row.score),
|
| 81 |
+
source_type="database",
|
| 82 |
+
)
|
| 83 |
+
for row in rows
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
async def _search_tabular(
|
| 87 |
+
self, embedding: list[float], user_id: str, k: int, operator: str = "<=>"
|
| 88 |
+
) -> list[RetrievalResult]:
|
| 89 |
+
"""Vector search over tabular document chunks. Accepts a pre-computed embedding."""
|
| 90 |
+
emb_str = "[" + ",".join(str(x) for x in embedding) + "]"
|
| 91 |
+
|
| 92 |
+
if operator == "<#>":
|
| 93 |
+
score_sql = f"(lpe.embedding <#> '{emb_str}'::vector) * -1"
|
| 94 |
+
elif operator == "<->":
|
| 95 |
+
score_sql = f"1.0 / (1.0 + (lpe.embedding <-> '{emb_str}'::vector))"
|
| 96 |
+
else:
|
| 97 |
+
score_sql = f"1.0 - (lpe.embedding <=> '{emb_str}'::vector)"
|
| 98 |
+
|
| 99 |
+
sql = text(f"""
|
| 100 |
+
SELECT lpe.document, lpe.cmetadata, {score_sql} AS score
|
| 101 |
+
FROM langchain_pg_embedding lpe
|
| 102 |
+
JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
|
| 103 |
+
WHERE lpc.name = 'document_embeddings'
|
| 104 |
+
AND lpe.cmetadata->>'user_id' = :user_id
|
| 105 |
+
AND lpe.cmetadata->>'source_type' = 'document'
|
| 106 |
+
AND (lpe.cmetadata->'data'->>'file_type' = 'csv'
|
| 107 |
+
OR lpe.cmetadata->'data'->>'file_type' = 'xlsx')
|
| 108 |
+
ORDER BY lpe.embedding {operator} '{emb_str}'::vector ASC
|
| 109 |
+
LIMIT :k
|
| 110 |
+
""")
|
| 111 |
+
|
| 112 |
+
async with _pgvector_engine.connect() as conn:
|
| 113 |
+
result = await conn.execute(sql, {"user_id": user_id, "k": k * 4})
|
| 114 |
+
rows = result.fetchall()
|
| 115 |
+
|
| 116 |
+
results = []
|
| 117 |
+
for row in rows:
|
| 118 |
+
results.append(
|
| 119 |
+
RetrievalResult(
|
| 120 |
+
content=row.document,
|
| 121 |
+
metadata=row.cmetadata,
|
| 122 |
+
score=float(row.score),
|
| 123 |
+
source_type="document",
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
if len(results) >= k:
|
| 127 |
+
break
|
| 128 |
+
return results
|
| 129 |
+
|
| 130 |
+
async def _search_fts_db(self, query: str, user_id: str, k: int) -> list[RetrievalResult]:
|
| 131 |
+
"""Full-text search over DB schema chunks using PostgreSQL tsvector.
|
| 132 |
+
|
| 133 |
+
Requires GIN index on langchain_pg_embedding.document (created by init_db.py).
|
| 134 |
+
"""
|
| 135 |
+
sql = text("""
|
| 136 |
+
SELECT lpe.document, lpe.cmetadata,
|
| 137 |
+
ts_rank(to_tsvector('english', lpe.document),
|
| 138 |
+
plainto_tsquery('english', :query)) AS rank
|
| 139 |
+
FROM langchain_pg_embedding lpe
|
| 140 |
+
JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
|
| 141 |
+
WHERE lpc.name = 'document_embeddings'
|
| 142 |
+
AND lpe.cmetadata->>'user_id' = :user_id
|
| 143 |
+
AND lpe.cmetadata->>'source_type' = 'database'
|
| 144 |
+
AND to_tsvector('english', lpe.document) @@ plainto_tsquery('english', :query)
|
| 145 |
+
ORDER BY rank DESC
|
| 146 |
+
LIMIT :k
|
| 147 |
+
""")
|
| 148 |
+
|
| 149 |
+
async with _pgvector_engine.connect() as conn:
|
| 150 |
+
result = await conn.execute(sql, {"query": query, "user_id": user_id, "k": k})
|
| 151 |
+
rows = result.fetchall()
|
| 152 |
+
|
| 153 |
+
return [
|
| 154 |
+
RetrievalResult(
|
| 155 |
+
content=row.document,
|
| 156 |
+
metadata=row.cmetadata,
|
| 157 |
+
score=float(row.rank),
|
| 158 |
+
source_type="database",
|
| 159 |
+
)
|
| 160 |
+
for row in rows
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
async def _search_fts_tabular(self, query: str, user_id: str, k: int) -> list[RetrievalResult]:
|
| 164 |
+
"""Full-text search over tabular document chunks using PostgreSQL tsvector."""
|
| 165 |
+
sql = text("""
|
| 166 |
+
SELECT lpe.document, lpe.cmetadata,
|
| 167 |
+
ts_rank(to_tsvector('english', lpe.document),
|
| 168 |
+
plainto_tsquery('english', :query)) AS rank
|
| 169 |
+
FROM langchain_pg_embedding lpe
|
| 170 |
+
JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
|
| 171 |
+
WHERE lpc.name = 'document_embeddings'
|
| 172 |
+
AND lpe.cmetadata->>'user_id' = :user_id
|
| 173 |
+
AND lpe.cmetadata->>'source_type' = 'document'
|
| 174 |
+
AND (lpe.cmetadata->'data'->>'file_type' = 'csv'
|
| 175 |
+
OR lpe.cmetadata->'data'->>'file_type' = 'xlsx')
|
| 176 |
+
AND to_tsvector('english', lpe.document) @@ plainto_tsquery('english', :query)
|
| 177 |
+
ORDER BY rank DESC
|
| 178 |
+
LIMIT :k
|
| 179 |
+
""")
|
| 180 |
+
|
| 181 |
+
async with _pgvector_engine.connect() as conn:
|
| 182 |
+
result = await conn.execute(sql, {"query": query, "user_id": user_id, "k": k})
|
| 183 |
+
rows = result.fetchall()
|
| 184 |
+
|
| 185 |
+
return [
|
| 186 |
+
RetrievalResult(
|
| 187 |
+
content=row.document,
|
| 188 |
+
metadata=row.cmetadata,
|
| 189 |
+
score=float(row.rank),
|
| 190 |
+
source_type="document",
|
| 191 |
+
)
|
| 192 |
+
for row in rows
|
| 193 |
+
]
|
| 194 |
+
|
| 195 |
+
def _rrf_merge(
|
| 196 |
+
self,
|
| 197 |
+
*ranked_lists: list[RetrievalResult],
|
| 198 |
+
k_rrf: int = 60,
|
| 199 |
+
top_k: int = 5,
|
| 200 |
+
) -> list[RetrievalResult]:
|
| 201 |
+
"""Reciprocal Rank Fusion — combines ranked lists using rank positions only."""
|
| 202 |
+
scores: dict[tuple, float] = {}
|
| 203 |
+
index: dict[tuple, RetrievalResult] = {}
|
| 204 |
+
|
| 205 |
+
for ranked in ranked_lists:
|
| 206 |
+
for rank, result in enumerate(ranked):
|
| 207 |
+
data = result.metadata.get("data", {})
|
| 208 |
+
key = (data.get("table_name"), data.get("column_name") or data.get("filename"))
|
| 209 |
+
scores[key] = scores.get(key, 0.0) + 1.0 / (k_rrf + rank + 1)
|
| 210 |
+
if key not in index or result.score > index[key].score:
|
| 211 |
+
index[key] = result
|
| 212 |
+
|
| 213 |
+
def _key(r: RetrievalResult) -> tuple:
|
| 214 |
+
d = r.metadata.get("data", {})
|
| 215 |
+
return (d.get("table_name"), d.get("column_name") or d.get("filename"))
|
| 216 |
+
|
| 217 |
+
merged = sorted(index.values(), key=lambda r: scores[_key(r)], reverse=True)
|
| 218 |
+
return merged[:top_k]
|
| 219 |
+
|
| 220 |
+
def _dedup(self, results: list[RetrievalResult]) -> list[RetrievalResult]:
|
| 221 |
+
"""Deduplicate by (table_name, column_name), keeping highest score per unique column."""
|
| 222 |
+
seen: dict[tuple, RetrievalResult] = {}
|
| 223 |
+
for r in results:
|
| 224 |
+
data = r.metadata.get("data", {})
|
| 225 |
+
key = (data.get("table_name"), data.get("column_name") or data.get("filename"))
|
| 226 |
+
if key not in seen or r.score > seen[key].score:
|
| 227 |
+
seen[key] = r
|
| 228 |
+
return sorted(seen.values(), key=lambda r: r.score, reverse=True)
|
| 229 |
+
|
| 230 |
+
# ------------------------------------------------------------------
|
| 231 |
+
# Named strategies — one embed call each, legs run in parallel
|
| 232 |
+
# ------------------------------------------------------------------
|
| 233 |
+
|
| 234 |
+
async def dense_no_threshold(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
|
| 235 |
+
"""Cosine similarity, no score cutoff — always returns k chunks."""
|
| 236 |
+
embedding = await self._embed_query(query)
|
| 237 |
+
db_results, tabular_results = await asyncio.gather(
|
| 238 |
+
self._search_db(embedding, user_id, k),
|
| 239 |
+
self._search_tabular(embedding, user_id, k),
|
| 240 |
+
)
|
| 241 |
+
return self._dedup(db_results + tabular_results)[:k]
|
| 242 |
+
|
| 243 |
+
async def dense_dot(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
|
| 244 |
+
"""Inner product similarity (<#>).
|
| 245 |
+
|
| 246 |
+
For L2-normalized embeddings (OpenAI), ranking is identical to cosine.
|
| 247 |
+
Score = raw inner product (not bounded to [0,1]).
|
| 248 |
+
"""
|
| 249 |
+
embedding = await self._embed_query(query)
|
| 250 |
+
db_results, tabular_results = await asyncio.gather(
|
| 251 |
+
self._search_db(embedding, user_id, k, "<#>"),
|
| 252 |
+
self._search_tabular(embedding, user_id, k, "<#>"),
|
| 253 |
+
)
|
| 254 |
+
return self._dedup(db_results + tabular_results)[:k]
|
| 255 |
+
|
| 256 |
+
async def dense_l2(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
|
| 257 |
+
"""L2 (Euclidean) distance similarity (<->).
|
| 258 |
+
|
| 259 |
+
For L2-normalized embeddings (OpenAI), ranking order matches cosine.
|
| 260 |
+
Score = 1 / (1 + l2_distance), bounded to (0, 1].
|
| 261 |
+
"""
|
| 262 |
+
embedding = await self._embed_query(query)
|
| 263 |
+
db_results, tabular_results = await asyncio.gather(
|
| 264 |
+
self._search_db(embedding, user_id, k, "<->"),
|
| 265 |
+
self._search_tabular(embedding, user_id, k, "<->"),
|
| 266 |
+
)
|
| 267 |
+
return self._dedup(db_results + tabular_results)[:k]
|
| 268 |
+
|
| 269 |
+
async def hybrid(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
|
| 270 |
+
"""RRF merge of dense + FTS over both database and tabular sources.
|
| 271 |
+
|
| 272 |
+
Embeds once, then runs all four legs (dense db, dense tabular, fts db,
|
| 273 |
+
fts tabular) in a single asyncio.gather.
|
| 274 |
+
"""
|
| 275 |
+
embedding = await self._embed_query(query)
|
| 276 |
+
db_results, tabular_results, fts_db, fts_tabular = await asyncio.gather(
|
| 277 |
+
self._search_db(embedding, user_id, k),
|
| 278 |
+
self._search_tabular(embedding, user_id, k),
|
| 279 |
+
self._search_fts_db(query, user_id, k * 4),
|
| 280 |
+
self._search_fts_tabular(query, user_id, k * 4),
|
| 281 |
+
)
|
| 282 |
+
dense = self._dedup(db_results + tabular_results)[:k]
|
| 283 |
+
fts_all = self._dedup(fts_db + fts_tabular)
|
| 284 |
+
return self._rrf_merge(dense, fts_all, top_k=k)
|
| 285 |
+
|
| 286 |
+
async def hybrid_bm25(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
|
| 287 |
+
"""RRF merge of dense + FTS (database chunks only).
|
| 288 |
+
|
| 289 |
+
Embeds once, then runs dense db, dense tabular, and fts db legs in parallel.
|
| 290 |
+
"""
|
| 291 |
+
embedding = await self._embed_query(query)
|
| 292 |
+
db_results, tabular_results, fts_results = await asyncio.gather(
|
| 293 |
+
self._search_db(embedding, user_id, k),
|
| 294 |
+
self._search_tabular(embedding, user_id, k),
|
| 295 |
+
self._search_fts_db(query, user_id, k * 4),
|
| 296 |
+
)
|
| 297 |
+
dense = self._dedup(db_results + tabular_results)[:k]
|
| 298 |
+
return self._rrf_merge(dense, self._dedup(fts_results), top_k=k)
|
| 299 |
+
|
| 300 |
+
# ------------------------------------------------------------------
|
| 301 |
+
# Public interface — called by the router
|
| 302 |
+
# ------------------------------------------------------------------
|
| 303 |
+
|
| 304 |
+
async def retrieve(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
|
| 305 |
+
strategy_fn = getattr(self, ACTIVE_STRATEGY)
|
| 306 |
+
results = await strategy_fn(query, user_id, k)
|
| 307 |
+
logger.info("schema retrieval", strategy=ACTIVE_STRATEGY, count=len(results))
|
| 308 |
+
return results
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# ------------------------------------------------------------------
|
| 312 |
+
# Benchmark helper — import in test scripts
|
| 313 |
+
# ------------------------------------------------------------------
|
| 314 |
+
|
| 315 |
+
async def benchmark(
|
| 316 |
+
query: str,
|
| 317 |
+
user_id: str,
|
| 318 |
+
k: int = 5,
|
| 319 |
+
strategies: list[Strategy] | None = None,
|
| 320 |
+
) -> dict[str, dict]:
|
| 321 |
+
"""Run multiple strategies against the same query and return timing + results."""
|
| 322 |
+
retriever = SchemaRetriever()
|
| 323 |
+
targets: list[Strategy] = strategies or [
|
| 324 |
+
"dense_no_threshold",
|
| 325 |
+
"dense_dot",
|
| 326 |
+
"dense_l2",
|
| 327 |
+
"hybrid",
|
| 328 |
+
"hybrid_bm25",
|
| 329 |
+
]
|
| 330 |
+
report: dict[str, dict] = {}
|
| 331 |
+
|
| 332 |
+
for name in targets:
|
| 333 |
+
fn = getattr(retriever, name)
|
| 334 |
+
t0 = time.perf_counter()
|
| 335 |
+
chunks = await fn(query, user_id, k)
|
| 336 |
+
elapsed_ms = round((time.perf_counter() - t0) * 1000)
|
| 337 |
+
|
| 338 |
+
total_chars = sum(len(r.content) for r in chunks)
|
| 339 |
+
report[name] = {
|
| 340 |
+
"chunks": len(chunks),
|
| 341 |
+
"estimated_tokens": total_chars // 4,
|
| 342 |
+
"elapsed_ms": elapsed_ms,
|
| 343 |
+
"results": chunks,
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
return report
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
schema_retriever = SchemaRetriever()
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes retrieval requests to the appropriate retriever based on source_hint."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from src.db.redis.connection import get_redis
|
| 9 |
+
from src.middlewares.logging import get_logger
|
| 10 |
+
from src.rag.base import BaseRetriever, RetrievalResult
|
| 11 |
+
|
| 12 |
+
logger = get_logger("retrieval_router")
|
| 13 |
+
|
| 14 |
+
_CACHE_TTL = 3600 # 1 hour
|
| 15 |
+
SourceHint = Literal["document", "schema", "both"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class RetrievalRouter:
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
schema_retriever: BaseRetriever,
|
| 22 |
+
document_retriever: BaseRetriever,
|
| 23 |
+
):
|
| 24 |
+
self._retrievers: dict[str, BaseRetriever] = {
|
| 25 |
+
"schema": schema_retriever,
|
| 26 |
+
"document": document_retriever,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
def _route(self, source_hint: SourceHint) -> list[BaseRetriever]:
|
| 30 |
+
if source_hint == "schema":
|
| 31 |
+
return [self._retrievers["schema"]]
|
| 32 |
+
if source_hint == "document":
|
| 33 |
+
return [self._retrievers["document"]]
|
| 34 |
+
return list(self._retrievers.values())
|
| 35 |
+
|
| 36 |
+
async def retrieve(
|
| 37 |
+
self,
|
| 38 |
+
query: str,
|
| 39 |
+
user_id: str,
|
| 40 |
+
source_hint: SourceHint = "both",
|
| 41 |
+
k: int = 10,
|
| 42 |
+
) -> list[RetrievalResult]:
|
| 43 |
+
redis = await get_redis()
|
| 44 |
+
query_hash = hashlib.md5(query.encode()).hexdigest()
|
| 45 |
+
cache_key = f"retrieval:{user_id}:{source_hint}:{query_hash}:{k}"
|
| 46 |
+
|
| 47 |
+
cached = await redis.get(cache_key)
|
| 48 |
+
if cached:
|
| 49 |
+
logger.info("returning cached retrieval results", source_hint=source_hint)
|
| 50 |
+
raw = json.loads(cached)
|
| 51 |
+
return [RetrievalResult(**r) for r in raw]
|
| 52 |
+
|
| 53 |
+
retrievers = self._route(source_hint)
|
| 54 |
+
batches = await asyncio.gather(
|
| 55 |
+
*[r.retrieve(query, user_id, k) for r in retrievers],
|
| 56 |
+
return_exceptions=True,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
results: list[RetrievalResult] = []
|
| 60 |
+
for batch in batches:
|
| 61 |
+
if isinstance(batch, Exception):
|
| 62 |
+
logger.error("retriever failed", error=str(batch))
|
| 63 |
+
continue
|
| 64 |
+
results.extend(batch)
|
| 65 |
+
|
| 66 |
+
results.sort(key=lambda r: r.score, reverse=True)
|
| 67 |
+
results = results[:k]
|
| 68 |
+
|
| 69 |
+
logger.info("retrieved chunks", count=len(results), source_hint=source_hint)
|
| 70 |
+
await redis.setex(
|
| 71 |
+
cache_key,
|
| 72 |
+
_CACHE_TTL,
|
| 73 |
+
json.dumps([vars(r) for r in results]),
|
| 74 |
+
)
|
| 75 |
+
return results
|
|
@@ -57,6 +57,22 @@ class AzureBlobStorage:
|
|
| 57 |
logger.error(f"Failed to download blob {blob_name}", error=str(e))
|
| 58 |
raise
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
async def delete_file(self, blob_name: str) -> bool:
|
| 61 |
"""Delete file from Azure Blob Storage."""
|
| 62 |
try:
|
|
@@ -71,6 +87,24 @@ class AzureBlobStorage:
|
|
| 71 |
logger.error(f"Failed to delete blob {blob_name}", error=str(e))
|
| 72 |
return False
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# Singleton instance
|
| 76 |
blob_storage = AzureBlobStorage()
|
|
|
|
| 57 |
logger.error(f"Failed to download blob {blob_name}", error=str(e))
|
| 58 |
raise
|
| 59 |
|
| 60 |
+
async def upload_bytes(self, content: bytes, blob_name: str) -> str:
|
| 61 |
+
"""Upload bytes to Azure Blob Storage using a specific blob name.
|
| 62 |
+
|
| 63 |
+
Unlike upload_file(), this does not generate a UUID name — caller controls the blob_name.
|
| 64 |
+
Used for Parquet files where the name must be deterministic (derived from document_id).
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
async with self._get_blob_client(blob_name) as blob_client:
|
| 68 |
+
logger.info(f"Uploading bytes to blob {blob_name}")
|
| 69 |
+
await blob_client.upload_blob(content, overwrite=True)
|
| 70 |
+
logger.info(f"Successfully uploaded {blob_name}")
|
| 71 |
+
return blob_name
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"Failed to upload bytes to {blob_name}", error=str(e))
|
| 74 |
+
raise
|
| 75 |
+
|
| 76 |
async def delete_file(self, blob_name: str) -> bool:
|
| 77 |
"""Delete file from Azure Blob Storage."""
|
| 78 |
try:
|
|
|
|
| 87 |
logger.error(f"Failed to delete blob {blob_name}", error=str(e))
|
| 88 |
return False
|
| 89 |
|
| 90 |
+
async def delete_blobs_with_prefix(self, prefix: str) -> int:
|
| 91 |
+
"""Delete all blobs whose name starts with prefix. Returns count deleted.
|
| 92 |
+
|
| 93 |
+
Used to delete all Parquet files for a document in one call.
|
| 94 |
+
"""
|
| 95 |
+
from azure.storage.blob.aio import ContainerClient
|
| 96 |
+
container_url = f"{self.account_url}/{self.container_name}?{self.sas_token}"
|
| 97 |
+
deleted = 0
|
| 98 |
+
try:
|
| 99 |
+
async with ContainerClient.from_container_url(container_url) as container:
|
| 100 |
+
async for blob in container.list_blobs(name_starts_with=prefix):
|
| 101 |
+
await container.delete_blob(blob.name)
|
| 102 |
+
deleted += 1
|
| 103 |
+
logger.info(f"Deleted {deleted} blobs with prefix {prefix}")
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Failed to delete blobs with prefix {prefix}", error=str(e))
|
| 106 |
+
return deleted
|
| 107 |
+
|
| 108 |
|
| 109 |
# Singleton instance
|
| 110 |
blob_storage = AzureBlobStorage()
|
|
@@ -66,6 +66,7 @@ dependencies = [
|
|
| 66 |
{ name = "spacy" },
|
| 67 |
{ name = "sqlalchemy", extra = ["asyncio"] },
|
| 68 |
{ name = "sqlalchemy-bigquery" },
|
|
|
|
| 69 |
{ name = "sse-starlette" },
|
| 70 |
{ name = "starlette" },
|
| 71 |
{ name = "structlog" },
|
|
@@ -149,6 +150,7 @@ requires-dist = [
|
|
| 149 |
{ name = "spacy", specifier = "==3.8.3" },
|
| 150 |
{ name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.36" },
|
| 151 |
{ name = "sqlalchemy-bigquery", specifier = ">=1.11.0" },
|
|
|
|
| 152 |
{ name = "sse-starlette", specifier = "==2.1.3" },
|
| 153 |
{ name = "starlette", specifier = "==0.41.3" },
|
| 154 |
{ name = "structlog", specifier = "==24.4.0" },
|
|
@@ -3221,6 +3223,15 @@ wheels = [
|
|
| 3221 |
{ url = "https://files.pythonhosted.org/packages/c0/87/11e6de00ef7949bb8ea06b55304a1a4911c329fdf0d9882b464db240c2c5/sqlalchemy_bigquery-1.16.0-py3-none-any.whl", hash = "sha256:0fe7634cd954f3e74f5e2db6d159f9e5ee87a47fbe8d52eac3cd3bb3dadb3a77", size = 40615, upload-time = "2025-11-06T01:35:39.358Z" },
|
| 3222 |
]
|
| 3223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3224 |
[[package]]
|
| 3225 |
name = "srsly"
|
| 3226 |
version = "2.5.3"
|
|
|
|
| 66 |
{ name = "spacy" },
|
| 67 |
{ name = "sqlalchemy", extra = ["asyncio"] },
|
| 68 |
{ name = "sqlalchemy-bigquery" },
|
| 69 |
+
{ name = "sqlglot" },
|
| 70 |
{ name = "sse-starlette" },
|
| 71 |
{ name = "starlette" },
|
| 72 |
{ name = "structlog" },
|
|
|
|
| 150 |
{ name = "spacy", specifier = "==3.8.3" },
|
| 151 |
{ name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.36" },
|
| 152 |
{ name = "sqlalchemy-bigquery", specifier = ">=1.11.0" },
|
| 153 |
+
{ name = "sqlglot", specifier = ">=25.0.0" },
|
| 154 |
{ name = "sse-starlette", specifier = "==2.1.3" },
|
| 155 |
{ name = "starlette", specifier = "==0.41.3" },
|
| 156 |
{ name = "structlog", specifier = "==24.4.0" },
|
|
|
|
| 3223 |
{ url = "https://files.pythonhosted.org/packages/c0/87/11e6de00ef7949bb8ea06b55304a1a4911c329fdf0d9882b464db240c2c5/sqlalchemy_bigquery-1.16.0-py3-none-any.whl", hash = "sha256:0fe7634cd954f3e74f5e2db6d159f9e5ee87a47fbe8d52eac3cd3bb3dadb3a77", size = 40615, upload-time = "2025-11-06T01:35:39.358Z" },
|
| 3224 |
]
|
| 3225 |
|
| 3226 |
+
[[package]]
|
| 3227 |
+
name = "sqlglot"
|
| 3228 |
+
version = "30.6.0"
|
| 3229 |
+
source = { registry = "https://pypi.org/simple" }
|
| 3230 |
+
sdist = { url = "https://files.pythonhosted.org/packages/3c/66/6ece15f197874e56c76e1d0269cebf284ba992a80dfadca9d1972fdf7edf/sqlglot-30.6.0.tar.gz", hash = "sha256:246d34d39927422a50a3fa155f37b2f6346fba85f1a755b13c941eb32ef93361", size = 5835307, upload-time = "2026-04-20T20:11:08.164Z" }
|
| 3231 |
+
wheels = [
|
| 3232 |
+
{ url = "https://files.pythonhosted.org/packages/dc/e7/64fe971cbca33a0446b06f4a5ff8e3fa4a1dbd0a039ceabcc3e6cf4087a9/sqlglot-30.6.0-py3-none-any.whl", hash = "sha256:e005fc2f47994f90d7d8df341f1cbe937518497b0b7b1507d4c03c4c9dfd2778", size = 673920, upload-time = "2026-04-20T20:11:05.758Z" },
|
| 3233 |
+
]
|
| 3234 |
+
|
| 3235 |
[[package]]
|
| 3236 |
name = "srsly"
|
| 3237 |
version = "2.5.3"
|