Skip to content

fix(store): hydrate document bodies after dedupe, not per chunk - #932

Open
mendelcyprys wants to merge 1 commit into
tobi:mainfrom
mendelcyprys:fix/vec-search-body-fanout
Open

mendelcyprys wants to merge 1 commit into
tobi:mainfrom
mendelcyprys:fix/vec-search-body-fanout

Conversation

@mendelcyprys

@mendelcyprys mendelcyprys commented Aug 29, 2026

Copy link
Copy Markdown

Problem

searchVec's step-2 document lookup selects content.doc as body while joining
content_vectors (one row per chunk) to content (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 limit are 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, µ, typographic
dashes — routine in scientific prose), so the real cost is roughly double again.

Reproduction

qmd vsearch "<any query>" --all
# FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 6: v8::internal::FactoryBase<v8::internal::Factory>::NewRawTwoByteString
 7: v8::internal::Factory::NewStringFromUtf8
 8: v8::String::NewFromUtf8
 9: napi_create_string_utf8
10: Data::GetValueJS            (better-sqlite3)
11: RowBuilder::GetRowJS        (better-sqlite3)
12: Statement::JS_all           (better-sqlite3)

qmd search and qmd vsearch -n 5 survive, because documents_fts is per-document
and the outer LIMIT caps returned rows — but even vsearch -n 5 hydrates 15 full
document 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_all heap-limit trace on a 32 GB machine and was
closed NOT_PLANNED in the post-v2.5.1 backlog sweep, with a note to reopen if it still
reproduced. 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 .txt files (52,922 vectors), on a 16 GB machine.
Same queries, same models, main vs this branch. "Peak" is /usr/bin/time -l's peak
memory footprint; both sides load the same three models, so the delta is attributable to
this change.

command main this branch
vsearch "sensory receptors" --all -c neuro dies after 97 s, 46.4 GB peak, 0 results 4.5 s, 1.6 GB peak, 19 results
vsearch "…sensory neurons in the fingertips" --all dies after 132 s, 46.4 GB peak 15.5 s, 1.6 GB peak
query "choroid plexus" -c neuro completes, 17.2 GB peak completes, 3.4 GB peak
query "where the fluid in the ventricles is made" -c neuro 24.9 s, 15.2 GB peak (11.1 GB RSS) 18.0 s, 3.4 GB peak (5.5 GB RSS)

Why some queries are far worse than others

sqlite-vec caps k at 4096, so the fan-out tops out at 4096 chunks — but the cost is set
by 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:

query body text materialised
sensory receptors 64.5 GB
sensory transduction 63.8 GB
types of sensory neurons in the fingertips 61.7 GB
psychophysics of sensory perception 51.0 GB
where the fluid in the ventricles is made 44.2 GB

sensory receptors puts 2,694 of its 4,096 chunks (66%) inside a single 18.9 MB
reference 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 query runs do not crash, but on a 16 GB machine they peak at 15-17 GB — above
physical 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 body from the per-chunk SELECT and hydrate it from content once results are
deduped and truncated to limit.

  • The JOIN content is retained, so row filtering semantics are unchanged — only
    the string materialisation moves.
  • Body lookups are chunked at VEC_HASH_SEQ_IN_CHUNK, matching the existing convention
    in exactVecScanByHashSeq.

Memory now scales with limit rather than with the number of matching chunks.

Tests

test/store.test.ts gains a Vector Search body hydration block. Rather than asserting
on 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.

case before after
limit 5, one 300-chunk document 15 copies 1
limit 100000 (--all), same document 300 copies 1
two 4-chunk documents, limit 10 8 copies 2

The third case also asserts each result carries its own document's body, guarding the
new hash→body map against mispairing. All three fail on main and 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 test and vitest. Typecheck and
oxlint clean.

Not addressed here

searchVec dedupes by filepath, so each document yields at most one result regardless
of -n. That is right for notes and surprising for books, where the best five passages
may 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.

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>
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.

1 participant