Skip to content

Commit 01d3a4b

Browse files
feat: make embedding_vec the sole store and hard-fail pgvector-on-SQLite
Drop the legacy CSV embeddings column in c7d8e9f0a1b2 after backfill, port readers/writers (including Module C) to embedding_vec, and SystemExit when pgvector similarity is requested without Postgres. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2dc4062 commit 01d3a4b

17 files changed

Lines changed: 592 additions & 80 deletions

application/cmd/cre_main.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,14 +1115,12 @@ def run_librarian(
11151115

11161116
backend = RetrieverBackend(cfg.retriever_backend)
11171117
if backend is RetrieverBackend.pgvector:
1118-
# Readiness must include the embedding_vec column (not dialect alone):
1119-
# Postgres pre-migration would otherwise fail on every retrieve().
1118+
# Hard-fail: do not silently fall back to in_memory / sklearn CSV paths.
1119+
from application.database.pgvector_utils import fail_pgvector_unavailable
1120+
11201121
if not database.can_use_pgvector_similarity():
1121-
logger.warning(
1122-
"CRE_LIBRARIAN_RETRIEVER_BACKEND=pgvector selected, but "
1123-
"Postgres is not ready for pgvector similarity (need Alembic "
1124-
"c7d8e9f0a1b2 / #977 embedding_vec). Set the backend to "
1125-
"in_memory until then."
1122+
fail_pgvector_unavailable(
1123+
context="CRE_LIBRARIAN_RETRIEVER_BACKEND=pgvector"
11261124
)
11271125
# The CRE ids present in the hub are exactly the known ids the explicit
11281126
# resolver may auto-link to (W2 seeded this from the golden set; here it is
@@ -1136,13 +1134,16 @@ def run_librarian(
11361134
if backend is RetrieverBackend.in_memory
11371135
else None
11381136
)
1137+
pg_connection = None
1138+
if backend is RetrieverBackend.pgvector:
1139+
pg_connection = database.session.connection()
11391140
retriever = build_retriever(
11401141
backend,
11411142
embed_fn=ph.get_text_embeddings,
11421143
top_k=cfg.top_k_retrieval,
11431144
threshold=cfg.link_threshold,
11441145
pool=pool,
1145-
connection=database.session.connection(),
1146+
connection=pg_connection,
11461147
)
11471148

11481149
# C.2 reranker: reads each (section, candidate-CRE) pair together and

application/database/db.py

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,6 @@ class Embeddings(BaseModel): # type: ignore
174174
__tablename__ = "embeddings"
175175

176176
id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid)
177-
embeddings = sqla.Column(sqla.String, nullable=False)
178177
doc_type = sqla.Column(sqla.String, nullable=False)
179178
cre_id = sqla.Column(
180179
sqla.String,
@@ -191,9 +190,10 @@ class Embeddings(BaseModel): # type: ignore
191190
embeddings_content = sqla.Column(sqla.String, nullable=True, default=None)
192191
embedding_model_id = sqla.Column(sqla.String, nullable=True, default=None)
193192
embedding_dim = sqla.Column(sqla.Integer, nullable=True, default=None)
194-
# Postgres: real ``vector(N)`` via Alembic (c7d8e9f0a1b2). SQLite tests map
195-
# this as Text storing a pgvector literal for dual-write coverage.
196-
embedding_vec = sqla.Column(sqla.Text, nullable=True, default=None)
193+
# Postgres: real ``vector(N)`` via Alembic (c7d8e9f0a1b2) — sole store for
194+
# vectors (legacy CSV ``embeddings`` column is dropped in that migration).
195+
# SQLite/tests: Text holding a pgvector literal ``[1.0,2.0,...]``.
196+
embedding_vec = sqla.Column(sqla.Text, nullable=False)
197197

198198

199199
class GapAnalysisResults(BaseModel):
@@ -2367,16 +2367,27 @@ def health_check(self) -> Dict[str, Any]:
23672367
}
23682368

23692369
def get_embeddings_by_doc_type(self, doc_type: str) -> Dict[str, List[float]]:
2370+
from application.database.pgvector_utils import (
2371+
parse_stored_embedding_vec,
2372+
require_embedding_vec_store,
2373+
)
2374+
2375+
require_embedding_vec_store(
2376+
self.session.get_bind(), context="get_embeddings_by_doc_type"
2377+
)
23702378
res = {}
23712379
embeddings = (
23722380
self.session.query(Embeddings).filter(Embeddings.doc_type == doc_type).all()
23732381
)
23742382
if embeddings:
23752383
for entry in embeddings:
2384+
vec = parse_stored_embedding_vec(entry.embedding_vec)
2385+
if not vec:
2386+
continue
23762387
if doc_type == cre_defs.Credoctypes.CRE.value:
2377-
res[entry.cre_id] = [float(e) for e in entry.embeddings.split(",")]
2388+
res[entry.cre_id] = vec
23782389
else:
2379-
res[entry.node_id] = [float(e) for e in entry.embeddings.split(",")]
2390+
res[entry.node_id] = vec
23802391
return res
23812392

23822393
def get_embedding_contents_by_doc_type(self, doc_type: str) -> Dict[str, str]:
@@ -2403,6 +2414,14 @@ def get_embedding_contents_by_doc_type(self, doc_type: str) -> Dict[str, str]:
24032414
def get_embeddings_by_doc_type_paginated(
24042415
self, doc_type: str, page: int = 1, per_page: int = 100
24052416
) -> Tuple[Dict[str, List[float]], int, int]:
2417+
from application.database.pgvector_utils import (
2418+
parse_stored_embedding_vec,
2419+
require_embedding_vec_store,
2420+
)
2421+
2422+
require_embedding_vec_store(
2423+
self.session.get_bind(), context="get_embeddings_by_doc_type_paginated"
2424+
)
24062425
res = {}
24072426
embeddings = (
24082427
self.session.query(Embeddings)
@@ -2412,13 +2431,21 @@ def get_embeddings_by_doc_type_paginated(
24122431
total_pages = embeddings.pages
24132432
if embeddings.items:
24142433
for entry in embeddings.items:
2434+
vec = parse_stored_embedding_vec(entry.embedding_vec)
2435+
if not vec:
2436+
continue
24152437
if doc_type == cre_defs.Credoctypes.CRE.value:
2416-
res[entry.cre_id] = [float(e) for e in entry.embeddings.split(",")]
2438+
res[entry.cre_id] = vec
24172439
else:
2418-
res[entry.node_id] = [float(e) for e in entry.embeddings.split(",")]
2440+
res[entry.node_id] = vec
24192441
return res, total_pages, page
24202442

24212443
def get_embeddings_for_doc(self, doc: cre_defs.Node | cre_defs.CRE) -> Embeddings:
2444+
from application.database.pgvector_utils import require_embedding_vec_store
2445+
2446+
require_embedding_vec_store(
2447+
self.session.get_bind(), context="get_embeddings_for_doc"
2448+
)
24222449
if doc.doctype == cre_defs.Credoctypes.CRE:
24232450
obj = self.session.query(CRE).filter(CRE.external_id == doc.id).first()
24242451
return (
@@ -2487,13 +2514,15 @@ def add_embedding(
24872514
f"embedding dimension mismatch for {db_object.id}: "
24882515
f"expected {expected_dim}, got {len(embeddings)}"
24892516
)
2490-
existing = self.get_embedding(db_object.id)
2491-
embeddings_str = ",".join([str(e) for e in embeddings])
2492-
# Dual-write CSV + pgvector literal. On Postgres after migration the
2493-
# column is ``vector(N)``; on SQLite tests it is Text. Never LLM
2494-
# re-embed here — see application.database.pgvector_utils.
2495-
from application.database.pgvector_utils import to_pgvector_literal
2517+
from application.database.pgvector_utils import (
2518+
require_embedding_vec_store,
2519+
to_pgvector_literal,
2520+
)
24962521

2522+
require_embedding_vec_store(self.session.get_bind(), context="add_embedding")
2523+
existing = self.get_embedding(db_object.id)
2524+
# Sole store is ``embedding_vec`` (pgvector on Postgres; Text literal
2525+
# on SQLite). Never LLM re-embed here — see pgvector_utils.
24972526
embedding_vec_literal = to_pgvector_literal(embeddings)
24982527
resolved_node_url: Optional[str] = None
24992528
if doctype != cre_defs.Credoctypes.CRE:
@@ -2505,7 +2534,6 @@ def add_embedding(
25052534
emb = None
25062535
if doctype == cre_defs.Credoctypes.CRE:
25072536
emb = Embeddings(
2508-
embeddings=embeddings_str,
25092537
cre_id=db_object.id,
25102538
doc_type=cre_defs.Credoctypes.CRE.value,
25112539
embeddings_content=embedding_text,
@@ -2515,7 +2543,6 @@ def add_embedding(
25152543
)
25162544
else:
25172545
emb = Embeddings(
2518-
embeddings=embeddings_str,
25192546
node_id=db_object.id,
25202547
doc_type=db_object.ntype,
25212548
embeddings_content=embedding_text,
@@ -2529,7 +2556,6 @@ def add_embedding(
25292556
return emb
25302557
else:
25312558
logger.debug(f"knew of embedding for object {db_object.id} ,updating")
2532-
existing[0].embeddings = embeddings_str
25332559
existing[0].embeddings_content = embedding_text
25342560
existing[0].embedding_model_id = embedding_model_id
25352561
existing[0].embedding_dim = embedding_dim
@@ -2571,14 +2597,20 @@ def find_most_similar_embedding_id(
25712597
"""Top-1 cosine match via pgvector ``<=>`` (Postgres only).
25722598
25732599
Returns ``(object_id, score)`` or ``(None, None)`` below threshold /
2574-
when no rows match.
2600+
when no rows match. Refuses SQLite / missing ``embedding_vec`` with
2601+
``SystemExit`` — callers that need a soft fallback must gate on
2602+
``can_use_pgvector_similarity()`` first and use sklearn instead.
25752603
"""
25762604
from application.database.pgvector_utils import (
2605+
fail_pgvector_unavailable,
25772606
most_similar_id_sql,
25782607
to_pgvector_literal,
25792608
)
25802609
from sqlalchemy import text as sql_text
25812610

2611+
if not self.can_use_pgvector_similarity():
2612+
fail_pgvector_unavailable(context="find_most_similar_embedding_id")
2613+
25822614
sql = most_similar_id_sql(id_column)
25832615
bind = self.session.get_bind()
25842616
params = {
@@ -2599,6 +2631,8 @@ def find_most_similar_embedding_id(
25992631
if score < similarity_threshold:
26002632
return None, None
26012633
return str(row.object_id), score
2634+
except SystemExit:
2635+
raise
26022636
except Exception as exc:
26032637
# Match prior sklearn-path resilience: chat/import should degrade
26042638
# to "no match" rather than 500 on a transient driver/query error.

application/database/pgvector_utils.py

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
Do **not** re-embed the full corpus on Heroku/production dynos — likely
44
OOM-killed. Re-embedding (fix missing / wrong-dim rows via the LLM) must run
55
only where there is enough RAM (local or a high-resource worker), then
6-
sync/backfill vectors. Alembic on prod only: enable extension, add column,
7-
copy CSV→vector for matching dims.
6+
sync/backfill vectors. Alembic on prod: enable extension, add column, copy
7+
CSV→vector for matching dims, then DROP the legacy CSV ``embeddings`` column.
88
99
HNSW/IVFFlat indexes are deferred (Essential-1 capacity); see migration
1010
``c7d8e9f0a1b2`` docstring for the follow-up plan.
@@ -21,14 +21,123 @@
2121
HEROKU_REEMBED_WARNING = (
2222
"Do not re-embed the full corpus on Heroku/production dynos — likely "
2323
"OOM-killed. Re-embedding must run only on a machine with enough RAM, "
24-
"then sync/backfill vectors. Alembic on prod only copies CSV→vector."
24+
"then sync/backfill vectors. Alembic on prod only copies CSV→vector "
25+
"then drops the CSV column."
26+
)
27+
28+
PGVECTOR_UNAVAILABLE_EXIT_MSG = (
29+
"pgvector embeddings are required but unavailable on this database. "
30+
"Need Postgres with embeddings.embedding_vec (Alembic c7d8e9f0a1b2). "
31+
"SQLite cannot serve pgvector similarity (``<=>``). For Module C / "
32+
"Librarian on SQLite or CI, set CRE_LIBRARIAN_RETRIEVER_BACKEND=in_memory "
33+
"(reads ``embedding_vec`` Text literals into an in-RAM hub). For "
34+
"Postgres-side similarity, use local Postgres (make docker-postgres) or "
35+
"a remote Postgres URL with the vector extension."
36+
)
37+
38+
EMBEDDING_VEC_REQUIRED_EXIT_MSG = (
39+
"embeddings.embedding_vec is required; the legacy CSV ``embeddings`` "
40+
"column is no longer a supported store. Postgres: run Alembic upgrade "
41+
"to c7d8e9f0a1b2. SQLite caches: rewrite with "
42+
"`python scripts/rewrite_sqlite_embeddings_to_vec.py --db PATH` or "
43+
"re-export from Postgres after the migration."
2544
)
2645

2746

2847
class MultiDimEmbeddingError(RuntimeError):
2948
"""Raised when more than one embedding dimension is present in the DB."""
3049

3150

51+
class PgVectorUnavailableError(RuntimeError):
52+
"""Raised when a pgvector operation is requested without Postgres+vector."""
53+
54+
55+
def _exit_with_message(msg: str) -> None:
56+
import logging
57+
58+
logging.getLogger(__name__).error(msg)
59+
raise SystemExit(msg)
60+
61+
62+
def fail_pgvector_unavailable(*, context: str = "") -> None:
63+
"""Log and exit — callers must not silently fall back to CSV/sklearn.
64+
65+
Intentionally raises ``SystemExit`` so CLI/import paths stop with a clear
66+
message when ``CRE_LIBRARIAN_RETRIEVER_BACKEND=pgvector`` (or other
67+
pgvector-only code) runs against SQLite / pre-migration Postgres.
68+
"""
69+
detail = f" ({context})" if context else ""
70+
_exit_with_message(PGVECTOR_UNAVAILABLE_EXIT_MSG + detail)
71+
72+
73+
def fail_embedding_vec_required(*, context: str = "") -> None:
74+
"""Exit when the vector store column is missing (legacy CSV-only schema)."""
75+
detail = f" ({context})" if context else ""
76+
_exit_with_message(EMBEDDING_VEC_REQUIRED_EXIT_MSG + detail)
77+
78+
79+
def connection_dialect_name(connection: Any) -> str:
80+
"""Best-effort dialect name from a SQLAlchemy Connection/Engine/Session bind."""
81+
dialect = getattr(connection, "dialect", None)
82+
if dialect is not None:
83+
return getattr(dialect, "name", "") or ""
84+
bind = getattr(connection, "get_bind", None)
85+
if callable(bind):
86+
return connection_dialect_name(bind())
87+
engine = getattr(connection, "engine", None)
88+
if engine is not None:
89+
return connection_dialect_name(engine)
90+
return ""
91+
92+
93+
def require_pgvector_connection(connection: Any, *, context: str = "") -> None:
94+
"""Refuse SQLite (and unknown non-Postgres) when loading pgvector embeddings."""
95+
dialect = connection_dialect_name(connection)
96+
if dialect == "sqlite":
97+
fail_pgvector_unavailable(context=context or "sqlite connection")
98+
# Hermetic test fakes often omit dialect — allow those through.
99+
if dialect and dialect != "postgresql":
100+
fail_pgvector_unavailable(context=context or f"unsupported dialect {dialect!r}")
101+
102+
103+
def _embeddings_table_columns(connection: Any) -> Optional[Set[str]]:
104+
"""Return column names for ``embeddings``, or None when the table is absent."""
105+
dialect = connection_dialect_name(connection)
106+
if dialect == "sqlite":
107+
rows = _execute(connection, text("PRAGMA table_info(embeddings)")).fetchall()
108+
if not rows:
109+
return None
110+
return {str(r[1]) for r in rows}
111+
if dialect != "postgresql":
112+
return None
113+
rows = _execute(
114+
connection,
115+
text(
116+
"SELECT column_name FROM information_schema.columns "
117+
"WHERE table_name = 'embeddings'"
118+
),
119+
).fetchall()
120+
if not rows:
121+
return None
122+
return {str(r[0]) for r in rows}
123+
124+
125+
def require_embedding_vec_store(connection: Any, *, context: str = "") -> None:
126+
"""Refuse legacy CSV-only schemas; require ``embedding_vec``.
127+
128+
No-ops for hermetic fakes (empty dialect) and when the ``embeddings``
129+
table does not exist yet (caller will hit a normal SQLAlchemy error).
130+
"""
131+
dialect = connection_dialect_name(connection)
132+
if not dialect:
133+
return
134+
cols = _embeddings_table_columns(connection)
135+
if cols is None:
136+
return
137+
if "embedding_vec" not in cols:
138+
fail_embedding_vec_required(context=context or f"{dialect} embeddings schema")
139+
140+
32141
def _execute(connection: Any, statement: Any, params: Optional[dict] = None):
33142
"""Run ``statement`` on a Connection/Session, or open one from an Engine.
34143
@@ -53,6 +162,23 @@ def csv_embeddings_to_literal(csv: str) -> str:
53162
return f"[{(csv or '').strip()}]"
54163

55164

165+
def parse_stored_embedding_vec(value: Any) -> list[float]:
166+
"""Parse a stored ``embedding_vec`` (pgvector / Text literal) to floats.
167+
168+
Accepts ``[1.0, 2.0]``, ``1.0,2.0``, or a sequence of numbers.
169+
"""
170+
if value is None:
171+
return []
172+
if isinstance(value, (list, tuple)):
173+
return [float(x) for x in value]
174+
s = str(value).strip()
175+
if not s:
176+
return []
177+
if s.startswith("[") and s.endswith("]"):
178+
s = s[1:-1]
179+
return [float(part.strip()) for part in s.split(",") if part.strip() != ""]
180+
181+
56182
def parse_csv_embedding_dim(csv: str) -> int:
57183
"""Return the number of floats implied by a CSV embeddings string."""
58184
parts = [p for p in (csv or "").split(",") if p.strip() != ""]

application/tests/db_test.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2390,8 +2390,8 @@ def test_add_embedding_persists_embedding_contract_metadata(self):
23902390
finally:
23912391
os.environ.pop("CRE_EMBED_MODEL", None)
23922392

2393-
def test_add_embedding_dual_writes_embedding_vec_literal(self):
2394-
"""CSV + embedding_vec literal are persisted together (#977)."""
2393+
def test_add_embedding_writes_embedding_vec_literal(self):
2394+
"""Vectors are stored only in embedding_vec (#977 option C)."""
23952395
from application.database.pgvector_utils import to_pgvector_literal
23962396

23972397
dbsa = db.Node(
@@ -2408,13 +2408,17 @@ def test_add_embedding_dual_writes_embedding_vec_literal(self):
24082408
db_object=dbsa,
24092409
doctype=defs.Credoctypes.Standard.value,
24102410
embeddings=vec,
2411-
embedding_text="dual-write",
2411+
embedding_text="vec-only",
24122412
)
24132413
row = self.collection.get_embedding(dbsa.id)[0]
2414-
self.assertEqual(row.embeddings, "0.1,0.2,0.3")
2415-
self.assertEqual(row.embeddings_content, "dual-write")
2414+
self.assertNotIn("embeddings", type(row).__table__.c)
2415+
self.assertEqual(row.embeddings_content, "vec-only")
24162416
self.assertEqual(row.embedding_vec, to_pgvector_literal(vec))
24172417
self.assertEqual(row.embedding_dim, 3)
2418+
by_type = self.collection.get_embeddings_by_doc_type(
2419+
defs.Credoctypes.Standard.value
2420+
)
2421+
self.assertEqual(by_type[dbsa.id], vec)
24182422

24192423
def test_assert_embedding_contract_fails_on_mixed_dimensions(self):
24202424
n1 = db.Node(

0 commit comments

Comments
 (0)