Rifqi Hafizuddin commited on
Commit
fc1239a
·
1 Parent(s): cf77d20

[KM-533] add table level schema, differentiate with chunk level. expand retrieval result with FK exploration

Browse files
src/pipeline/db_pipeline/db_pipeline_service.py CHANGED
@@ -21,7 +21,13 @@ from src.db.postgres.connection import _pgvector_engine
21
  from src.db.postgres.vector_store import get_vector_store
22
  from src.middlewares.logging import get_logger
23
  from src.models.credentials import DbType
24
- from src.pipeline.db_pipeline.extractor import get_schema, profile_table
 
 
 
 
 
 
25
 
26
  logger = get_logger("db_pipeline")
27
 
@@ -156,6 +162,7 @@ class DbPipelineService:
156
  metadata={
157
  "user_id": user_id,
158
  "source_type": "database",
 
159
  "database_client_id": client_id,
160
  "updated_at": updated_at,
161
  "data": {
@@ -168,6 +175,46 @@ class DbPipelineService:
168
  },
169
  )
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  async def run(
172
  self,
173
  user_id: str,
@@ -193,6 +240,29 @@ class DbPipelineService:
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.
 
21
  from src.db.postgres.vector_store import get_vector_store
22
  from src.middlewares.logging import get_logger
23
  from src.models.credentials import DbType
24
+ from src.pipeline.db_pipeline.extractor import (
25
+ build_table_chunk,
26
+ fetch_sample_row,
27
+ get_row_count,
28
+ get_schema,
29
+ profile_table,
30
+ )
31
 
32
  logger = get_logger("db_pipeline")
33
 
 
162
  metadata={
163
  "user_id": user_id,
164
  "source_type": "database",
165
+ "chunk_level": "column",
166
  "database_client_id": client_id,
167
  "updated_at": updated_at,
168
  "data": {
 
175
  },
176
  )
177
 
178
+ def _to_table_document(
179
+ self,
180
+ user_id: str,
181
+ client_id: str,
182
+ table_name: str,
183
+ columns: list[dict],
184
+ row_count: int,
185
+ text: str,
186
+ updated_at: str,
187
+ ) -> LangChainDocument:
188
+ foreign_keys = []
189
+ for c in columns:
190
+ fk = c.get("foreign_key")
191
+ if not fk:
192
+ continue
193
+ target_table, _, target_column = fk.partition(".")
194
+ foreign_keys.append({
195
+ "column": c["name"],
196
+ "target_table": target_table,
197
+ "target_column": target_column,
198
+ })
199
+
200
+ return LangChainDocument(
201
+ page_content=text,
202
+ metadata={
203
+ "user_id": user_id,
204
+ "source_type": "database",
205
+ "chunk_level": "table",
206
+ "database_client_id": client_id,
207
+ "updated_at": updated_at,
208
+ "data": {
209
+ "table_name": table_name,
210
+ "row_count": row_count,
211
+ "primary_key": [c["name"] for c in columns if c.get("is_primary_key")],
212
+ "foreign_keys": foreign_keys,
213
+ "column_names": [c["name"] for c in columns],
214
+ },
215
+ },
216
+ )
217
+
218
  async def run(
219
  self,
220
  user_id: str,
 
240
  entries = await asyncio.to_thread(profile_table, engine, table_name, columns)
241
  docs = [self._to_document(user_id, client_id, table_name, e, updated_at) for e in entries]
242
  all_docs.extend(docs)
243
+
244
+ # Table-level chunk. Failures here are logged and skipped — column
245
+ # chunks above are already in all_docs and will still be written.
246
+ try:
247
+ row_count = await asyncio.to_thread(get_row_count, engine, table_name)
248
+ sample_row = (
249
+ await asyncio.to_thread(fetch_sample_row, engine, table_name)
250
+ if row_count > 0
251
+ else None
252
+ )
253
+ table_text = build_table_chunk(
254
+ table_name, row_count, columns, entries, sample_row
255
+ )
256
+ all_docs.append(
257
+ self._to_table_document(
258
+ user_id, client_id, table_name, columns, row_count, table_text, updated_at
259
+ )
260
+ )
261
+ except Exception as e:
262
+ logger.error(
263
+ "table chunk generation failed", table=table_name, error=str(e)
264
+ )
265
+
266
  logger.info("profiled table", table=table_name, count=len(docs))
267
 
268
  # Insert new chunks first; only delete stale chunks after the insert succeeds.
src/pipeline/db_pipeline/extractor.py CHANGED
@@ -85,7 +85,9 @@ def get_schema(
85
 
86
 
87
  def get_row_count(engine: Engine, table_name: str) -> int:
88
- return pd.read_sql(f"SELECT COUNT(*) FROM {_qi(engine, table_name)}", engine).iloc[0, 0]
 
 
89
 
90
 
91
  def profile_column(
@@ -187,6 +189,74 @@ def profile_table(engine: Engine, table_name: str, columns: list[dict]) -> list[
187
  return results
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def build_text(table_name: str, row_count: int, col: dict, profile: dict) -> str:
191
  col_name = col["name"]
192
  col_type = col["type"]
 
85
 
86
 
87
  def get_row_count(engine: Engine, table_name: str) -> int:
88
+ # Cast to plain int pandas returns numpy.int64 which fails JSONB serialization
89
+ # when the value lands in PGVector cmetadata via the table-level chunk.
90
+ return int(pd.read_sql(f"SELECT COUNT(*) FROM {_qi(engine, table_name)}", engine).iloc[0, 0])
91
 
92
 
93
  def profile_column(
 
189
  return results
190
 
191
 
192
+ def fetch_sample_row(engine: Engine, table_name: str) -> Optional[dict]:
193
+ """First row of the table as a dict, or None if the table is empty.
194
+
195
+ Reuses _qi for dialect-correct quoting and _head_query for TOP/LIMIT.
196
+ """
197
+ qt = _qi(engine, table_name)
198
+ sql = _head_query(engine, "*", qt, 1)
199
+ df = pd.read_sql(sql, engine)
200
+ if df.empty:
201
+ return None
202
+ return df.iloc[0].to_dict()
203
+
204
+
205
+ def build_table_chunk(
206
+ table_name: str,
207
+ row_count: int,
208
+ columns: list[dict],
209
+ column_profiles: list[dict],
210
+ sample_row: Optional[dict],
211
+ ) -> str:
212
+ """Build the table-level chunk text.
213
+
214
+ Format (lines omitted when not applicable):
215
+ Table: {name} ({row_count} rows)
216
+ Primary key: {pk_cols}
217
+ Foreign keys: {col} -> {target_table}.{target_col}, ...
218
+ Columns ({n}): {col1}, {col2}, ...
219
+ Numeric ranges: {col} [{min}-{max}], ...
220
+ Sample row: {dict}
221
+
222
+ Pure formatter — no DB I/O. column_profiles is the output of profile_table
223
+ and is reused so we don't re-introspect.
224
+ """
225
+ lines = [f"Table: {table_name} ({row_count} rows)"]
226
+
227
+ pk_cols = [c["name"] for c in columns if c.get("is_primary_key")]
228
+ if pk_cols:
229
+ lines.append(f"Primary key: {', '.join(pk_cols)}")
230
+
231
+ fk_parts = [
232
+ f"{c['name']} -> {c['foreign_key']}" for c in columns if c.get("foreign_key")
233
+ ]
234
+ if fk_parts:
235
+ lines.append(f"Foreign keys: {', '.join(fk_parts)}")
236
+
237
+ col_names = [c["name"] for c in columns]
238
+ lines.append(f"Columns ({len(col_names)}): {', '.join(col_names)}")
239
+
240
+ range_parts = []
241
+ for entry in column_profiles:
242
+ col = entry["col"]
243
+ profile = entry["profile"]
244
+ if not col.get("is_numeric"):
245
+ continue
246
+ mn = profile.get("min")
247
+ mx = profile.get("max")
248
+ if mn is None or mx is None:
249
+ continue
250
+ range_parts.append(f"{col['name']} [{mn}-{mx}]")
251
+ if range_parts:
252
+ lines.append(f"Numeric ranges: {', '.join(range_parts)}")
253
+
254
+ if sample_row is not None:
255
+ lines.append(f"Sample row: {sample_row}")
256
+
257
+ return "\n".join(lines)
258
+
259
+
260
  def build_text(table_name: str, row_count: int, col: dict, profile: dict) -> str:
261
  col_name = col["name"]
262
  col_type = col["type"]
src/query/executors/db_executor.py CHANGED
@@ -41,6 +41,7 @@ _enc = tiktoken.get_encoding("cl100k_base")
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.
@@ -137,20 +138,31 @@ class DbExecutor(BaseExecutor):
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
 
@@ -180,7 +192,8 @@ class DbExecutor(BaseExecutor):
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
@@ -220,7 +233,7 @@ class DbExecutor(BaseExecutor):
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),
@@ -236,57 +249,211 @@ class DbExecutor(BaseExecutor):
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,
@@ -307,6 +474,7 @@ class DbExecutor(BaseExecutor):
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'
@@ -334,7 +502,11 @@ class DbExecutor(BaseExecutor):
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}")
@@ -352,14 +524,47 @@ class DbExecutor(BaseExecutor):
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)
@@ -374,7 +579,7 @@ class DbExecutor(BaseExecutor):
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:
 
41
  _SUPPORTED_DB_TYPES = {"postgres", "supabase", "mysql"}
42
  _MAX_RETRIES = 3
43
  _MAX_LIMIT = 500
44
+ _FK_EXPANSION_MAX_TABLES = 5
45
 
46
  _SQL_SYSTEM_PROMPT = """\
47
  You are a SQL data analyst working with a user's database.
 
138
  logger.warning("unsupported db_type for query execution", db_type=client.db_type)
139
  return None
140
 
141
+ # Hit tables = tables retrieval pointed at directly. Get full per-column
142
+ # schema for these. Related tables (one FK hop away, both directions) are
143
+ # fetched separately in abbreviated form to give the LLM enough context
144
+ # to JOIN without paying the per-column profile token cost.
145
+ hit_tables = list({
146
  r.metadata.get("data", {}).get("table_name")
147
  for r in results
148
  if r.metadata.get("data", {}).get("table_name")
149
  })
150
+ if not hit_tables:
151
+ logger.warning("no table_name on any retrieval result", client_id=client_id)
152
+ return None
153
 
154
+ full_schema = await self._fetch_full_schema(client_id, hit_tables, user_id)
155
  if not full_schema:
156
+ logger.warning("no schema found in vector store", client_id=client_id, tables=hit_tables)
157
  return None
158
 
159
+ related_tables = await self._find_related_tables(client_id, user_id, hit_tables)
160
+ related_schema = (
161
+ await self._fetch_abbreviated_schema(client_id, user_id, related_tables)
162
+ if related_tables else {}
163
+ )
164
+
165
+ schema_ctx = self._build_schema_context(full_schema, related_schema)
166
  capped_limit = min(limit, _MAX_LIMIT)
167
  dialect = client.db_type
168
 
 
192
  "question": question,
193
  })
194
  sql = result.sql.strip()
195
+ allowed_tables = set(full_schema) | set(related_schema)
196
+ validation_error = self._validate(sql, allowed_tables, capped_limit)
197
  if validation_error:
198
  prev_error = validation_error
199
  prev_reasoning = result.reasoning
 
233
  return QueryResult(
234
  source_type="database",
235
  source_id=client_id,
236
+ table_or_file=", ".join(hit_tables),
237
  columns=columns,
238
  rows=rows,
239
  row_count=len(rows),
 
249
  # Schema helpers
250
  # ------------------------------------------------------------------
251
 
252
+ async def _find_related_tables(
253
  self,
254
  client_id: str,
255
  user_id: str,
256
+ hit_tables: list[str],
257
  ) -> list[str]:
258
+ """One-hop FK neighbours of `hit_tables`, both directions, excluding hits.
259
 
260
+ Prefers chunk_level='table' rows; if none exist for the client (legacy
261
+ ingest predating Phase 1), falls back to aggregating from column-chunk
262
+ metadata. Returns [] when no FK metadata is available.
263
+
264
+ Capped at _FK_EXPANSION_MAX_TABLES, ranked by edge count desc then
265
+ table name asc. A warning is logged when the cap kicks in.
266
  """
267
+ if not hit_tables:
268
+ return []
269
 
270
+ hit_set = set(hit_tables)
271
+ # edge_counts[related_table] = number of FK edges connecting it to the hit set
272
+ edge_counts: dict[str, int] = defaultdict(int)
273
+
274
+ # ---- Primary path: table-level chunks ----
275
+ sql = text("""
276
+ SELECT lpe.cmetadata
277
  FROM langchain_pg_embedding lpe
278
  JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
279
  WHERE lpc.name = 'document_embeddings'
280
  AND lpe.cmetadata->>'user_id' = :user_id
281
  AND lpe.cmetadata->>'source_type' = 'database'
282
  AND lpe.cmetadata->>'database_client_id' = :client_id
283
+ AND lpe.cmetadata->>'chunk_level' = 'table'
 
284
  """)
285
+ async with _pgvector_engine.connect() as conn:
286
+ result = await conn.execute(sql, {"user_id": user_id, "client_id": client_id})
287
+ table_rows = result.fetchall()
288
+
289
+ if table_rows:
290
+ for row in table_rows:
291
+ data = row.cmetadata.get("data", {})
292
+ table = data.get("table_name")
293
+ fks = data.get("foreign_keys") or []
294
+ if not table:
295
+ continue
296
+ if table in hit_set:
297
+ # Outgoing: this hit's FKs point at related tables
298
+ for fk in fks:
299
+ target = fk.get("target_table")
300
+ if target and target not in hit_set:
301
+ edge_counts[target] += 1
302
+ else:
303
+ # Incoming: this non-hit table's FKs point into the hit set
304
+ for fk in fks:
305
+ target = fk.get("target_table")
306
+ if target in hit_set:
307
+ edge_counts[table] += 1
308
+ else:
309
+ # ---- Fallback: aggregate from column chunks ----
310
+ sql = text("""
311
+ SELECT lpe.cmetadata->'data'->>'table_name' AS src_table,
312
+ lpe.cmetadata->'data'->>'foreign_key' AS fk
313
+ FROM langchain_pg_embedding lpe
314
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
315
+ WHERE lpc.name = 'document_embeddings'
316
+ AND lpe.cmetadata->>'user_id' = :user_id
317
+ AND lpe.cmetadata->>'source_type' = 'database'
318
+ AND lpe.cmetadata->>'database_client_id' = :client_id
319
+ AND lpe.cmetadata->>'chunk_level' = 'column'
320
+ AND lpe.cmetadata->'data'->>'foreign_key' IS NOT NULL
321
+ """)
322
+ async with _pgvector_engine.connect() as conn:
323
+ result = await conn.execute(sql, {"user_id": user_id, "client_id": client_id})
324
+ col_rows = result.fetchall()
325
+
326
+ for row in col_rows:
327
+ src = row.src_table
328
+ fk = row.fk
329
+ if not src or not fk:
330
+ continue
331
+ target = fk.split(".", 1)[0]
332
+ if src in hit_set and target and target not in hit_set:
333
+ edge_counts[target] += 1
334
+ elif src not in hit_set and target in hit_set:
335
+ edge_counts[src] += 1
336
+
337
+ if not edge_counts:
338
+ return []
339
+
340
+ ranked = sorted(edge_counts.items(), key=lambda kv: (-kv[1], kv[0]))
341
+ if len(ranked) > _FK_EXPANSION_MAX_TABLES:
342
+ logger.warning(
343
+ "fk expansion cap hit",
344
+ client_id=client_id,
345
+ total=len(ranked),
346
+ cap=_FK_EXPANSION_MAX_TABLES,
347
+ dropped=[t for t, _ in ranked[_FK_EXPANSION_MAX_TABLES:]],
348
+ )
349
+ ranked = ranked[:_FK_EXPANSION_MAX_TABLES]
350
+
351
+ related = [t for t, _ in ranked]
352
+ logger.info("fk-related tables", hit=sorted(hit_set), related=related)
353
+ return related
354
+
355
+ async def _fetch_abbreviated_schema(
356
+ self,
357
+ client_id: str,
358
+ user_id: str,
359
+ table_names: list[str],
360
+ ) -> dict[str, dict[str, Any]]:
361
+ """Abbreviated schema: name, row_count, PK, FKs, column names — no profiles.
362
+
363
+ Prefers chunk_level='table' rows. Falls back to aggregating column-chunk
364
+ metadata when table chunks are missing for a given table_name.
365
 
366
+ Returns {table_name: {"row_count": int|None, "primary_key": [str],
367
+ "foreign_keys": [{column, target_table, target_column}],
368
+ "column_names": [str]}}.
369
+ """
370
+ if not table_names:
371
+ return {}
372
+
373
+ placeholders = ", ".join(f":t{i}" for i in range(len(table_names)))
374
  params: dict[str, Any] = {"user_id": user_id, "client_id": client_id}
375
  for i, name in enumerate(table_names):
376
  params[f"t{i}"] = name
377
 
378
+ # Primary path: one row per table from chunk_level='table'
379
+ sql_table = text(f"""
380
+ SELECT lpe.cmetadata
381
+ FROM langchain_pg_embedding lpe
382
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
383
+ WHERE lpc.name = 'document_embeddings'
384
+ AND lpe.cmetadata->>'user_id' = :user_id
385
+ AND lpe.cmetadata->>'source_type' = 'database'
386
+ AND lpe.cmetadata->>'database_client_id' = :client_id
387
+ AND lpe.cmetadata->>'chunk_level' = 'table'
388
+ AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders})
389
+ """)
390
  async with _pgvector_engine.connect() as conn:
391
+ result = await conn.execute(sql_table, params)
392
+ t_rows = result.fetchall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
 
394
+ out: dict[str, dict[str, Any]] = {}
395
+ for row in t_rows:
396
+ data = row.cmetadata.get("data", {})
397
+ tname = data.get("table_name")
398
+ if not tname:
399
+ continue
400
+ out[tname] = {
401
+ "row_count": data.get("row_count"),
402
+ "primary_key": list(data.get("primary_key") or []),
403
+ "foreign_keys": list(data.get("foreign_keys") or []),
404
+ "column_names": list(data.get("column_names") or []),
405
+ }
406
+
407
+ # Fallback for tables with no table-chunk: aggregate column chunks
408
+ missing = [t for t in table_names if t not in out]
409
+ if missing:
410
+ placeholders_m = ", ".join(f":m{i}" for i in range(len(missing)))
411
+ params_m: dict[str, Any] = {"user_id": user_id, "client_id": client_id}
412
+ for i, name in enumerate(missing):
413
+ params_m[f"m{i}"] = name
414
+ sql_col = text(f"""
415
+ SELECT lpe.cmetadata
416
+ FROM langchain_pg_embedding lpe
417
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
418
+ WHERE lpc.name = 'document_embeddings'
419
+ AND lpe.cmetadata->>'user_id' = :user_id
420
+ AND lpe.cmetadata->>'source_type' = 'database'
421
+ AND lpe.cmetadata->>'database_client_id' = :client_id
422
+ AND lpe.cmetadata->>'chunk_level' = 'column'
423
+ AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders_m})
424
+ ORDER BY lpe.cmetadata->'data'->>'table_name', lpe.cmetadata->'data'->>'column_name'
425
+ """)
426
+ async with _pgvector_engine.connect() as conn:
427
+ result = await conn.execute(sql_col, params_m)
428
+ c_rows = result.fetchall()
429
+
430
+ agg: dict[str, dict[str, Any]] = {
431
+ t: {"row_count": None, "primary_key": [], "foreign_keys": [], "column_names": []}
432
+ for t in missing
433
+ }
434
+ for row in c_rows:
435
+ data = row.cmetadata.get("data", {})
436
+ tname = data.get("table_name")
437
+ cname = data.get("column_name")
438
+ if not tname or tname not in agg or not cname:
439
+ continue
440
+ bucket = agg[tname]
441
+ bucket["column_names"].append(cname)
442
+ if data.get("is_primary_key"):
443
+ bucket["primary_key"].append(cname)
444
+ fk = data.get("foreign_key")
445
+ if fk:
446
+ target_table, _, target_col = fk.partition(".")
447
+ bucket["foreign_keys"].append({
448
+ "column": cname,
449
+ "target_table": target_table,
450
+ "target_column": target_col,
451
+ })
452
+ for t, v in agg.items():
453
+ if v["column_names"]:
454
+ out[t] = v
455
+
456
+ return out
457
 
458
  async def _fetch_full_schema(
459
  self,
 
474
  WHERE lpc.name = 'document_embeddings'
475
  AND lpe.cmetadata->>'user_id' = :user_id
476
  AND lpe.cmetadata->>'source_type' = 'database'
477
+ AND lpe.cmetadata->>'chunk_level' = 'column'
478
  AND lpe.cmetadata->>'database_client_id' = :client_id
479
  AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders})
480
  ORDER BY lpe.cmetadata->'data'->>'table_name', lpe.cmetadata->'data'->>'column_name'
 
502
  })
503
  return dict(schema)
504
 
505
+ def _build_schema_context(
506
+ self,
507
+ schema: dict[str, list[dict[str, Any]]],
508
+ related_schema: dict[str, dict[str, Any]] | None = None,
509
+ ) -> str:
510
  lines: list[str] = []
511
  for table, columns in schema.items():
512
  lines.append(f"Table: {table}")
 
524
  lines.append(f" {line}")
525
  break
526
  lines.append("")
527
+
528
+ related_block = self._build_related_schema_block(related_schema or {})
529
+ if related_block:
530
+ lines.append(related_block)
531
+
532
  return "\n".join(lines).strip()
533
 
534
+ def _build_related_schema_block(self, related_schema: dict[str, dict[str, Any]]) -> str:
535
+ """Format the abbreviated FK-related-tables section. Empty string when no related."""
536
+ if not related_schema:
537
+ return ""
538
+ lines: list[str] = ["Related tables (one hop via FK, abbreviated — use for JOINs only):"]
539
+ for table, info in related_schema.items():
540
+ row_count = info.get("row_count")
541
+ header = f"- {table} ({row_count} rows)" if row_count is not None else f"- {table}"
542
+ lines.append(header)
543
+ pk = info.get("primary_key") or []
544
+ lines.append(f" Primary key: {', '.join(pk) if pk else '(none)'}")
545
+ fks = info.get("foreign_keys") or []
546
+ if fks:
547
+ fk_strs = [
548
+ f"{fk.get('column')} -> {fk.get('target_table')}.{fk.get('target_column')}"
549
+ for fk in fks
550
+ ]
551
+ lines.append(f" Foreign keys: {', '.join(fk_strs)}")
552
+ else:
553
+ lines.append(" Foreign keys: (none)")
554
+ cols = info.get("column_names") or []
555
+ lines.append(f" Columns: {', '.join(cols)}")
556
+ return "\n".join(lines)
557
+
558
  # ------------------------------------------------------------------
559
  # Guardrails
560
  # ------------------------------------------------------------------
561
 
562
+ def _validate(self, sql: str, allowed_tables: set[str], limit: int) -> str:
563
+ """Return an error string if validation fails, empty string if OK.
564
+
565
+ `allowed_tables` is the union of hit-table names and FK-related table
566
+ names — both are legal targets for SELECT/JOIN.
567
+ """
568
  # Layer 1: sqlglot parse + SELECT-only check
569
  try:
570
  parsed = sqlglot.parse_one(sql)
 
579
  return f"DML ({type(node).__name__}) is not allowed."
580
 
581
  # Layer 2: schema grounding — table names
582
+ known_tables = {t.lower() for t in allowed_tables}
583
  for tbl in parsed.find_all(exp.Table):
584
  name = tbl.name.lower()
585
  if name and name not in known_tables:
src/rag/retrievers/schema.py CHANGED
@@ -47,6 +47,7 @@ class SchemaRetriever(BaseRetriever):
47
  WHERE lpc.name = 'document_embeddings'
48
  AND lpe.cmetadata->>'user_id' = :user_id
49
  AND lpe.cmetadata->>'source_type' = 'database'
 
50
  ORDER BY lpe.embedding <=> '{emb_str}'::vector ASC
51
  LIMIT :k
52
  """)
@@ -110,6 +111,7 @@ class SchemaRetriever(BaseRetriever):
110
  WHERE lpc.name = 'document_embeddings'
111
  AND lpe.cmetadata->>'user_id' = :user_id
112
  AND lpe.cmetadata->>'source_type' = 'database'
 
113
  AND to_tsvector('english', lpe.document) @@ plainto_tsquery('english', :query)
114
  ORDER BY rank DESC
115
  LIMIT :k
 
47
  WHERE lpc.name = 'document_embeddings'
48
  AND lpe.cmetadata->>'user_id' = :user_id
49
  AND lpe.cmetadata->>'source_type' = 'database'
50
+ AND lpe.cmetadata->>'chunk_level' = 'column'
51
  ORDER BY lpe.embedding <=> '{emb_str}'::vector ASC
52
  LIMIT :k
53
  """)
 
111
  WHERE lpc.name = 'document_embeddings'
112
  AND lpe.cmetadata->>'user_id' = :user_id
113
  AND lpe.cmetadata->>'source_type' = 'database'
114
+ AND lpe.cmetadata->>'chunk_level' = 'column'
115
  AND to_tsvector('english', lpe.document) @@ plainto_tsquery('english', :query)
116
  ORDER BY rank DESC
117
  LIMIT :k