Skip to content

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
supermemoryai:mainfrom
rajarshidattapy:fix/rag-persist-across-resume
Open

fix(rag): cache ingested chunks to disk so resumed runs don't search an empty index#79
rajarshidattapy wants to merge 1 commit into
supermemoryai:mainfrom
rajarshidattapy:fix/rag-persist-across-resume

Conversation

@rajarshidattapy

Copy link
Copy Markdown

Fixes #66 — the RAG provider held its whole index in a process-local Map, so any run resumed
in 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:

  1. The checkpoint records ingest: completed / indexing: completed and persists that, so a
    resume skips both phases (ingest.ts:23-26, indexing.ts:99-101).
  2. Nothing else repopulates the index, so HybridSearchEngine.search hits
    if (!container || container.chunks.size === 0) return [].
  3. An empty array is a legal search result, so the phase marks the question completed and the
    run continues to answer and judge with no context at all.

awaitIndexing reports success unconditionally, so there was no point at which the missing data
could 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.

filesystem already 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: one
appendable JSONL file per container at data/providers/rag/<container>.jsonl.

  • ingest appends each batch of embedded chunks after adding them in memory. Appending (not
    rewriting) means a partially-ingested container keeps whatever completed, which matches how
    ingest already checkpoints completedSessions per session.
  • search calls loadFromCache first, which is a no-op when the index is already warm.
  • clear now removes the cache file too, so it actually clears.

Two decisions worth calling out:

Embeddings are cached, not recomputed. Re-embedding on load would have been less code and
smaller files, but loadFromCache runs inside search, and the search phase times that call as
the provider's search latency (search.ts:51-64). An embedding round-trip there would land in
the 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 ingest creates
the 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:

  • Chunks survive a serialize/parse round trip with content, metadata and embeddings intact.
  • float32 storage stays within 1e-6 of the original values, so ranking is unaffected.
  • The actual regression: an engine that only ever sees the cache returns the same results, in
    the same order, as the engine that ingested them — simulating the process boundary. Previously
    the second engine returned nothing.
  • A cache truncated mid-line (a process killed during append) still yields every complete chunk
    before it, rather than discarding the container.
  • Duplicate appends from a re-ingest are collapsed by chunk ID. Chunk IDs are deterministic, and
    the BM25 index counts every add, so replaying them would skew IDF and length normalisation
    for the whole container.
  • Searching a container with no cache rejects with an actionable message instead of returning
    [].

bun test 6/6, tsc --noEmit clean.

Notes for the reviewer

  • src/providers/rag/index.ts was already failing prettier --check at HEAD, on two long lines
    I don't touch (chunkText's signature and the logger.info in initialize). I left them
    alone 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.
  • Disk cost is real: roughly 8KB per chunk, so a few MB per question and low GBs for a full
    500-question LongMemEval run. That's the same order as what the filesystem provider already
    writes, and data/ is gitignored scratch, but it is a change in footprint. Trading it back
    for a re-embed on load is a one-line change to loadFromCache if that's the wrong call.
  • clear() cleans up per container, but nothing calls it during a normal run, so deleting a run
    directory still leaves its RAG cache behind. Pre-existing, and I left it out of scope.
  • Unrelated bug I did not touch, visible in the diff context: chunkText can loop forever when
    the 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RAG provider stores everything in process memory, so any resumed run silently searches an empty index and scores ~0%

1 participant