| """Persist schema chunks + few-shot Q/SQL pairs into Chroma. |
| |
| Two collections per arch v2 §4: |
| - ``schema_chunks``: one record per (db, table). ``text`` is the rendered |
| table card from `chunker.to_chunks`; embedded vector comes from the |
| injected `EmbeddingProvider` (Mistral in production). |
| - ``fewshot_qsql``: question text is the embedded body; SQL + db_id + intent |
| ride along as metadata (NEVER embedded — keeps the retrieval space focused |
| on intent, not on syntactic SQL similarity). |
| |
| FK graph is **not** a Chroma collection. It's reconstructed in memory from |
| the schema chunks' ``fk_targets`` metadata at retrieve time (see |
| `SchemaIndex.fk_graph`). Per docs/02_architecture_v2.md §4, this is a |
| deliberate cut: dense retrieval on FK edges is bookkeeping, not semantics. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from collections.abc import Iterable, Iterator, Mapping |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable |
|
|
| from nl_sql.llm.providers.base import EmbedRequest |
| from nl_sql.schema_index.chunker import SchemaChunk |
|
|
| if TYPE_CHECKING: |
| from chromadb.api import ClientAPI |
| from chromadb.api.models.Collection import Collection |
|
|
| SCHEMA_COLLECTION = "schema_chunks" |
| FEWSHOT_COLLECTION = "fewshot_qsql" |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class FewShotExample: |
| """One Q→SQL training pair. Sourced from BIRD train split, never dev/test |
| (see `03_eval_methodology.md` §5 — leakage prevention). |
| """ |
|
|
| example_id: str |
| db_id: str |
| question: str |
| sql: str |
| intent: str = "" |
|
|
|
|
| @runtime_checkable |
| class _Embedder(Protocol): |
| """Minimal slice of `EmbeddingProvider` we need here, named locally so the |
| indexer doesn't pull in the OpenAI client transitively at type-check time. |
| """ |
|
|
| def embed(self, req: EmbedRequest) -> Any: ... |
|
|
|
|
| class SchemaIndex: |
| """Owns a Chroma persistent client + a thread of two collections. |
| |
| Single-writer: the indexer assumes one process at a time. Concurrent |
| `index_schema` calls on the same db_id will not corrupt data (Chroma |
| upserts are per-id), but vector dimensionality is enforced by the first |
| insert into a collection — embedder swaps require wiping the collection. |
| """ |
|
|
| def __init__( |
| self, |
| persist_dir: Path | str, |
| embedder: _Embedder, |
| *, |
| client: ClientAPI | None = None, |
| embed_batch: int = 16, |
| ) -> None: |
| self._persist_dir = Path(persist_dir) |
| self._embedder = embedder |
| self._embed_batch = embed_batch |
| self._client = client or self._build_default_client(self._persist_dir) |
| self._schema = self._client.get_or_create_collection(name=SCHEMA_COLLECTION) |
| self._fewshot = self._client.get_or_create_collection(name=FEWSHOT_COLLECTION) |
|
|
| @staticmethod |
| def _build_default_client(persist_dir: Path) -> ClientAPI: |
| import chromadb |
|
|
| persist_dir.mkdir(parents=True, exist_ok=True) |
| return chromadb.PersistentClient(path=str(persist_dir)) |
|
|
| @property |
| def schema_collection(self) -> Collection: |
| return self._schema |
|
|
| @property |
| def fewshot_collection(self) -> Collection: |
| return self._fewshot |
|
|
| def index_schema(self, chunks: list[SchemaChunk]) -> int: |
| """Embed and upsert one schema chunk per table. Returns chunk count.""" |
| if not chunks: |
| return 0 |
| for batch in _batched(chunks, self._embed_batch): |
| texts = [c.text for c in batch] |
| vectors = self._embedder.embed(EmbedRequest(texts=texts)).vectors |
| self._schema.upsert( |
| ids=[c.chunk_id for c in batch], |
| documents=texts, |
| embeddings=vectors, |
| metadatas=[dict(c.metadata) for c in batch], |
| ) |
| return len(chunks) |
|
|
| def index_fewshots(self, examples: list[FewShotExample]) -> int: |
| """Embed each Q (NOT the SQL) and upsert with SQL/intent in metadata.""" |
| if not examples: |
| return 0 |
| for batch in _batched(examples, self._embed_batch): |
| texts = [ex.question for ex in batch] |
| vectors = self._embedder.embed(EmbedRequest(texts=texts)).vectors |
| self._fewshot.upsert( |
| ids=[ex.example_id for ex in batch], |
| documents=texts, |
| embeddings=vectors, |
| metadatas=[ |
| { |
| "db_id": ex.db_id, |
| "sql": ex.sql, |
| "intent": ex.intent, |
| } |
| for ex in batch |
| ], |
| ) |
| return len(examples) |
|
|
| def query_schema( |
| self, |
| question: str, |
| *, |
| db_id: str, |
| top_k: int = 5, |
| ) -> list[SchemaQueryHit]: |
| """Dense top-k over `schema_chunks` filtered to a single db_id.""" |
| qvec = self._embed_one(question) |
| result = self._schema.query( |
| query_embeddings=cast(Any, [qvec]), |
| n_results=top_k, |
| where={"db_id": db_id}, |
| ) |
| return cast( |
| list[SchemaQueryHit], |
| _hits_from_query(cast(Mapping[str, Any], result), hit_cls=SchemaQueryHit), |
| ) |
|
|
| def query_fewshots( |
| self, |
| question: str, |
| *, |
| db_id: str, |
| top_k: int = 3, |
| cross_db: bool = False, |
| ) -> list[FewShotHit]: |
| """Dense top-k over `fewshot_qsql`. |
| |
| Default (`cross_db=False`) restricts retrieval to the same `db_id`. |
| That works when fewshot pool and test pool share schemas. BIRD's |
| train and dev splits, however, partition by db_id (zero overlap) — |
| same-db retrieval would return zero hits. `cross_db=True` drops the |
| filter so the LLM sees Q→SQL patterns from any train db, which is |
| the standard cross-domain fewshot setup in NL-SQL literature. |
| """ |
| qvec = self._embed_one(question) |
| query_kwargs: dict[str, Any] = { |
| "query_embeddings": cast(Any, [qvec]), |
| "n_results": top_k, |
| } |
| if not cross_db: |
| query_kwargs["where"] = {"db_id": db_id} |
| result = self._fewshot.query(**query_kwargs) |
| return cast( |
| list[FewShotHit], |
| _hits_from_query(cast(Mapping[str, Any], result), hit_cls=FewShotHit), |
| ) |
|
|
| def fk_graph(self, db_id: str) -> dict[str, set[str]]: |
| """Reconstruct ``table → {referred_tables}`` adjacency from the |
| schema chunks' metadata. Symmetric: edges are doubled so retriever |
| can traverse in either direction. |
| """ |
| records = self._schema.get(where={"db_id": db_id}, include=["metadatas"]) |
| graph: dict[str, set[str]] = {} |
| for meta in records.get("metadatas") or []: |
| if not meta: |
| continue |
| table = str(meta.get("table_name") or "") |
| targets_raw = str(meta.get("fk_targets") or "") |
| targets = {t for t in targets_raw.split(",") if t} |
| if not table: |
| continue |
| graph.setdefault(table, set()).update(targets) |
| for t in targets: |
| graph.setdefault(t, set()).add(table) |
| return graph |
|
|
| def _embed_one(self, text: str) -> list[float]: |
| return list(self._embedder.embed(EmbedRequest(texts=[text])).vectors[0]) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class SchemaQueryHit: |
| chunk_id: str |
| table_name: str |
| db_id: str |
| text: str |
| distance: float |
| metadata: dict[str, Any] |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class FewShotHit: |
| example_id: str |
| db_id: str |
| question: str |
| sql: str |
| distance: float |
| metadata: dict[str, Any] |
|
|
|
|
| def _hits_from_query( |
| result: Mapping[str, Any], |
| *, |
| hit_cls: type[SchemaQueryHit] | type[FewShotHit], |
| ) -> list[Any]: |
| """Translate a Chroma `.query()` payload into our flat hit dataclass. |
| |
| Chroma returns each list nested by query (we always pass one query): |
| {"ids": [[id1, id2]], "documents": [[doc1, doc2]], ...} |
| so we always index `[0]` to flatten. |
| """ |
| ids = (result.get("ids") or [[]])[0] |
| docs = (result.get("documents") or [[]])[0] |
| metas = (result.get("metadatas") or [[]])[0] |
| dists = (result.get("distances") or [[]])[0] |
| hits: list[Any] = [] |
| for i, _id in enumerate(ids): |
| meta = metas[i] if i < len(metas) and metas[i] else {} |
| doc = docs[i] if i < len(docs) else "" |
| dist = float(dists[i]) if i < len(dists) and dists[i] is not None else 0.0 |
| if hit_cls is SchemaQueryHit: |
| hits.append( |
| SchemaQueryHit( |
| chunk_id=str(_id), |
| table_name=str(meta.get("table_name") or ""), |
| db_id=str(meta.get("db_id") or ""), |
| text=str(doc), |
| distance=dist, |
| metadata=dict(meta), |
| ) |
| ) |
| else: |
| hits.append( |
| FewShotHit( |
| example_id=str(_id), |
| db_id=str(meta.get("db_id") or ""), |
| question=str(doc), |
| sql=str(meta.get("sql") or ""), |
| distance=dist, |
| metadata=dict(meta), |
| ) |
| ) |
| return hits |
|
|
|
|
| def _batched(items: Iterable[Any], n: int) -> Iterator[list[Any]]: |
| bucket: list[Any] = [] |
| for item in items: |
| bucket.append(item) |
| if len(bucket) >= n: |
| yield bucket |
| bucket = [] |
| if bucket: |
| yield bucket |
|
|