fix(store): pre-filtered KNN for collection-scoped vector search - #936
brettdavies wants to merge 2 commits into
Conversation
…or search A collection-scoped vector search runs a single sqlite-vec KNN statement whose IN-subselect names every requested collection. vec0 restricts the candidate set on its primary key before selecting k, so the scope is an exact top-k over the named collections and a small collection is never crowded out by a larger one, in or out of scope (tobi#775, tobi#791, tobi#803). The scoped path had collected the scope's hash_seq keys and evaluated `hash_seq IN (400 keys)` against vectors_vec in chunks. vec0 uses its key index only for equality, so each chunk walked the whole table: on a 1.26M-chunk index a 3.8k-key collection paid ten full passes per vector query, and a default scope of 13 collections recursed once per collection. Scopes above 20k keys fell back to a capped global top-k with a post-filter, which starved them whenever nearer vectors existed elsewhere. The unscoped path keeps the global KNN. Step 2 binds the same collection names as an IN list. Result assembly is unchanged: over-fetch factor 3 capped at 4096, one row per file at its best chunk distance, score equal to one minus distance. Tests pin the starvation guarantees, a hash shared with an out-of-scope collection, inactive rows, empty and unknown scopes, a limit above the k cap, the empty-list-means-unscoped contract, chunk collapse across a union, and a 20k+ chunk scope behind nearer out-of-scope vectors.
The README note on multiple `-c` flags, the multi-collection test header, and a `[Unreleased]` changelog entry describe the scoped vector path as it is: one nearest-neighbour query over the union of the named collections, three times `-n` chunks collapsed to one row per file.
…ate (#194) ## Summary `qmd-update.service` (Linux) and `com.user.qmd-update` (macOS) run `qmd update` only. `qmd cleanup` stays with the nightly `qmd-cleanup` unit and agent, which already run it once a day. Cleanup every five minutes rewrote the entire index each cycle: `VACUUM` copies the 12.6 GB database through the WAL and checkpoints it back (about 25 GB of I/O per run, with the WAL growing to the size of the database mid-run), and the update unit stayed busy for two and a half minutes of every five. It also emptied `llm_cache` every cycle, so a repeated `qmd query` almost never found its cached expansion and paid the 1.3s LLM expansion again. ## Changelog ### Fixed - Fix the five-minute qmd update timer to run `qmd update` only; vacuum, FTS optimize, and LLM cache drop now happen once a day in the nightly cleanup unit instead of every five minutes. ## Type of Change - [x] `fix`: Bug fix (non-breaking change which adds functionality) ## Related Issues/Stories - Story: n/a - Issue: n/a - Architecture: n/a - Related PRs: tobi/qmd#936 (the scoped vector search speedup this timer was masking) ## Testing - [x] Unit tests added/updated - [ ] Integration tests added/updated - [x] Manual testing completed - [x] All tests passing **Test Summary:** - Unit tests: `bats tests/qmd-serve.bats`, 57 passing (one assertion replaced, one added: the update unit must not mention `qmd cleanup`; the nightly unit still must) - Integration tests: n/a - Coverage: n/a ## Files Modified **Modified:** - `stow/local/dot-config/systemd/user/qmd-update.service`: `ExecStart` runs `qmd update` only; description updated - `stow/launchagent/Library/LaunchAgents/com.user.qmd-update.plist`: same for the macOS agent, plus the header comment - `BOOTSTRAP.md`: LaunchAgent table row for `com.user.qmd-update` - `tests/qmd-serve.bats`: assertions for the update unit **Created:** - None. **Deleted:** - None. ## Breaking Changes - [x] No breaking changes ## Deployment Notes - [ ] No special deployment steps required - [x] Deployment steps documented below: After merging: `scripts/stow-deploy local` (Linux) or `scripts/stow-deploy launchagent` (macOS), then `systemctl --user daemon-reload` or `launchctl bootout gui/$(id -u)/com.user.qmd-update && launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.qmd-update.plist`. The next timer run then skips the vacuum; the nightly unit is unchanged. ## Checklist - [x] Code follows project conventions and style guidelines - [x] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) - [x] Self-review of code completed - [x] Tests added/updated and passing - [x] No new warnings or errors introduced - [x] Changes are backward compatible (or breaking changes documented)
|
Independent synthetic reproduction against pinned main This PR addresses the provider-side behavior we need. We reproduced a sharp eligible-set threshold on the baseline using 3D synthetic vectors and a precomputed query:
The eligible vectors are There is a second, distinct document-top-k edge worth retaining when changing this path: one eligible document with 20 close chunks and another eligible document with one farther chunk, requested limit=2, returns only one document on the baseline exact-scan path. Truncating to Our small exact-scan candidate retains the best chunk per content hash before truncation; downstream filepath mapping and metadata filtering remain intact. It fixes that second case, including a noneligible path sharing the content hash and the best chunk crossing the 400-key batch boundary. It deliberately does not fix the >20k branch, and may not apply directly after this PR removes the exact-scan helper. Sharing it as a test/design reference, not a proposed competing long-term fork. Diagnostic instructions: save the script as vector-scope-probe.mjs in a prepared checkout; use isolated QMD_CONFIG_DIR/XDG_CACHE_HOME and run Self-contained vector diagnosticimport assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
const root=process.env.QMD_UPSTREAM_DIR;
if(!root) throw new Error('Set QMD_UPSTREAM_DIR to the isolated pinned QMD checkout.');
const {createStore,searchVec,insertContent,insertDocument,insertEmbedding,hashContent,DEFAULT_EMBED_MODEL}=await import(pathToFileURL(resolve(root,'src/store.ts')).href);
const {replaceDocumentMetadata}=await import(pathToFileURL(resolve(root,'src/metadata-store.ts')).href);
const {METADATA_EXTRACTION_VERSION}=await import(pathToFileURL(resolve(root,'src/metadata.ts')).href);
const now='2026-09-16T00:00:00Z', filter={key:'eligible',operator:'eq',value:true};
async function doc(store,name,eligible,embedding,chunks=1) {
const body=`# ${name}`, hash=await hashContent(body);
insertContent(store.db,hash,body,now);
const id=insertDocument(store.db,'book',`${name}.md`,name,hash,now,now);
replaceDocumentMetadata(store.db,id,{metadata:{eligible},extractionVersion:METADATA_EXTRACTION_VERSION});
for(let seq=0;seq<chunks;seq++) insertEmbedding(store.db,hash,seq,seq*100,new Float32Array(embedding),DEFAULT_EMBED_MODEL,now,chunks);
return hash;
}
async function search(store,limit) {
const start=performance.now();
const rows=await searchVec(store.db,'synthetic',DEFAULT_EMBED_MODEL,limit,'book',undefined,[1,0,0],undefined,filter);
assert.ok(rows.every(row=>row.metadata.eligible===true),'scope correctness must hold');
return {count:rows.length,files:rows.map(row=>row.displayPath),ms:Math.round(performance.now()-start)};
}
const large=createStore(':memory:');
const chunked=createStore(':memory:');
try {
large.ensureVecTable(3); chunked.ensureVecTable(3);
large.db.exec('BEGIN');
for(let i=0;i<20000;i++) await doc(large,`eligible-${i}`,true,[0,1,0]);
for(let i=0;i<200;i++) await doc(large,`future-${i}`,false,[1,0,0]);
large.db.exec('COMMIT');
const at20000=await search(large,5);
assert.equal(at20000.count,5);
await doc(large,'eligible-20000',true,[0,1,0]);
const at20001=await search(large,5);
const sharedHash=await doc(chunked,'many-chunks',true,[1,0,0],20);
const excludedId=insertDocument(chunked.db,'book','excluded-copy.md','excluded-copy',sharedHash,now,now);
replaceDocumentMetadata(chunked.db,excludedId,{metadata:{eligible:false},extractionVersion:METADATA_EXTRACTION_VERSION});
await doc(chunked,'second-document',true,[0,1,0]);
const chunkTop2=await search(chunked,2);
assert.ok(!chunkTop2.files.includes('book/excluded-copy.md'));
if(process.argv.includes('--assert-chunks')) assert.equal(chunkTop2.count,2);
for(let seq=0;seq<20;seq++) insertEmbedding(chunked.db,sharedHash,seq,seq*100,new Float32Array(seq===19?[1,0,0]:[0,0,1]),DEFAULT_EMBED_MODEL,now,20);
const bestChunk=await searchVec(chunked.db,'synthetic',DEFAULT_EMBED_MODEL,2,'book',undefined,[1,0,0],undefined,filter);
assert.equal(bestChunk[0].chunkPos,1900,'best chunk position must survive deduplication');
for(let seq=0;seq<450;seq++) insertEmbedding(chunked.db,sharedHash,seq,seq*100,new Float32Array(seq===449?[1,0,0]:[0,0,1]),DEFAULT_EMBED_MODEL,now,450);
const acrossBatch=await searchVec(chunked.db,'synthetic',DEFAULT_EMBED_MODEL,2,'book',undefined,[1,0,0],undefined,filter);
assert.equal(acrossBatch[0].chunkPos,44900,'best chunk across the 400-row scan batch must survive');
if(process.argv.includes('--assert-chunks')||process.argv.includes('--assert-complete')) assert.equal(acrossBatch.length,2);
console.log(JSON.stringify({at20000,at20001,chunkTop2,acrossBatch:{count:acrossBatch.length,bestPosition:acrossBatch[0].chunkPos},expected:{large:5,chunked:2},modelsDownloaded:false},null,2));
if(process.argv.includes('--assert-complete')) {
assert.equal(at20001.count,5,'eligible-set threshold must not change recall correctness');
assert.equal(chunkTop2.count,2,'chunks of one document must not starve another eligible document');
}
} finally {large.close();chunked.close();}Isolated exact-scan document-dedup candidatediff --git a/src/store.ts b/src/store.ts
index 3a45bbc..acea152 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -4201,9 +4191,9 @@ function exactVecScanByHashSeq(
if (hashSeqs.length === 0 || limit <= 0) return [];
const queryVec = new Float32Array(embedding);
- // Over-fetch a bit so multi-chunk docs can still yield `limit` unique files.
- const fetchLimit = Math.max(limit * 3, limit);
- const scored: { hash_seq: string; distance: number }[] = [];
+ // Preserve the best chunk per content before truncating candidates. A long
+ // document must not occupy the entire window with near-identical chunks.
+ const best = new Map<string, { hash_seq: string; distance: number }>();
for (let i = 0; i < hashSeqs.length; i += VEC_HASH_SEQ_IN_CHUNK) {
const chunk = hashSeqs.slice(i, i + VEC_HASH_SEQ_IN_CHUNK);
@@ -4213,11 +4203,14 @@ function exactVecScanByHashSeq(
FROM vectors_vec
WHERE hash_seq IN (${placeholders})
`).all(queryVec, ...chunk) as { hash_seq: string; distance: number }[];
- scored.push(...rows);
+ for (const row of rows) {
+ const hash = row.hash_seq.slice(0, row.hash_seq.lastIndexOf("_"));
+ const previous = best.get(hash);
+ if (!previous || row.distance < previous.distance) best.set(hash, row);
+ }
}
- scored.sort((a, b) => a.distance - b.distance);
- return scored.slice(0, fetchLimit);
+ return [...best.values()].sort((a, b) => a.distance - b.distance).slice(0, limit);
}
function annVecScan(
|
Collection-scoped vector search as a pre-filtered KNN statement
Problem
searchVecwith a collection filter collects the scope'shash_seqkeys and evaluateshash_seq IN (400 keys)againstvectors_vecin chunks (#847). vec0 uses its key index only for equality, so every chunk is a full pass over the table. On a 793k-vector index (12.6 GB) a 3.8k-key collection pays ten passes of about 0.7s per vector query:qmd vsearch -c solutionsspends 30s of a 33s run there, and a default scope of 13 collections recurses once per collection and takes 152s. BM25 stays under a second for the same scopes.Scopes above 20k keys take the other branch, a capped global top-k with a post-filter, which is the starvation the exact scan was added to avoid. On this index
vaultholds 20,844 active chunks, so a default-scope search returned onevaultdocument whilestars(761k keys) filled the top-k.Fix
One KNN statement per vector query, whatever the scope:
vec0 applies the IN list on its primary key before selecting k, so the result is an exact top-k over the scope and a small collection is never crowded out by a larger one, in or out of scope. The subselect reads
content_vectors,documents, and vec0's own key tablevectors_vec_rowids; it never touches thevectors_vecvirtual table, so the two-statement rule from #23 still holds. The join onvectors_vec_rowidsis load-bearing: vec0 0.1.9 answers a KNN whose IN list names a key it does not hold with no rows at all, and acontent_vectorsrow whose vector is missing (the embed path writes the two tables in separate statements) would otherwise blank every scoped search that includes that document. Drawing the list from the shadow table narrows the scope instead.A scope that holds at least half of the active documents skips the pre-filter: the global KNN runs with an over-fetch of four times k over the scope's share, its rows are post-filtered to the scope, and the result is kept when at least k in-scope rows came back (or the table ran out). The in-scope rows of a global top-k' are the exact in-scope top-m, so that answer is identical to the pre-filtered one; when the scan comes back short the pre-filtered statement runs. This matters because vec0 resolves every key of an IN list one by one, which for a 761k-key scope costs more than the brute-force pass itself. A 19ms document count picks the route, and the global attempt is skipped when its over-fetch would not fit under the 4096 cap, so a large
-nor--allnever pays both scans.Without a scope the statement is the unchanged global KNN. Step 2 binds the same names as an IN list. k is three chunks per requested result for each collection in scope, pooled into the one statement and capped at 4096, so a long document in one collection cannot own a union's whole pool; a single scope keeps the previous k of three times the limit. Result assembly is unchanged: one row per file at its best chunk distance, score is one minus distance.
exactVecScanByHashSeq, its two constants, and the per-collection recursion insearchVecare gone.searchFTSandstructuredSearchkeep their per-collection loops, so RRF weighting does not change.Measured on a live index
12.6 GB index, 793k vectors;
starsholds 761k keys, the 13 default collections 36k,solutions3.8k. Best of the runs taken outside the index's five-minute update and embed windows. Both columns use the same warm model process for embedding and query expansion, so the difference is the vector stage.qmd vsearch "slow query" -c solutions -n 3qmd query "slow query" -c solutions -n 3qmd vsearch "slow query" -n 3(13 collections)qmd vsearch "slow query" -c stars -n 3(761k keys)searchVecalone, one precomputed query embedding, read-only connection:solutions, limit 20stars, limit 3The
starsscope goes through the global-scan route; a pre-filtered statement over its 761k keys measures 5.2s per vector query because vec0 resolves each key in its shadow table one by one, and the old code paid 3.8s collecting the same keys in JavaScript before its global scan.Results before and after
Ordered result keys from
searchVecwith the same embedding are identical forsolutionsat limits 3 and 20, for the default scope at limit 3, and forstarsat limit 3. The default scope at limit 20 shares its first eight rows; the old tail held rows scoring 0.541 to 0.547, the new tail holds eightvaultfiles scoring 0.553 to 0.561 that the old code never fetched because vault's 20,844 chunks put it on the global top-k branch.Tests
test/store.test.ts, "Vector Search collection filter": the #791/#803 and #775 tests pass with their assertions unchanged. Added: two of three collections in scope; a hash shared with an out-of-scope collection returned once under the scoped name; a hash whose only in-scope row is inactive; an active plus an inactive row for one hash; a scoped collection with no vectors; an unknown name; a limit above the k cap; an empty list equals unscoped; chunk collapse across a union; result row fields; a 20,001-chunk scope behind 100 nearer out-of-scope vectors, which returns nothing onmainand passes here; a scoped collection that still answers when one chunk row has no vector; a union that keeps a sibling collection reachable behind a long in-scope document; a majority collection served from the global scan, both below and above the over-fetch, plus the same scope not starved when the nearest vectors sit outside it; and a routing test that pins the global scan to a majority scope and thevectors_vec_rowidspre-filter to a minority scope through the prepared statements.CI=truevitest and bun suites green under Node 26 and Bun 1.4;tsc --noEmitandoxlintclean.Known limits
-n; the README note names-nand--allas the lever.Related issues
#775, #791, #803 were closed by #847's exact scan, which this replaces. #918 is the keyword-side counterpart for
searchFTS.