Repository navigation
v0.1.5: large vaults: incremental keyword index, deferred compaction under watch, remembered model-load failures - #5
Merged
Conversation
…under watch, remembered model-load failures Measured on Linux (fox-n1), synthetic vault of 50,000 chunks (20,000), v0.1.4 vs this commit: - next search after a save (serve reload): 7.5 s -> 0.8 s (2.9 s -> 0.3 s) - end of each `puremind watch` run: 0.5 s -> 0.002 s (0.22 s -> 0.002 s); the 0.5 s compaction now runs once per quiet spell - keyword query: under 2 ms -> 14-27 ms (the price of query-time scoring) Reload: bm25s.BM25().index (6.5 s at 50k) is replaced by retrieval.Lexicon, BM25 scored at query time exactly as bm25s scores it (Lucene variant, k1 1.5, b 0.75, repeated query tokens count again), from per-chunk term ids and counts. LexiconBuilder keeps those for the chunks of the last snapshot, keyed by a blake2b digest of the text (chunk ids are reused after a delete), so a reload tokenizes only new chunks. It prunes text that left the index, gives each Lexicon its own vocabulary dict, and drops unused words once they are half the vocabulary. bm25s.tokenize is still used, so tokens are unchanged. Ties are now ordered by position. Store.load_index joins the vector blobs into one buffer. Watch: index_vault(purge=False) records 'purge_pending' in meta and only checkpoints; cmd_watch compacts on its first run, when watch() calls its new quiet hook 60 s after a run with no change since, at the latest 300 s after the first deferral, and on exit. `puremind index` compacts every run and pays a debt left by a crashed watch. purge() and clear() clear the record. Models: _LoadOnce remembers a ModelLoadError for LOAD_RETRY_AFTER (60 s) per instance; calls within it, including queued ones, get a fresh ModelLoadError with the same message and cause, so the indexer's cause check is unchanged. Tests: parity with bm25s scores on random corpora, incremental vs fresh builds, tokenization of new text only, pruning, vocabulary compaction with an older lexicon still answering, failed builds, tie order, a reload after one save tokenizing one chunk, purge_pending lifecycle, deferred compaction in index_vault and cmd_watch (first run, quiet, exit, cap), the quiet hook, and the model-load cool-down including overlapping callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zd8G1cibJoQPVbZEpVLYs
…t startup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zd8G1cibJoQPVbZEpVLYs
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 three known performance issues at 20k+ chunks. Measurement showed the biggest cost is different from the one listed first: it is the server's in-memory bm25s rebuild after every write (7.5 s at 50k chunks), not the FTS5
optimize(0.5 s).Measured
Linux (fox-n1, unloaded), synthetic vault of 5-chunk notes, ~170 Zipf-distributed words per chunk, 1024-dim vectors. v0.1.4 vs this branch, same machine and index:
servereload)watchrunThe keyword-query cost is the trade: scoring happens at query time over all postings (
np.isin(kind="table")), instead of in a precomputed score matrix. +25 ms is small next to embedding the query and reranking. If it matters later, a term-sorted layout costs about 0.25 s more per reload and makes queries O(postings).(tensor-core numbers were unusable: under host memory pressure, with swap 35 of 39 GB, the reload stalled in kernel page reclaim for minutes in any process that allocates a few hundred MB.)
1. Reload: incremental keyword index (
retrieval.py,service.py)Lexiconreplacesbm25s.BM25. It is BM25 scored at query time, exactly as bm25s scores it: Lucene idf/tfc, k1 1.5, b 0.75, document length = tokens after stopwords, repeated query tokens counted again, unknown tokens dropped. It is built from per-chunk (term id, count) arrays with a few vectorised numpy operations.LexiconBuilder(one perService, used under its lock) keeps those arrays for the last snapshot's chunks, keyed by a blake2b digest of the text. Chunk rowids are reused after a delete, so the key can't be the chunk id. A reload tokenizes only chunks it hasn't seen. It prunes text that left the index, copies the vocab dict when it grows (so a snapshot still being searched keeps its own), and renumbers to drop unused words once they make up half the vocabulary. A failed build leaves its state unchanged.bm25s.tokenizeis unchanged, so tokens are identical. Scores match bm25s within float32 rounding; ties are now ordered by position (bm25s'sargpartitionorder is arbitrary).Store.load_indexjoins the vector blobs into one buffer (and refuses mixed lengths with a--rebuildhint).2. Watch: deferred FTS5 compaction (
indexer.py,store.py,watcher.py,cli.py)index_vault(purge=False)recordspurge_pendinginmetaand only checkpoints the WAL. The text of deleted or edited chunks still leaves the tables, and is zeroed, at the end of the run; only their words stay in the FTS5 segments untiloptimize.cmd_watchcompacts:watch()calls its newquiethook, 60 s after a run with no change since;puremind indexstill compacts every run, and also pays a pending debt.purge()andclear()clear the record.3. Failed model loads (
models.py)_LoadOncewraps both models' loaders. It remembers aModelLoadErrorforLOAD_RETRY_AFTER(60 s) per instance. Callers within that window, including threads queued on the lock, get a freshModelLoadErrorwith the same message and cause, without touchingresolve_modelor the disk. The cause chain is kept, so the indexer'sisinstance(exc.__cause__, ModelLoadError)check is unchanged. A success clears the memory.Validation
pytest -q: 621 passed, 6 skipped.ruff check src tests bench(0.17.1): clean.Servicereload after one save tokenizes exactly one chunk;purge_pendinglifecycle;index_vault(the canary text is gone after the paying run);cmd_watchcompaction on the first run, when quiet, on exit, and at the cap;quiethook firing once per quiet spell;Known, not fixed here
tests/test_watcher.py::test_a_burst_of_writes_gives_one_runflaked once (3 runs instead of 2) in thebuild and installed wheeljob of run 39066192192, and passed in every other job. That test callswatch()without the new hook, so it runs code this PR leaves unchanged. With a 0.2 s debounce, a loaded runner can split the 16-write burst into two runs. Pre-existing timing sensitivity.🤖 Generated with Claude Code
https://claude.ai/code/session_011zd8G1cibJoQPVbZEpVLYs