Skip to content

perf(vector/search): make HNSW traversal deletion-aware (#665)#772

Merged
mosuka merged 1 commit into
mainfrom
perf/665-hnsw-deletion-aware
Jun 3, 2026
Merged

perf(vector/search): make HNSW traversal deletion-aware (#665)#772
mosuka merged 1 commit into
mainfrom
perf/665-hnsw-deletion-aware

Conversation

@mosuka
Copy link
Copy Markdown
Owner

@mosuka mosuka commented Jun 3, 2026

Summary

The HNSW graph walk (search_graph) never consulted is_deleted during traversal. This was a correctness bug, not just a recall regression:

  • finalize_graph_results does not re-check deletion (only f32::MAX field-missing and min_similarity).
  • Result vectors call get_vector (the only place deletion was honoured) only when include_vectors == true. So with include_vectors == false (the default) deleted docs leaked into hits verbatim, and with true they leaked as vector: None hits.
  • The quantized (int8/PQ) distance path bypasses get_vector entirely, 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: an ef_search window 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_deleted is now pub(crate), and a new has_deletions() (deleted_count > 0) lets the searcher decide whether bookkeeping is needed. No public API change — the searcher already downcasts to HnswIndexReader, which holds the bitmap, so the issue's "pass Arc<DeletionBitmap> into HnswSearcher" suggestion is satisfied without duplicating state.
  • search_graph branches on needs_bookkeeping = filter.is_some() || reader.has_deletions(). The bookkeeping branch generalizes the former filtered branch; check_deletions is hoisted so the filter-only path short-circuits is_deleted away entirely.
  • The pristine path (no filter, no deletions) is the byte-for-byte pre-perf(vector/search): no filter-aware HNSW graph traversal — allowed_ids is post-filter only #645 loop — unchanged codegen/latency for the dominant production case.
  • The entry-point seed and the tiny-allow-set brute path apply the same predicate. This also fixes a latent perf(vector/search): no filter-aware HNSW graph traversal — allowed_ids is post-filter only #645 bug where the entry was admitted to found unconditionally (and finalize never 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 — clean
  • cargo fmt --check — clean
  • cargo test -p laurus --lib — 1104 passed
  • New suite (6) + regression on vector_filter_aware_hnsw_test (6) and vector_deletion_test (1) — all green
  • markdownlint-cli2 (en/ja) — 0 errors

Docs: added a "Deletion-Aware HNSW Traversal" section to concepts/search/vector_search.md (en/ja).

Closes #665

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
@mosuka mosuka merged commit 65b591d into main Jun 3, 2026
50 of 52 checks passed
@mosuka mosuka deleted the perf/665-hnsw-deletion-aware branch June 3, 2026 11:42
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.

perf(vector/search): HNSW traversal not deletion-aware — deleted nodes still consume ef_search

1 participant