feat(knowledge): put the vector store in Postgres, off the disk that gets wiped - #241
Merged
Conversation
…gets wiped
`CHROMA_PERSIST_DIR` is `/app/data/chroma` — the Heroku container filesystem. It does
not survive a dyno restart, and `web` and `worker` are separate process types with
separate copies of it. Production, 2026-08-27, five hours after a deploy:
22:00:44 repair_embeddings "Vector store empty but 758 docs in DB.
Forcing a full re-index."
22:01:21 code_symbol_embed started (all 8 646 files, because force_full)
22:39:09 code_symbol_embed completed (38 min)
23:59:50 heartbeat stops - the 7 200 s ceiling, at 380 of 758 documents
00:04 stale run reaped
A full rebuild costs 12 039 s and the nightly budget is 7 200 s, so the store was
empty again the next night, and the night after. index_repo has completed 16 times in
94 runs; daily_sync 13 in 91. The C3 self-repair was not wrong - the repair cost more
than the budget allowed, which no ceiling could fix.
PgVectorStore is a drop-in: same six methods, same shapes, same cosine distance, and
the collection handle still answers count(), because that is what
pipeline_runner.py:415 reads to decide whether a full re-index is owed.
Embeddings do not change. ChromaDB computed them internally with its bundled ONNX
all-MiniLM-L6-v2; this calls the same class directly. Identical vectors, identical
384 dimensions, and vector_cosine_ops because the collections it replaces were built
with {"hnsw:space": "cosine"}. Verified end to end against the production database:
same four documents, same ranking, distances 0.2234 / 0.7867 / 1.0161.
HNSW over IVFFlat: no training pass, so it is correct from an empty table - IVFFlat
built on an empty one returns nothing until rebuilt, which is the exact failure this
removes. Measured on the schema with 30 000 rows of 384 dimensions: build 20 s, and a
top-5 filtered by project_id goes 4 738 ms -> 0.92 ms. hnsw.iterative_scan was
measured too and changes nothing at this selectivity; the 65 ms it appeared to save
was a cold cache.
VECTOR_STORE_BACKEND still defaults to chroma, so the flip is a decision on a
verified deployment rather than a side effect of this merge. psycopg sits beside
asyncpg deliberately: the interface is called synchronously from ten sites including
the agent's hot path, and converting those is larger and riskier than a second driver.
pgvector is available on BOTH deployments - 0.8.1 on Heroku Postgres, 0.8.2 on
Supabase - so this does not have to wait for a database move.
Also: three checks over the migration graph. `b1c2d3e4f5a6` was already taken, and
alembic reports that as CycleDetected naming four unrelated revisions, only when
something walks the graph - so a duplicate id can be committed and merged first. Read
with ast, because a first attempt used a regex and reported seven heads where alembic
reports one (merge migrations name parents as a tuple); a fourth test fails if that
understanding ever regresses.
Suppression ceilings raised by one each, with the reason recorded in the same commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
CHROMA_PERSIST_DIRis/app/data/chroma— the Heroku container filesystem. It does not survive a dyno restart, andwebandworkerare separate process types with separate copies of it. Production, 2026-08-27, five hours after a deploy:A full rebuild costs 12 039 s and the nightly budget is 7 200 s, so the store was empty again the next night, and the night after:
index_repodaily_syncThe C3 self-repair (v1.13.0) was not wrong. The repair simply cost more than the budget allowed — which is not something a bigger ceiling can fix, and #237/#240 raising that ceiling did not fix it.
The change
PgVectorStoreis a drop-in: same six methods, same shapes, same cosine distance. The collection handle still answerscount(), because that is exactly whatpipeline_runner.py:415reads to decide whether a full re-index is owed — the decision at the centre of the loop.Embeddings do not change. ChromaDB computed them internally with its bundled ONNX
all-MiniLM-L6-v2; this calls the same class directly. Identical vectors, identical 384 dimensions, andvector_cosine_opsbecause the collections it replaces were created with{"hnsw:space": "cosine"}. Verified end to end against the production database: same four documents, same ranking, distances 0.2234 / 0.7867 / 1.0161 — re-verified unchanged after a later edit toquery().HNSW over IVFFlat, and not by preference: HNSW needs no training pass, so it is correct from an empty table. IVFFlat built on an empty table returns nothing until rebuilt — the exact failure this removes. Measured on this schema, 30 000 rows × 384 dims:
hnsw.iterative_scanwas measured too and makes no difference at this selectivity; the 65 ms it first appeared to save was a cold cache, controlled for with three warm runs each way.Decisions worth arguing with
VECTOR_STORE_BACKENDstill defaults tochroma. The flip is a decision taken on a verified deployment, not a side effect of merging.context_loader.py:213,421,knowledge_catalog_service.py:480,539). Converting those to async is a larger, riskier change than a second driver.pgvectoron a SQLite URL fails at start-up saying so, rather than later on a missing table.Also: three checks over the migration graph
b1c2d3e4f5a6was already taken byb1c2d3e4f5a6_batch_started_at_claim.py. Alembic reports that asCycleDetectednaming four unrelated revisions, and only when something walks the graph — so a migration with a duplicate id can be committed and merged before anything notices. Ids here are hand-picked rather than generated, which puts it within reach.Read with
ast, not a regex: the first version reported seven heads where alembic reports one, because merge migrations name their parents as a tuple. A fourth test fails if that understanding ever regresses — a test that misreads the graph is worse than none, because it fails on a healthy repo and gets deleted.Negative control run: with a duplicate planted the check fails; removed, all four pass.
Verification
pytest tests/— 6987 passed, 4 skipped, 1 xfailed.ruff format --check,ruff check,mypy app/— clean, 0 errors.alembic_versionuntouched, table dropped, prod unchanged.Post-merge, per
CB-KNOW2Deploy → set
VECTOR_STORE_BACKEND=pgvector→ one full re-index reachingpipeline_end→ then watch a deploy happen and the next nightly run stay incremental. The row is struck on that evidence, not on this merge.