fix(store): hydrate document bodies after dedupe, not per chunk - #932
Open
mendelcyprys wants to merge 1 commit into
Open
mendelcyprys wants to merge 1 commit into
mendelcyprys wants to merge 1 commit into
Conversation
searchVec's document-lookup query selected content.doc alongside a per-chunk join on content_vectors, so every matching chunk carried a full copy of its document body. A document with N matching chunks materialised N copies of the whole file before dedupe or limit applied. For note-sized documents this is unnoticeable. For book-sized ones it is fatal: vsearch --all maps to limit 100000, which fans out over every chunk in the collection, and query runs several expanded queries each over-fetching candidates. Both exhaust the V8 heap before returning, on indexes as small as a single 8MB text file. Drop the body column from the fan-out SELECT and hydrate it from content once results are deduped and truncated to limit. The content JOIN is retained so row filtering is unchanged, and lookups are chunked at VEC_HASH_SEQ_IN_CHUNK to stay inside SQLite's variable budget. Output is byte-identical; this is purely a memory fix. Memory now scales with -n rather than with the number of matching chunks. Regression tests count how many times a document body crosses the SQLite -> JS boundary during a search, rather than asserting on SQL text. Without the fix they observe 15 copies at limit 5, 300 at limit 100000, and 8 across two four-chunk documents; with it, one per returned document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
searchVec's step-2 document lookup selectscontent.doc as bodywhile joiningcontent_vectors(one row per chunk) tocontent(one row per document).Every matching chunk therefore carries a full copy of its document's body, and all of
them are materialised into JS before dedupe or
limitare applied.For notes and source files this is invisible. For book-length documents it is fatal:
a 300-chunk match on an 8 MB document builds ~2.4 GB of strings, and V8 stores strings
two bytes per character once the text leaves Latin-1 (Greek letters,
µ, typographicdashes — routine in scientific prose), so the real cost is roughly double again.
Reproduction
qmd searchandqmd vsearch -n 5survive, becausedocuments_ftsis per-documentand the outer
LIMITcaps returned rows — but evenvsearch -n 5hydrates 15 fulldocument copies to return 1.
Previously reported
This is the crash in #430 ("qmd query crashes with out-of-memory on large libraries"),
which reported the same
Statement::JS_allheap-limit trace on a 32 GB machine and wasclosed
NOT_PLANNEDin the post-v2.5.1 backlog sweep, with a note to reopen if it stillreproduced. It still reproduces on 2.8.3 — repro steps and measurements below.
That issue proposed pagination as the fix. Paging the fan-out would cap the rows in
flight, but the cost per row is a whole document body; removing the body from the
per-chunk query addresses the cause rather than the batch size.
Measured on a real index
A 440 MB index over 19 book-length
.txtfiles (52,922 vectors), on a 16 GB machine.Same queries, same models,
mainvs this branch. "Peak" is/usr/bin/time -l's peakmemory footprint; both sides load the same three models, so the delta is attributable to
this change.
mainvsearch "sensory receptors" --all -c neurovsearch "…sensory neurons in the fingertips" --allquery "choroid plexus" -c neuroquery "where the fluid in the ventricles is made" -c neuroWhy some queries are far worse than others
sqlite-vec caps
kat 4096, so the fan-out tops out at 4096 chunks — but the cost is setby which documents those chunks belong to, since each chunk carries its own full copy.
Replicating step 1 for a range of queries and totalling the body bytes the current code
would materialise:
sensory receptorssensory transductiontypes of sensory neurons in the fingertipspsychophysics of sensory perceptionwhere the fluid in the ventricles is madesensory receptorsputs 2,694 of its 4,096 chunks (66%) inside a single 18.9 MBreference work, so that one file is copied 2,694 times. The ceiling on this corpus is
4096 x 18.9 MB= 77 GB.Note the shape of this: the vaguer the query, the worse it is. Broad queries match
across a large reference work and concentrate the fan-out in the biggest file; more
specific queries spread across smaller books and cost less. Cheap-looking queries are the
dangerous ones.
The
queryruns do not crash, but on a 16 GB machine they peak at 15-17 GB — abovephysical RAM, with system time rising to 8.0 s as the allocator thrashes. Same fan-out,
spread across several expanded queries instead of concentrated in one.
Output is unchanged: every command that completes on both sides produces
byte-identical stdout.
Fix
Drop
bodyfrom the per-chunk SELECT and hydrate it fromcontentonce results arededuped and truncated to
limit.JOIN contentis retained, so row filtering semantics are unchanged — onlythe string materialisation moves.
VEC_HASH_SEQ_IN_CHUNK, matching the existing conventionin
exactVecScanByHashSeq.Memory now scales with
limitrather than with the number of matching chunks.Tests
test/store.test.tsgains aVector Search body hydrationblock. Rather than assertingon SQL text — which silently stops matching if the query is rewritten — the tests count
how many times a known document body crosses the SQLite → JS boundary during a search.
limit 5, one 300-chunk documentlimit 100000(--all), same documentlimit 10The third case also asserts each result carries its own document's body, guarding the
new hash→body map against mispairing. All three fail on
mainand pass with this change.Counts are asserted with exact equality, so a regression to fan-out (>N) and a broken
test harness (0) both fail.
Full suite: 1146 passed / 0 failed under both
bun testandvitest. Typecheck andoxlint clean.
Not addressed here
searchVecdedupes byfilepath, so each document yields at most one result regardlessof
-n. That is right for notes and surprising for books, where the best five passagesmay all live in one file. It is a behaviour change rather than a memory fix, so it is
left for a separate PR — and it is considerably easier to implement once bodies are no
longer attached to every chunk.