ADR-0002 — Index persistence: SQLite truth, BM25 rebuild-at-open¶
- Status: Accepted (owner, 2026-08-10)
- Date: 2026-08-10
- Deciders: Andy Freeman (owner)
Context¶
ADR-0001 confirmed ARP's verified gap #1 — no persistence across processes —
and decided the BM25 library question (pure-Python, ported near-verbatim
over rank-bm25). It explicitly deferred the persistence design around
that index to this ADR. SPEC.md §5.3 requires a persistence layout of one
index directory per collection, SQLite for documents/chunks/offsets/ingest
state, content-hash dedup on write, and — per SPEC.md §2 — no in-process
state may shadow persisted state (ADR-0001 hazard 7, the memory.py
_key_map defect this repo exists partly to avoid repeating).
Scope: this ADR covers persistence design only — the SQLite schema, the
BM25-rebuild strategy, the incremental-re-ingest trigger, and the
single-connection concurrency model for SQLiteMetadataStore. It does not
revisit the BM25 library choice (ADR-0001) or the dense store (Phase 3).
Decision¶
-
SQLite is the durable truth for documents and chunks. A
SQLiteMetadataStoreper collection (<index_dir>/<collection>.sqlite3) holdsdocuments(document_id, source, content_hash, ingested_at)andchunks(chunk_id, document_id, chunk_index, content, start_offset, end_offset, content_hash, metadata), withchunks.document_ida foreign keyON DELETE CASCADEand an index onchunks(document_id).metadatais stored as a JSON text column.PRAGMA foreign_keys=ONandPRAGMA journal_mode=WALare set on every connection open. -
BM25 is rebuilt in memory at open, never pickled.
BM25Indexholds no on-disk representation of its own.BM25Index.from_store(store)constructs a fresh index and callsindex_chunks()once with every chunk read fromSQLiteMetadataStore.get_chunks(). The postings, document frequencies, and length statistics are pure functions of the persisted chunk set, so there is nothing to serialize independently — and nothing that can drift out of sync with SQLite the waymemory.py's in-process_key_mapdrifted from its backing store.
ERRATUM: "the postings, document frequencies, and length statistics"
named three structures, and until GK-018 (2026-08-19) only two of them
existed. BM25Index held _doc_freqs (the document-frequency counter
IDF reads) and _doc_lengths/_avg_doc_length (the length statistics),
and no term → [chunk index] map at all — the one structure the word
postings actually names, and the one an inverted index exists for. The
class docstring claimed the inverted index too. Nothing was wrong with the
sentence's claim — whatever postings exist are a pure function of the
persisted chunk set, which is why building them now needs no migration and
no format — it was wrong about the tree, in the same way this repo's other
erratum (ADR-0022 decision 5, a method named that never existed) was: an
accepted ADR describing code that is not there. The consequence was not
cosmetic. search scored every indexed chunk for every query, so
query cost was a function of corpus size rather than of query
selectivity, and a one-term query against a large collection did the same
work as a query matching everything.
index_chunks now appends each chunk's index to the postings list of
every term it holds, from the same first-sighting branch that increments
_doc_freqs, and search walks the union of the query terms' postings
instead of range(len(self._chunks)). Three properties make this an
erratum against decision 2 rather than a new decision needing its own ADR:
- It is score-identical, not an approximation. A chunk holding no
query term skips every term inside
_score_document, scores exactly0.0, and was already dropped by the existingscore > 0.0filter. Narrowing the walk removes work, never a result — including the low-scoring-but-nonzero tail, which is precisely what an approximate candidate-generation scheme (a score cutoff, a top-N-per-term cap) would have dropped. That is why none was taken. - The candidate union is walked in ascending chunk index, so the
stable sort behind the
content_hashtie-break still falls back to insertion order for two byte-identical chunks — the one tie no content-derived key can resolve (KNOWN_LIMITATIONS.md). Iterating the union as a set would have handed that fallback to set layout, which is the one way this change could have altered output while every score-equality assertion still passed. -
Nothing is persisted and nothing shadows SQLite. The postings map is built in memory by
index_chunks, from the chunksfrom_storeread out of SQLite, and dies with the instance. Decision 2's invariant is untouched, and so is the alternative below: the O(corpus) rebuild-at-open cost is unchanged, and "persisted BM25 postings tables in SQLite" is still deferred with its measurement trigger unmet. This erratum changes what a query costs, not what an open costs; reading it as having taken that alternative would be exactly backwards. -
Incremental re-ingest is content-hash gated.
upsert_documentrecords acontent_hashpersource; the ingestion pipeline (outside this module's scope) callsget_document_hash(source)first and skips re-chunking/re-embedding when the hash is unchanged. (Superseded in part by ADR-0009: the gate and the column are unchanged, but the value stored in them is a fingerprint over the content plus the chunker and chunking configuration. Content alone let a settings change hash-match and leave stale chunks in place.) When a source is re-ingested,upsert_documentexplicitly deletes the prior document row and its chunks (by the previously-registereddocument_id, which may differ from the new one) before inserting the replacement — explicit deletion rather than relying solely on the cascade, so the deleted state is never implicit in aINSERT OR REPLACEconflict-resolution side effect. -
Concurrency: one connection, one asyncio.Lock.
SQLiteMetadataStoreopens a singlesqlite3.Connectionwithcheck_same_thread=Falseand serializes every operation through oneasyncio.Lock, held for the duration of the coroutine including the awaitedasyncio.to_threadcall. sqlite3 connections are not safe to share across OS threads without this guard, andasyncio.to_threaddispatches each call to a fresh worker thread from the default executor. The lock, notcheck_same_thread=Falsealone, is what makes concurrentawaits from multiple coroutines safe.
Alternatives considered¶
- Persisted BM25 postings tables in SQLite (term → document → frequency
rows, avoiding the O(corpus) rebuild). Deferred, not rejected outright: it
adds real complexity (keeping postings consistent with
chunksunder concurrent writes, migration surface) for a cost that is currently hypothetical. Revisit if rebuild-at-open time is measured to be a problem for a real corpus size — that measurement is the trigger, not a guess made now. - Pickled BM25 index files. Rejected:
pickle.loadon an index file is unsafe deserialization of untrusted-by-default input (the file could have been swapped or corrupted between writes), the format is opaque to anything but this exact Python/BM25Index version, and a version bump toBM25Index's internal shape silently breaks every existing pickle with no schema check. SPEC.md's fail-closed principle argues against a persistence format with no validation step. - LanceDB for everything, including chunk/document metadata. Rejected
for this layer: document/chunk truth wants relational queries (
upsertby uniquesource,ON DELETE CASCADEto keep chunks and documents in lockstep, indexed lookups bydocument_id) that a vector table is not designed to express or enforce. SPEC.md §3 already commits to LanceDB + SQLite as separate concerns behind separate interfaces; this ADR does not reopen that split, only the SQLite side of it.
Consequences¶
Positive: the metadata store is durable and independently verifiable —
closing and reopening a SQLiteMetadataStore against the same file recovers
every document and chunk, closing exactly the gap ARP's gap-audit claim #1
confirmed. BM25 can never hold state that SQLite doesn't also have, by
construction — there is no BM25-only write path. Incremental re-ingest is a
single indexed lookup (source is UNIQUE), not a corpus scan.
Negative: BM25Index.from_store is an O(corpus) rebuild on every process
start — for a large collection this is a real, measurable startup cost that
this ADR accepts for v1 rather than building the postings-persistence
alternative above pre-emptively. The single-connection-plus-lock concurrency
model serializes all metadata-store access; under WAL, SQLite itself would
allow concurrent readers, but groundkit does not exploit that concurrency in
v1 — acceptable for a single-process local service, revisited only if
metadata-store contention is measured to matter.
Staleness window: rebuild-at-open means a Retriever reflects the
store's state only as of its open() call and never refreshes itself. A
write to the same collection after open() — a re-ingest, an edit, a
deletion — is invisible to that Retriever until it is reopened. Two
distinct behaviors follow from this, and only one of them is the fail-closed
outcome SPEC.md §2 requires: a hit against a document that was modified or
deleted resolves against get_document_sources(), finds no stored source,
and raises RetrievalError — correct, fails closed. A query that should
match newly-ingested content has no representation at all in the stale
in-memory BM25 index, so it silently returns zero results instead of
erroring. Nothing in this design distinguishes "no matches" from "stale
index" for that second case. Callers that ingest and then search against the
same collection in one process must reopen the Retriever after every write
to observe it; this is documented on the Retriever class itself
(src/groundkit/retrieval/search.py).