Skip to content

Single-pass filtered top-k search for the dynamic Vamana index (C API) - #422

Draft
yuejiaointel wants to merge 3 commits into
intel:mainfrom
yuejiaointel:feature/two-heap-filtered-search
Draft

yuejiaointel wants to merge 3 commits into
intel:mainfrom
yuejiaointel:feature/two-heap-filtered-search

Conversation

@yuejiaointel

@yuejiaointel yuejiaointel commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Filtered top-k on the dynamic Vamana index through the C API runs an unfiltered search via BatchIterator, filters each batch, and widens the window until k results pass. At selective filters the sorted search buffer grows to about k / selectivity entries, so per-query cost rises sharply.

  • With search_buffer_visited_set off (the old default), duplicate distance computations dominate.
  • With it on (the current default), resuming the iterator cannot re-admit candidates that were seen and then dropped, so filtered recall falls. On a 1M x 128-d synthetic set, k=10, 1 % selectivity, search window 100, recall@10 is about 0.66 with the visited set on, against 0.986 with it off.

Change

A single-pass, hnswlib-style search for filtered top-k on the dynamic index:

  • a candidate min-heap and a result max-heap holding only non-deleted nodes that pass the filter, capped at max(k, search window size);
  • nodes that fail the filter are still expanded but never enter the result heap;
  • an exact visited set (one tag per slot, reset per query), and no resume;
  • the search stops when the best candidate is worse than the worst result in a full heap.

Results are padded when fewer than k nodes pass, as before. Unfiltered search, the static index, BatchIterator, build and rerank are not touched.

The new entry point is DynamicVamana::two_heap_filtered_search, backed by one new virtual appended last in DynamicVamanaInterface. It returns false for data types that need a custom search path (anything other than uncompressed and scalar-quantized data, so LVQ and LeanVec), and the C API then keeps using the batch iterator for those.

Results

1M x 128-d synthetic data, k=10, single search thread, mean latency per query. Each row compares the old path (visited set off), the new path, and hnswlib at matched recall (each recall in brackets). One run each.

selectivity before new hnswlib
10 % 475.5 us (1.0000) 137.9 us (1.0000) 251.3 us (0.9984)
1 % 4517 us (0.9862) 1123 us (0.9872) 3784 us (0.9866)
0.5 % 12069 us (0.9824) 2345 us (0.9855) 7291 us (0.9858)
0.2 % 34812 us (0.9852) 5690 us (0.9886) 17294 us (0.9880)

At 10 % the new path's recall is above the hnswlib row, so that ratio understates its lead. With the visited set on, the old path's recall at 1 % and below is 0.63 to 0.81 at windows 10 to 100, so it has no valid latency comparison there.

In a Valkey server benchmark (16 clients) at 1 % and below, the new path's mean latency was 0.58 to 0.60 times hnswlib's, with recall about 0.005 lower (for example 0.9812 against 0.9872 at 1 %).

Trade-off: the new path calls the predicate on more candidates than the old path (for example about 360 against 99 per query at 10 %, window 10). In the server benchmark at 10 to 25 % selectivity, where each predicate call is more expensive, it was about 1.1 times hnswlib's latency, and the old path with the visited set on was faster there.

Comparison against current main (same machine and session, two passes each)

Single search thread, 1M x 128-d synthetic data, k=10. main here has search_buffer_visited_set on by default.

Unfiltered search (unchanged code path), mean latency per query with recall@10 in brackets:

search window main this PR PR / main
10 50.6 us (0.9892) 50.0 us (0.9892) 0.99
100 147.9 us (1.0000) 147.1 us (1.0000) 0.99
1000 1267.0 us (1.0000) 1282.5 us (1.0000) 1.01
2000 2811.3 us (1.0000) 2809.8 us (1.0000) 1.00

The largest pass-to-pass spread was 1.2 %, so there is no measurable regression.

Filtered search on main today: recall@10 at 1 % selectivity is 0.627 to 0.656 at search windows 10, 20, 50 and 100. It is below 0.95 in 16 of 24 tested (window, selectivity) cells, down to 0.2 % and 0.1 % selectivity. With this PR the lowest filtered recall in the same grid is 0.985. At matched recall (cheapest window reaching 0.99) this PR is 0.66x main's latency at 50 % selectivity and 0.63x at 10 %.

Note on the search window: on the new path the search window now sets the size of the filtered result set, not a batch size. A large window therefore does more work than before. For example, at 10 % selectivity a window of 100 takes 1157 us against 217 us on main (both recall >= 0.9996), while a window of 10 already reaches recall 1.0000 in 138 us. Small windows are enough for filtered top-k on this path.

Limitations and follow-ups

  • Dynamic index through the C API only. The static index, the C++ runtime bindings and the concurrent dynamic index are unchanged.
  • LVQ and LeanVec indexes keep the batch-iterator path.
  • When fewer than k nodes pass, the search visits every reachable node, so a filter matching almost nothing costs a full traversal. The old path's filter-rate early-out is not used on the new path. Valkey avoids this for very small matching sets by pre-filtering exactly.
  • Exactly duplicated vectors can leave parts of the graph unreachable, so fewer than k results can come back where the old path found k. The existing dynamic filtered-search test used such data and now uses random data.
  • Translating the internal id to the external id for each predicate call is a hash lookup; an array would be cheaper (about 8 % of search time at 1 %).
  • The path could be chosen from an estimated selectivity.
  • The memory-footprint estimate for filtered search in the index builder still assumes the batch iterator.

Testing

  • New C API test C API Dynamic Filtered Search recall (10k x 32-d random data, search window 100, k=10, 20 queries): recall against brute force restricted to the filter is at least 0.95 for modulus filters at 1/2, 1/10 and 1/100; every returned id passes the filter; after deleting half of the ids that pass the filter, none of them is returned and recall stays at least 0.95; a filter matching nothing returns no results. On main the 1/100 and deletion cases fail this test.
  • svs_c_api_test (all 24 test cases): 23 pass. C API Logger Output fails with and without this change.
  • tests "*Dynamic*" (C++ unit tests, 19 test cases): all pass.
  • pre-commit run --all-files is clean.

🤖 Generated with Claude Code

Filtered top-k in the C API ran an unfiltered search through BatchIterator,
filtered each batch and widened the window until k results passed. At selective
filters the search buffer grows to about k / selectivity entries, and with the
visited set enabled the resumed search cannot re-admit candidates that were
seen and then dropped, which lowers recall.

Add index::vamana::two_heap_filtered_search: one graph traversal with a
candidate min-heap, a result max-heap of non-deleted nodes that pass the
filter (capped at max(k, search window)), and an exact visited set. Failing
nodes are still expanded. DynamicVamana::two_heap_filtered_search exposes it
through a new virtual appended last in DynamicVamanaInterface; it returns
false for data types that need a custom search path (anything other than
uncompressed and scalar-quantized data), and the C API then keeps using the
batch iterator. The static index is unchanged.
… C API index

Use random data in the existing dynamic filtered search test: the periodic
generator produces a few distinct vectors, which leaves the graph
disconnected.

This branch has not been deployed

No deployments
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