Repository navigation
Store the BM25 index in the database, beside the chunks it indexes - #33
Merged
Badri-Rajendran merged 1 commit intoSep 23, 2026
Merged
Conversation
Badri-Rajendran
force-pushed
the
feature_bm25_in_postgres
branch
from
September 22, 2026 19:44
67930c2 to
9dddc85
Compare
Badri-Rajendran
force-pushed
the
feature_robots_unavailable
branch
from
September 22, 2026 19:44
a32cffc to
6714cae
Compare
Badri-Rajendran
force-pushed
the
feature_bm25_in_postgres
branch
from
September 22, 2026 20:23
9dddc85 to
cdd59d4
Compare
This was referenced Sep 22, 2026
`_bm25_index()` read `data/corpus/indices/bm25.pkl` from local disk and raised FileNotFoundError when it was absent. Nothing catches that, so the chat route's safety net turns it into a 500: an app running anywhere but the machine that built the corpus answers no questions at all — not degraded, not sparse-only, every question fails. That never showed because the app had only ever run where the file was. It cannot be fixed where it stands: `data/` is gitignored and stays that way, so no image can be built containing it. The index is derived data — every byte of it comes from the `chunks` table, which already holds each chunk's text and id. So it moves into the database next to them, as one row of `search_indexes`, and `make build-index` rebuilds it from the chunks already stored with no fetching, chunking or embedding. Seeding a fresh database is a restore plus one command. A missing index now names that command instead of naming a path that will not exist wherever the error was raised. The payload also stops carrying a second copy of the corpus: `texts` was read by nothing but a test, and dropping it took the stored index from 3.4 MB to 1.8 MB for the same 1,568 chunks. The orchestrator's tests now check what reaches the indexer at the call rather than by reading back a pickle, which is what they were really asserting; storing it has its own tests.
Badri-Rajendran
force-pushed
the
feature_bm25_in_postgres
branch
from
September 22, 2026 20:43
cdd59d4 to
01ee2a3
Compare
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.
Problem
_bm25_index()readdata/corpus/indices/bm25.pklfrom local disk and raisedFileNotFoundErrorwhen it was absent. Nothing catches that exception —grep -rn "FileNotFoundError" src/finds only the two raise sites — so the chat route's catch-all turns it into a 500.An app running anywhere but the machine that built the corpus therefore answers no questions at all. Not degraded, not dense-only: every question fails.
That never showed up because the app had only ever run where the file was. It is also not fixable where it stands:
data/is gitignored and stays that way, so no image can be built containing it;make ingest, a long local job;Object storage would mean a storage account, a credential, an SDK and a new startup failure mode. Building the image locally is not CD.
Implementation
The index is derived data — every byte comes from the
chunkstable, which already holds each chunk's text and id. So it moves next to them.search_indexes:name(PK),payload,chunks,built_at. One row; a second index would be a second row, not a second table. New migration.make build-indexrebuilds it from the chunks already stored — no fetching, chunking or embedding. Seeding a fresh database becomes a restore plus one command, and the index can't silently disagree with a corpus loaded some other way.make ingeststill builds it as its last step.MissingSearchIndexErrornaming the command to run, instead of aFileNotFoundErrornaming a path that won't exist wherever the error was raised.bm25_index_pathandINDEX_DIRare gone. There is no path to configure.textskey — a second copy of the whole corpus, read by nothing outside one test. 3.4 MB → 1.8 MB for the same 1,568 chunks.make test/make lint/make check, so "it passed locally" means what CI means.On unpickling: the payload is a pickle because
BM25Okapiis a Python object. It is written only by this project's ingestion, into a table only this app writes to, and it was already being unpickled from a file. Moving the bytes from a file the app owns to a row the app owns adds no source of untrusted input. ADR 0021 records what would change that assessment and what the answer would be.Validation
uv run pytest -q --cov=src --cov-fail-under=85— 413 passing, 92.27% (up from 406 / 91.8%).tests/test_search_index.py(7 tests): the row and its chunk count, the payload carrying ids but not texts, a rebuild replacing the one row, retrieval reading it, the error namingmake build-index, andbuild_indexrebuilding from stored chunks. Two of these caught real problems while being written — a pre-existing committed row leaking into assertions, and a 2-document BM25 corpus whereidfis exactly 0 so nothing scores above the filter.make build-index→bm25 | 1568 chunks | 1852 kB.ruff check,alembic check(no new operations after the migration),bandit -r src -llclean.Security / DB / API impact
search_indexes. Additive; the downgrade drops it.# nosec B301justification is updated to match.The decisive test is a container with no
data/directory answering a question correctly — that comes with the Dockerfile PR, which this unblocks.Note
Stacked on #32 → #31 → #30 → #29 → #28.