-
Notifications
You must be signed in to change notification settings - Fork 114
feat: pgvector embedding_vec migration + chat similarity cutover #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
13b86f0
495037a
2dc4062
01d3a4b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -174,7 +174,6 @@ class Embeddings(BaseModel): # type: ignore | |||||||||||||||||||||
| __tablename__ = "embeddings" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid) | ||||||||||||||||||||||
| embeddings = sqla.Column(sqla.String, nullable=False) | ||||||||||||||||||||||
| doc_type = sqla.Column(sqla.String, nullable=False) | ||||||||||||||||||||||
| cre_id = sqla.Column( | ||||||||||||||||||||||
| sqla.String, | ||||||||||||||||||||||
|
|
@@ -191,6 +190,10 @@ class Embeddings(BaseModel): # type: ignore | |||||||||||||||||||||
| embeddings_content = sqla.Column(sqla.String, nullable=True, default=None) | ||||||||||||||||||||||
| embedding_model_id = sqla.Column(sqla.String, nullable=True, default=None) | ||||||||||||||||||||||
| embedding_dim = sqla.Column(sqla.Integer, nullable=True, default=None) | ||||||||||||||||||||||
| # Postgres: real ``vector(N)`` via Alembic (c7d8e9f0a1b2) — sole store for | ||||||||||||||||||||||
| # vectors (legacy CSV ``embeddings`` column is dropped in that migration). | ||||||||||||||||||||||
| # SQLite/tests: Text holding a pgvector literal ``[1.0,2.0,...]``. | ||||||||||||||||||||||
| embedding_vec = sqla.Column(sqla.Text, nullable=False) | ||||||||||||||||||||||
|
Comment on lines
+193
to
+196
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win Restore the legacy The PR objectives and linked Issue 💡 Proposed fix embedding_dim = sqla.Column(sqla.Integer, nullable=True, default=None)
+ embeddings = sqla.Column(sqla.String, nullable=True, default=None)
# Postgres: real ``vector(N)`` via Alembic (c7d8e9f0a1b2) — sole store for
# vectors (legacy CSV ``embeddings`` column is dropped in that migration).
# SQLite/tests: Text holding a pgvector literal ``[1.0,2.0,...]``.
embedding_vec = sqla.Column(sqla.Text, nullable=False)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| class GapAnalysisResults(BaseModel): | ||||||||||||||||||||||
|
|
@@ -2364,16 +2367,27 @@ def health_check(self) -> Dict[str, Any]: | |||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def get_embeddings_by_doc_type(self, doc_type: str) -> Dict[str, List[float]]: | ||||||||||||||||||||||
| from application.database.pgvector_utils import ( | ||||||||||||||||||||||
| parse_stored_embedding_vec, | ||||||||||||||||||||||
| require_embedding_vec_store, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| require_embedding_vec_store( | ||||||||||||||||||||||
| self.session.get_bind(), context="get_embeddings_by_doc_type" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| res = {} | ||||||||||||||||||||||
| embeddings = ( | ||||||||||||||||||||||
| self.session.query(Embeddings).filter(Embeddings.doc_type == doc_type).all() | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| if embeddings: | ||||||||||||||||||||||
| for entry in embeddings: | ||||||||||||||||||||||
| vec = parse_stored_embedding_vec(entry.embedding_vec) | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Fallback to legacy To safely support the transition period before the backfill completes, and to honor the PR requirement to preserve the CSV column, the embedding retrieval methods should fall back to reading the legacy
📍 Affects 1 file
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| if not vec: | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| if doc_type == cre_defs.Credoctypes.CRE.value: | ||||||||||||||||||||||
| res[entry.cre_id] = [float(e) for e in entry.embeddings.split(",")] | ||||||||||||||||||||||
| res[entry.cre_id] = vec | ||||||||||||||||||||||
| else: | ||||||||||||||||||||||
| res[entry.node_id] = [float(e) for e in entry.embeddings.split(",")] | ||||||||||||||||||||||
| res[entry.node_id] = vec | ||||||||||||||||||||||
| return res | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def get_embedding_contents_by_doc_type(self, doc_type: str) -> Dict[str, str]: | ||||||||||||||||||||||
|
|
@@ -2400,6 +2414,14 @@ def get_embedding_contents_by_doc_type(self, doc_type: str) -> Dict[str, str]: | |||||||||||||||||||||
| def get_embeddings_by_doc_type_paginated( | ||||||||||||||||||||||
| self, doc_type: str, page: int = 1, per_page: int = 100 | ||||||||||||||||||||||
| ) -> Tuple[Dict[str, List[float]], int, int]: | ||||||||||||||||||||||
| from application.database.pgvector_utils import ( | ||||||||||||||||||||||
| parse_stored_embedding_vec, | ||||||||||||||||||||||
| require_embedding_vec_store, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| require_embedding_vec_store( | ||||||||||||||||||||||
| self.session.get_bind(), context="get_embeddings_by_doc_type_paginated" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| res = {} | ||||||||||||||||||||||
| embeddings = ( | ||||||||||||||||||||||
| self.session.query(Embeddings) | ||||||||||||||||||||||
|
|
@@ -2409,13 +2431,21 @@ def get_embeddings_by_doc_type_paginated( | |||||||||||||||||||||
| total_pages = embeddings.pages | ||||||||||||||||||||||
| if embeddings.items: | ||||||||||||||||||||||
| for entry in embeddings.items: | ||||||||||||||||||||||
| vec = parse_stored_embedding_vec(entry.embedding_vec) | ||||||||||||||||||||||
| if not vec: | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| if doc_type == cre_defs.Credoctypes.CRE.value: | ||||||||||||||||||||||
| res[entry.cre_id] = [float(e) for e in entry.embeddings.split(",")] | ||||||||||||||||||||||
| res[entry.cre_id] = vec | ||||||||||||||||||||||
| else: | ||||||||||||||||||||||
| res[entry.node_id] = [float(e) for e in entry.embeddings.split(",")] | ||||||||||||||||||||||
| res[entry.node_id] = vec | ||||||||||||||||||||||
| return res, total_pages, page | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def get_embeddings_for_doc(self, doc: cre_defs.Node | cre_defs.CRE) -> Embeddings: | ||||||||||||||||||||||
| from application.database.pgvector_utils import require_embedding_vec_store | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| require_embedding_vec_store( | ||||||||||||||||||||||
| self.session.get_bind(), context="get_embeddings_for_doc" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| if doc.doctype == cre_defs.Credoctypes.CRE: | ||||||||||||||||||||||
| obj = self.session.query(CRE).filter(CRE.external_id == doc.id).first() | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
|
|
@@ -2484,8 +2514,16 @@ def add_embedding( | |||||||||||||||||||||
| f"embedding dimension mismatch for {db_object.id}: " | ||||||||||||||||||||||
| f"expected {expected_dim}, got {len(embeddings)}" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| from application.database.pgvector_utils import ( | ||||||||||||||||||||||
| require_embedding_vec_store, | ||||||||||||||||||||||
| to_pgvector_literal, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| require_embedding_vec_store(self.session.get_bind(), context="add_embedding") | ||||||||||||||||||||||
| existing = self.get_embedding(db_object.id) | ||||||||||||||||||||||
| embeddings_str = ",".join([str(e) for e in embeddings]) | ||||||||||||||||||||||
| # Sole store is ``embedding_vec`` (pgvector on Postgres; Text literal | ||||||||||||||||||||||
| # on SQLite). Never LLM re-embed here — see pgvector_utils. | ||||||||||||||||||||||
| embedding_vec_literal = to_pgvector_literal(embeddings) | ||||||||||||||||||||||
| resolved_node_url: Optional[str] = None | ||||||||||||||||||||||
| if doctype != cre_defs.Credoctypes.CRE: | ||||||||||||||||||||||
| resolved_node_url = ( | ||||||||||||||||||||||
|
|
@@ -2496,32 +2534,32 @@ def add_embedding( | |||||||||||||||||||||
| emb = None | ||||||||||||||||||||||
| if doctype == cre_defs.Credoctypes.CRE: | ||||||||||||||||||||||
| emb = Embeddings( | ||||||||||||||||||||||
| embeddings=embeddings_str, | ||||||||||||||||||||||
| cre_id=db_object.id, | ||||||||||||||||||||||
| doc_type=cre_defs.Credoctypes.CRE.value, | ||||||||||||||||||||||
| embeddings_content=embedding_text, | ||||||||||||||||||||||
| embedding_model_id=embedding_model_id, | ||||||||||||||||||||||
| embedding_dim=embedding_dim, | ||||||||||||||||||||||
| embedding_vec=embedding_vec_literal, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| else: | ||||||||||||||||||||||
| emb = Embeddings( | ||||||||||||||||||||||
| embeddings=embeddings_str, | ||||||||||||||||||||||
| node_id=db_object.id, | ||||||||||||||||||||||
| doc_type=db_object.ntype, | ||||||||||||||||||||||
| embeddings_content=embedding_text, | ||||||||||||||||||||||
| embeddings_url=resolved_node_url, | ||||||||||||||||||||||
| embedding_model_id=embedding_model_id, | ||||||||||||||||||||||
| embedding_dim=embedding_dim, | ||||||||||||||||||||||
| embedding_vec=embedding_vec_literal, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| self.session.add(emb) | ||||||||||||||||||||||
| self.session.commit() | ||||||||||||||||||||||
| return emb | ||||||||||||||||||||||
| else: | ||||||||||||||||||||||
| logger.debug(f"knew of embedding for object {db_object.id} ,updating") | ||||||||||||||||||||||
| existing[0].embeddings = embeddings_str | ||||||||||||||||||||||
| existing[0].embeddings_content = embedding_text | ||||||||||||||||||||||
| existing[0].embedding_model_id = embedding_model_id | ||||||||||||||||||||||
| existing[0].embedding_dim = embedding_dim | ||||||||||||||||||||||
| existing[0].embedding_vec = embedding_vec_literal | ||||||||||||||||||||||
| if doctype != cre_defs.Credoctypes.CRE: | ||||||||||||||||||||||
| if embeddings_url is not None: | ||||||||||||||||||||||
| existing[0].embeddings_url = embeddings_url | ||||||||||||||||||||||
|
|
@@ -2531,6 +2569,79 @@ def add_embedding( | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| return existing | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def can_use_pgvector_similarity(self) -> bool: | ||||||||||||||||||||||
| """True when Postgres has ``embeddings.embedding_vec`` for SQL cosine.""" | ||||||||||||||||||||||
| cached = getattr(self, "_pgvector_similarity_ready", None) | ||||||||||||||||||||||
| if cached is not None: | ||||||||||||||||||||||
| return bool(cached) | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| bind = self.session.get_bind() | ||||||||||||||||||||||
| from application.database.pgvector_utils import embedding_vec_column_exists | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ready = getattr( | ||||||||||||||||||||||
| bind.dialect, "name", "" | ||||||||||||||||||||||
| ) == "postgresql" and embedding_vec_column_exists(bind) | ||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||
| ready = False | ||||||||||||||||||||||
| self._pgvector_similarity_ready = ready | ||||||||||||||||||||||
| return ready | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def find_most_similar_embedding_id( | ||||||||||||||||||||||
| self, | ||||||||||||||||||||||
| query_embedding: List[float], | ||||||||||||||||||||||
| *, | ||||||||||||||||||||||
| doc_type: str, | ||||||||||||||||||||||
| id_column: str, | ||||||||||||||||||||||
| similarity_threshold: float, | ||||||||||||||||||||||
| ) -> Tuple[Optional[str], Optional[float]]: | ||||||||||||||||||||||
| """Top-1 cosine match via pgvector ``<=>`` (Postgres only). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Returns ``(object_id, score)`` or ``(None, None)`` below threshold / | ||||||||||||||||||||||
| when no rows match. Refuses SQLite / missing ``embedding_vec`` with | ||||||||||||||||||||||
| ``SystemExit`` — callers that need a soft fallback must gate on | ||||||||||||||||||||||
| ``can_use_pgvector_similarity()`` first and use sklearn instead. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| from application.database.pgvector_utils import ( | ||||||||||||||||||||||
| fail_pgvector_unavailable, | ||||||||||||||||||||||
| most_similar_id_sql, | ||||||||||||||||||||||
| to_pgvector_literal, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| from sqlalchemy import text as sql_text | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if not self.can_use_pgvector_similarity(): | ||||||||||||||||||||||
| fail_pgvector_unavailable(context="find_most_similar_embedding_id") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| sql = most_similar_id_sql(id_column) | ||||||||||||||||||||||
| bind = self.session.get_bind() | ||||||||||||||||||||||
| params = { | ||||||||||||||||||||||
| "q": to_pgvector_literal(query_embedding), | ||||||||||||||||||||||
| "doc_type": doc_type, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| # Prefer the Session so we stay inside the app transaction; fall | ||||||||||||||||||||||
| # back to a Connection from the Engine for SQLAlchemy 2. | ||||||||||||||||||||||
| if hasattr(self.session, "execute"): | ||||||||||||||||||||||
| row = self.session.execute(sql_text(sql), params).fetchone() | ||||||||||||||||||||||
| else: | ||||||||||||||||||||||
| with bind.connect() as conn: | ||||||||||||||||||||||
| row = conn.execute(sql_text(sql), params).fetchone() | ||||||||||||||||||||||
| if not row: | ||||||||||||||||||||||
| return None, None | ||||||||||||||||||||||
| score = float(row.score) | ||||||||||||||||||||||
| if score < similarity_threshold: | ||||||||||||||||||||||
| return None, None | ||||||||||||||||||||||
| return str(row.object_id), score | ||||||||||||||||||||||
| except SystemExit: | ||||||||||||||||||||||
| raise | ||||||||||||||||||||||
| except Exception as exc: | ||||||||||||||||||||||
| # Match prior sklearn-path resilience: chat/import should degrade | ||||||||||||||||||||||
| # to "no match" rather than 500 on a transient driver/query error. | ||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||
| "pgvector similarity query failed (%s); returning no match", | ||||||||||||||||||||||
| exc, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| return None, None | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||
| def assert_embedding_contract( | ||||||||||||||||||||||
| self, | ||||||||||||||||||||||
| *, | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Readiness loops for Docker Postgres lack explicit failure on timeout.
The Docker Postgres readiness loops in the Makefile and benchmark script don't fail explicitly when the container doesn't become ready — the Makefile falls through to
psqlwith a confusing error, and the benchmark script's unboundeduntilloop can hang indefinitely.scripts/setup-heroku-staging.sh(L243–L251) demonstrates the correct pattern with a bounded loop and explicitdieon timeout.Makefile#L60-L64: After the 20-iteration loop, verify readiness with an explicit failure before runningpsql, so the target fails with a clear message instead of a confusing connection error.scripts/run_benchmark.sh#L44-L47: Replace the unboundeduntilloop with a bounded iteration loop and explicit failure, matching the pattern inscripts/setup-heroku-staging.sh.🔧 Proposed fix for Makefile readiness check
🔧 Proposed fix for run_benchmark.sh readiness check
📝 Committable suggestion
📍 Affects 2 files
Makefile#L60-L64(this comment)scripts/run_benchmark.sh#L44-L47🤖 Prompt for AI Agents