fix(rag): cache ingested chunks to disk so resumed runs don't search an empty index - #79
Open
rajarshidattapy wants to merge 1 commit into
Open
Conversation
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.
Fixes #66 — the RAG provider held its whole index in a process-local
Map, so any run resumedin a new process searched nothing, recorded that as a successful search, and published a
fabricated ~0% accuracy.
Why it was silent
Three things had to line up, and they did:
ingest: completed/indexing: completedand persists that, so aresume skips both phases (
ingest.ts:23-26,indexing.ts:99-101).HybridSearchEngine.searchhitsif (!container || container.chunks.size === 0) return [].completedand therun continues to answer and judge with no context at all.
awaitIndexingreports success unconditionally, so there was no point at which the missing datacould have been noticed.
Approach
Took the issue's "persist" option, since re-ingesting means re-paying for an LLM extraction call
per session — the expensive part of this provider — and the issue notes the embeddings are worth
caching regardless.
filesystemalready solves the same problem (data/providers/filesystem/<container>/memories/ *.md, read back on search), so this follows that layout rather than introducing a new one: oneappendable JSONL file per container at
data/providers/rag/<container>.jsonl.rewriting) means a partially-ingested container keeps whatever completed, which matches how
ingest already checkpoints
completedSessionsper session.loadFromCachefirst, which is a no-op when the index is already warm.Two decisions worth calling out:
Embeddings are cached, not recomputed. Re-embedding on load would have been less code and
smaller files, but
loadFromCacheruns insidesearch, and the search phase times that call asthe provider's search latency (
search.ts:51-64). An embedding round-trip there would land inthe latency number that feeds MemScore, so the restore path is kept to pure disk I/O. Embeddings
are stored as base64 float32 rather than JSON numbers — about a quarter of the bytes, and float32
is the precision the cosine similarity actually uses.
A missing cache raises instead of returning empty. This keeps the fail-loudly half of the
issue for the case the cache was deleted while the checkpoint still claims ingest completed.
Distinguishing that from a container that genuinely produced no memories is why
ingestcreatesthe file up front, before it knows whether these sessions yield any chunks: the file's
existence means "ingest ran here". Previously the two cases were indistinguishable and both
scored 0%.
Tests
src/providers/rag/persistence.test.ts, no network needed:the same order, as the engine that ingested them — simulating the process boundary. Previously
the second engine returned nothing.
before it, rather than discarding the container.
the BM25 index counts every
add, so replaying them would skew IDF and length normalisationfor the whole container.
[].bun test6/6,tsc --noEmitclean.Notes for the reviewer
src/providers/rag/index.tswas already failingprettier --checkat HEAD, on two long linesI don't touch (
chunkText's signature and thelogger.infoininitialize). I left themalone rather than bury this behind a whole-file reformat; every line I added is
prettier-clean. Happy to include the reformat as a separate commit if you'd prefer.
500-question LongMemEval run. That's the same order as what the
filesystemprovider alreadywrites, and
data/is gitignored scratch, but it is a change in footprint. Trading it backfor a re-embed on load is a one-line change to
loadFromCacheif that's the wrong call.clear()cleans up per container, but nothing calls it during a normal run, so deleting a rundirectory still leaves its RAG cache behind. Pre-existing, and I left it out of scope.
chunkTextcan loop forever whenthe break point lands within the overlap window. Filed separately as Reported Recall@K, F1@K and NDCG are mathematically degenerate — they measure nothing beyond Hit@K #67 if you want it in
this area's next pass.