perf(vector/search): make HNSW traversal deletion-aware (#665)#772
Merged
Conversation
The HNSW graph walk never consulted is_deleted during traversal, so deleted documents leaked into results (finalize_graph_results does not re-check deletion and the quantized distance path bypasses get_vector) and consumed ef_search slots, dropping recall as the deletion rate grew. Apply a unified admission predicate in search_graph: a node enters the result heap only if it matches the filter (if any) AND is not deleted, while the frontier still expands through every neighbour to preserve connectivity. The pristine path (no filter, no deletions) is unchanged, and check_deletions short-circuits is_deleted away on the filter-only path. The entry seed and tiny-allow-set brute path apply the same guard. Closes #665
3 tasks
This was referenced Jun 3, 2026
ci: free disk space before embeddings-all builds to stop intermittent "No space left on device"
#775
Closed
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
The HNSW graph walk (
search_graph) never consultedis_deletedduring traversal. This was a correctness bug, not just a recall regression:finalize_graph_resultsdoes not re-check deletion (onlyf32::MAXfield-missing andmin_similarity).get_vector(the only place deletion was honoured) only wheninclude_vectors == true. So withinclude_vectors == false(the default) deleted docs leaked into hits verbatim, and withtruethey leaked asvector: Nonehits.get_vectorentirely, so even that single deletion filter never ran on the hot path.On top of that, deleted nodes occupied the
ef_search-sized result heap and pushed live nodes out, so recall fell as the deletion rate grew (worst case: anef_searchwindow all deleted → empty/all-leaked results). A post-filter cannot fix this — its slots are already spent — so exclusion must happen during traversal admission.Approach
Same shape as the #645 filter-aware traversal: a unified admission predicate. A node enters the result heap (
found) only if it matches the filter (if any) and is not deleted; the frontier (candidates) still expands through every neighbour to preserve connectivity.HnswIndexReader::is_deletedis nowpub(crate), and a newhas_deletions()(deleted_count > 0) lets the searcher decide whether bookkeeping is needed. No public API change — the searcher already downcasts toHnswIndexReader, which holds the bitmap, so the issue's "passArc<DeletionBitmap>intoHnswSearcher" suggestion is satisfied without duplicating state.search_graphbranches onneeds_bookkeeping = filter.is_some() || reader.has_deletions(). The bookkeeping branch generalizes the former filtered branch;check_deletionsis hoisted so the filter-only path short-circuitsis_deletedaway entirely.allowed_idsis post-filter only #645 loop — unchanged codegen/latency for the dominant production case.allowed_idsis post-filter only #645 bug where the entry was admitted tofoundunconditionally (andfinalizenever re-filtered), letting a filter-rejected entry leak.Tests
New
laurus/tests/vector_hnsw_deletion_aware_test.rs— membership-only invariants (robust to the randomized graph, per repo convention):deleted_never_returned— delete the 20 nearest docs; none appear, all hits live.deleting_nearest_keeps_full_page— page stays full from next-nearest live docs (recall preserved).deletion_with_filter— allow-set >ef_search(graph path); hits ⊆ allow ∖ deleted.tiny_allowset_excludes_deleted— allow-set <ef_search(exact brute path) honours deletion.mass_deletion_no_leak— 195/200 deleted; only survivors returned.no_deletions_returns_full_page— pristine regression.Verification
cargo clippy -p laurus --all-targets -- -D warnings— cleancargo fmt --check— cleancargo test -p laurus --lib— 1104 passedvector_filter_aware_hnsw_test(6) andvector_deletion_test(1) — all greenmarkdownlint-cli2(en/ja) — 0 errorsDocs: added a "Deletion-Aware HNSW Traversal" section to
concepts/search/vector_search.md(en/ja).Closes #665