Skip to content

Make search scale: bounded memory, fast default path - #962

Open
rikvanriel wants to merge 7 commits into
tobi:mainfrom
rikvanriel:riel/scalable-qmd-v4
Open

rikvanriel wants to merge 7 commits into
tobi:mainfrom
rikvanriel:riel/scalable-qmd-v4

Conversation

@rikvanriel

Copy link
Copy Markdown

Make search scale: bounded memory, fast default path

Motivation

Searching a multi-thousand-file collection with large transcripts could
use gigabytes of heap (observed 4.2GB) and take over a minute per query
on CPU-only hardware. Three root causes: full document bodies were
loaded into JS for every candidate, every unbounded query used all()
which materialized full arrays in the V8 heap, and every search always
ran query expansion, vector search, embedding-model load, and LLM
reranking even when the keyword ranking was already decisive.
Reindexing had the same shape of problem: with no changes it still read
and hashed every file, costing minutes of IO on large collections.

Results

On 5 sample queries over a ~13k-file collection, the slow queries went
from 54s/71s to 0.5-1.5s (35-60x faster), peak memory on the heaviest
query from 1.44GB to 164MB (~9x less), with 90%+ top-5 overlap against
the old full pipeline (4-5 of the top-5 files unchanged). An unchanged
collection now reindexes in seconds instead of minutes. A 10-query eval
of the optional centroid expansion changes the top-5 in 80% of queries
and pulls genuinely new documents (outside the old top-100) into the
top-5 on half of them.

Patches

  1. feat(index): add mtime+size fast-path via file_sync_state -
    reindex stats each file first and skips the read entirely when mtime
    and size match the cache, so an unchanged collection updates in
    seconds; files over 10MB and empty files are skipped.
  2. fix(search): bound hydration at DB level via substr, not JS -
    caps each fetched body at 256KB in SQL as a first stop-gap against
    the heap blowup (40 winners max ~10MB instead of unbounded).
  3. fix(search,embed,index): stream large queries with iterate() -
    converts every unbounded all() to streaming iterate(); on an
    index with 13k files and 89k chunk vectors this keeps tens of
    thousands of rows out of a single V8 array. Small bounded queries
    keep all().
  4. feat(search): bound hydration and use chunk-level slices -
    fetches only each winner's ranked 3.6KB chunk via
    substr(doc, pos+1, len), so 40 winners stay under ~500KB total;
    chunk-only rerank approach copied from qmd-py's bounded hydration
    code and guard test.
  5. feat(search): score files from top non-overlapping chunks -
    scores a file from its top-3 non-overlapping chunk hits (best vector
    distance to the query embedding first, adjacent overlapping chunks
    skipped so shared 15% overlap is not double-counted), approximating
    whole-file rerank at chunk cost.
  6. feat(cli, mcp, sdk): make fast search the default - trusts the
    keyword ranking alone when the top FTS score clears 0.70, skipping
    expansion, vector search, model load, and rerank; full rerank stays
    available via no-rerank false / rerank true.
  7. feat(search): add centroid expansion - optional Rocchio step that
    averages the top-3 winners' embeddings and searches the centroid for
    thematically related files the keywords missed, reusing stored
    vectors when available; auto-enables only when keyword recall looks
    thin (off by default otherwise).

When a collection has no changes, reindexing still
reads every file to hash it. With thousands of files
this costs minutes of IO and hashing even when nothing
changed.

Add file_sync_state table tracking mtime_ms, size,
and content hash per (collection, path). On reindex,
stat the file first and skip the read entirely when
mtime and size match the cache. If mtime changed but
hash is identical only the cached mtime is updated.

Skip files over 10MB and empty files, and remove the
cache entry on orphan deletion.

Second reindex with no changes performs no file reads,
taking update from minutes to seconds when the
collection is unchanged.
Previously, searchFTS and searchVec loaded the whole
document body as content.doc as body. On collections
with large transcripts this caused 20 candidates times 6
expansions times 100KB = 12MB of string copies through
better-sqlite3 into V8, observed as 4.2GB heap.

Now the bodies are bounded at the SQL level using
substr(content.doc, 1, 262144) as body. This limits each
body to 256KB. With 40 winners the max is 10MB vs
unbounded before. Rerank input stays 900 tokens, about
3.6KB, per chunk. The long term fix fetches only the
winning chunk via substr(doc, pos, len) for the 40 RRF
winners, but this commit prevents the OOM first.
Large result sets previously used all() which
materialized full arrays in V8 heap. On an index with
13k files and 89k chunk vectors this means tens of
thousands of rows (active paths, sync-state entries,
pending-embedding docs, candidate chunk vectors) in a
single array, which caused OOM.

Now they stream via iterate(): sync-state reads,
pending-embedding scans, active-path listings,
glob/fuzzy scans, hash_seq eligibility (with early
exit past the cap, falling back to ANN), and
embedding-body fetches (substr-bound, with a
bytes-only path).

Small queries remain with all() as they are bounded.
Previously, each winning file loaded its whole
document body into JS. On large transcripts this
used gigabytes (observed 4.2GB). Now only the winning
3.6KB chunk at its ranked position is fetched via
substr(doc, pos+1, len) at the SQL level. 40 winners
stay under 500KB total.

FTS winners carry no position, so they fall back to
the doc head slice via pos 0. Callers pass chunkPos
from SearchResult when available.

Chunk-only rerank approach copied from qmd-py's bounded
hydration code and guard test.
Previously a file scored from its single best chunk
hit. Now a file aggregates its top 3 non-overlapping
chunks: sort the file's chunk hits by vector distance
to the query embedding, best first, then greedily keep
a hit unless it overlaps an already-kept chunk
(adjacent sequence numbers with character positions
within one chunk length). Adjacent chunks share
15 percent overlap, so counting both would double
count the same passage.

File score is 1-d0 plus 0.25 times 1-d1 plus 0.1
times 1-d2. This approximates reranking the whole
file at chunk-vector cost. Chunk distance is the
cosine distance from the vector index, capped into a
similarity in 0 to 1.
When the top keyword (FTS) score clears 0.70, the keyword ranking is
trusted on its own: query expansion and the vector phase are skipped
and BM25 plus RRF results are returned directly. This keeps a typical
search fast on CPU-only hardware.

Lower the strong-signal bar from score 0.85 and gap 0.15 to score
0.70 and gap 0.08, and add rerank-skip thresholds at 0.70 and 0.15
so reranking stops early when chunk vectors already agree with the
fused ranking. Skipping the vector phase also skips loading the
embedding model, so the fast path avoids a 13 second model load.

Make no-rerank the default in CLI, MCP, and SDK: CLI no-rerank
defaults to true, MCP rerank defaults to false, SDK skips rerank
unless rerank is true. Full rerank stays available with no-rerank
false or rerank true.

Measured on 5 sample queries over a ~3k-file collection: the slow
queries previously took 54 and 71 seconds, the fast path takes 0.5
to 1.5 seconds, about 35 to 60 times faster. Top-5 overlap is 90
percent or more, 4 to 5 files stay the same. Memory on the heaviest
query drops from 1.44GB to 164MB, about 9 times less. Multi-vector
file scoring from the previous commit keeps recall without a
cross-encoder.
Rocchio pseudo-relevance feedback: top keyword matches may share
query vocabulary but not the underlying theme. Their collective
embedding points toward the thematic cluster. Example query "async
task cancellation": top-3 are about future/cancel tokens, their mean
pulls docs about cooperative cancellation that don't mention query
terms.

Flow: keyword plus vector search fused into one ranking (RRF), take
the top 3 files as theme representatives, embed their winning chunk
texts, average into one centroid vector, vector-search the centroid
for 20 nearby files, fuse the centroid hits back into the ranking
as one more list at weight 1.0.

Stored-vector path: reuse the chunk embeddings already stored in the
vector index for the winning files. If all are present, build the
centroid directly from them, avoiding re-embedding and a model load.
Fall back to embedding only when stored vectors are missing or were
made by a different model version.

Auto-enable on weak keyword results: when the fused ranking holds 6
or fewer files, or the top keyword score is below 0.35 (scores run 0
to 1, higher is better), centroid expansion switches on even if
disabled in config, because the fast path returned keyword-only
results with too few matches to trust. Otherwise it stays off by
default until a faster vector index lands.

Eval on 10 queries: centroid changes top5 in 80 percent (overlap 3.0/5
avg 60 percent). Half pull new docs beyond the base top-10 into the
new top-5, 3 of 10 pull in docs ranked outside the base top-100 on
queries where very few documents matched ("async cancellation", "RRF
fusion", "amdgpu freeze"). Cost: the second vector search takes 13s
(flat scan over 88k vectors without a fast index). Fast path stays
0.5-1.5s without centroid.
@rikvanriel

Copy link
Copy Markdown
Author

This should fix #952 and related issues.

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