perf(store): drive hybrid search from candidate set to avoid full-table scan#456
Merged
Conversation
…le scan After #455 materialized the vector-KNN CTE, the hybrid search query still scanned the entire `documents` table on every search: the final SELECT read `FROM documents d ... LEFT JOIN vec_distances ... LEFT JOIN fts_scores` and filtered to the match set with `WHERE (v.id IS NOT NULL OR f.id IS NOT NULL)`. Search cost therefore scaled with total database size. Introduce a `candidates` CTE (UNION of the vector and FTS id sets) and drive the final query from it, looking documents up by primary key. Results are identical (same rows and scores); cost is now proportional to the number of matches, not the size of the database. Measured on a synthesized 72k-chunk / 5 GB database (single search, SQL level): 233ms -> 8.7ms (~27x). EXPLAIN QUERY PLAN drops the full `SCAN documents` in favour of `SCAN candidates` + primary-key lookups. The FTS-only fallback path already drives from the FTS index and is unchanged. Follow-up to #454 / #455. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes the hybrid (vector + FTS) search query in DocumentStore by driving the final result selection from a small candidate-id set rather than scanning the full documents table, making query cost scale with match count instead of total DB size.
Changes:
- Adds a
candidatesCTE that unions ids fromvec_distancesandfts_scores. - Updates the final
SELECTto start fromcandidatesand join intodocuments/pages, removing the prior full-table-scan shape.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`fts_scores` is now referenced twice (in the `candidates` CTE and in the final LEFT JOIN). SQLite already materializes CTEs referenced more than once, and EXPLAIN QUERY PLAN confirms both shapes produce byte-identical plans containing `MATERIALIZE fts_scores` (measured 20.2ms vs 20.3ms on a 70k-document database, i.e. noise). Pin it explicitly anyway so evaluation-once does not depend on planner heuristics, matching `vec_distances`. Relying on the planner is what caused the original regression in #454. Co-Authored-By: Claude Opus 4.8 <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.
Summary
Follow-up to #454 / #455. The
MATERIALIZEDfix in #455 removed the pathological re-evaluation of the vector KNN, but the hybrid search query still scans the entiredocumentstable on every search: the finalSELECTreadsFROM documents d … LEFT JOIN vec_distances … LEFT JOIN fts_scoresand narrows to the match set withWHERE (v.id IS NOT NULL OR f.id IS NOT NULL). So search cost still scaled with total database size (a residual, much smaller cost than the original wedge, but real on large corpora).This PR drives the final query from the candidate id set instead:
Cost is now proportional to the number of matches (~
k+ overfetch), not the size of the database. Documents are looked up by primary key.Evidence
Synthesized a 72,581-chunk / 5 GB database (matching the scale in #454) and ran the exact old vs new statements with the same parameters:
FROM documents)SCAN d(full documents table)SCAN candidates→SEARCH d … PRIMARY KEYvec_score/fts_score(verified by comparing the full result sets).Notes / scope
Testing
npx vitest run src/store/DocumentStore.test.ts test/vector-search-e2e.test.ts→ 76 passed.npm run typecheckclean;npm run buildclean.🤖 Generated with Claude Code