Repository navigation
Filtered top-k search on the dynamic Vamana index via the search builder - #442
Draft
yuejiaointel wants to merge 5 commits into
Draft
yuejiaointel wants to merge 5 commits into
yuejiaointel wants to merge 5 commits into
Conversation
Reuse the MutableBuffer valid/invalid mechanism: a FilteredValidBuilder marks an entry invalid if it is deleted or the predicate rejects its external id. New MutableVamanaIndex::search overload taking a predicate, padding rows with id -1 / +inf and returning per-query found counts. Supplement path checks the predicate before computing the distance. Orchestrator virtual appended last.
Replace the separate filtered search overload with two optional trailing arguments on MutableVamanaIndex::search (an id predicate and a per-query result count). Without a predicate the existing code path runs unchanged. check_and_supplement_search_buffer gets an optional predicate that is checked before the distance is computed. The filter predicate type is now a type-erased std::function, so FilteredValidBuilder is no longer a template. The orchestrator keeps one appended virtual that forwards to the index search.
The optional filter on MutableVamanaIndex::search now only chooses which builder the existing greedy search closure uses; both cases call the same per_thread_batch_search. FilteredValidBuilder derives from ValidBuilder. The buffer top-up in single_search applies the filter when the search closure carries one. The per-query result count out-parameter is removed (callers count non-padding ids). per_thread_batch_search now pads result rows with fewer than k entries (id size_t(-1), distance +inf) instead of reading past the end of the search buffer, and the post-search id translation skips the padding.
Give ValidBuilder an optional predicate and pass it down through internal_search_builder, greedy_search_closure and search() as optional arguments, instead of a separate builder and search path. Filtered-only checks: the supplement fallback skips failing ids, copy-out pads short rows with id -1 and the worst score, translation skips padded ids.
The orchestrator reports filtered search as unsupported for indexes whose search has no filter argument (the concurrent index), instead of failing to compile. In the filtered test, drop the unfiltered-padding section (padding is filtered-only) and add an inner-product case checking padding uses -inf.
This was referenced Oct 23, 2026
This branch has not been deployed
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.
Idea
The dynamic index's search already marks every node it finds as valid or invalid: today "invalid" means deleted. Invalid nodes are still navigated through, but they do not count toward the search window, and
cleanup()drops them before results are returned.This PR widens "invalid" to "deleted or rejected by a user predicate". Filtered top-k search then becomes the normal greedy search with one extra check per node, instead of the post-filter loop over the batch iterator: one pass, no resume, no extra buffer capacity.
Core changes
ValidBuildertakes an optional predicate (plus the id translator). If a node is not deleted and a predicate is set, it is valid only ifpred(external_id)is true. This is the only place filtering is decided. (include/svs/index/vamana/dynamic_index.h)internal_search_builder(pred),greedy_search_closure(..., pred),search(results, queries, params, cancel, pred). With no predicate everything behaves as before.search()still makes the sameper_thread_batch_searchcall; with a predicate the closure is wrapped in a smallFilteredSearchstruct so the steps below can see the predicate.include/svs/index/vamana/extensions.h):check_and_supplement_search_bufferskips ids that fail the predicate (before computing their distance).single_searchpasses the predicate to that top-up.size_t(-1)and the metric's worst score (+inf for L2, -inf for inner product / cosine).translate_to_external).DynamicVamana::filtered_search(results, queries, params, filter), a new virtual appended last (ABI). Returns false for compressed data types.Not changed:
greedy_search.h,MutableBuffer, the static index, the concurrent index, the batch iterator, unfiltered search results.Notes and limitations
filtered_searchreturns false for them.Benchmark
Single thread, 1M x 128 synthetic, L2, k=10, dynamic index, one index per process, mean of 2 processes, search window 10, this exact version (PR head c4d7396). 99 % pass rate:
Lower pass rates at window 10 (recall / QPS), this change vs the batch-iterator loop:
Speed falls behind at <= 1 % pass and large windows (1 %, window 100: 43 QPS), where many failing nodes accumulate in the sorted buffer.
Tests
[filtered]test intests/svs/index/vamana/dynamic_index.cpp: recall against brute force over passing ids, deleted ids never returned, fewer than k passing (padding), unfiltered search unchanged by the optional argument, inner-product padding uses -inf, orchestrator entry point. Run:[filtered],*Dynamic*,MutableVamana*,Concurrent*,Multi*,[dynamic_index],*Vamana*,[vamana](Release, GCC 11.4), and the C API targetsvs_c_apibuilds. Not covered: the orchestrator returning false for a non-SimpleDatatype (no cheap compressed index in this test).🤖 Generated with Claude Code