Skip to content

Filtered search inside greedy search, with a two-heap search buffer - #456

Draft
yuejiaointel wants to merge 16 commits into
mainfrom
feature/filtered-search-two-heap-buffer
Draft

yuejiaointel wants to merge 16 commits into
mainfrom
feature/filtered-search-two-heap-buffer

Conversation

@yuejiaointel

@yuejiaointel yuejiaointel commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

  • The filter is checked inside the search buffer (like hnswlib): a neighbor goes to the results heap only if it could improve the results and passes the filter. ValidBuilder is unchanged.
  • During filtered search, MutableBuffer keeps a min-heap of nodes to visit and a max-heap of the best passing nodes (the results), instead of one sorted list.
  • The C API filtered search on a dynamic index uses this path when the data type supports it.
  • Heap mode is on only when a filter is passed. Unfiltered search, the static index, greedy_search, and the batch iterator are unchanged.

Difference from today's search buffer (sorted list)

  • Today MutableBuffer keeps one sorted array: each insert is a binary search plus a shift. The next node to visit is a pointer to the best unvisited entry, the worst kept entry is the last valid one, and a duplicate is found by scanning the entries with an equal distance.
  • This PR keeps two heaps: a min-heap of nodes to visit (pop gives the next node) and a max-heap of passing results (the top is the worst kept entry). Duplicates are found with a hash set of ids already queued.
  • The filter is called only for a neighbor that is not a duplicate and is better than the worst kept result.

Files

  • include/svs/index/vamana/dynamic_search_buffer.h: MutableBuffer gets an opt-in heap mode that takes the filter as a callable on internal ids.
  • include/svs/index/vamana/dynamic_index.h: MutableVamanaIndex::search takes an optional filter; when given, it turns heap mode on.
  • include/svs/index/vamana/extensions.h: the top-up step and result copy-out respect the filter and pad with the worst score when fewer than k pass.
  • include/svs/orchestrators/dynamic_vamana.h: new filtered_search entry point on the dynamic Vamana orchestrator (appended last in the interface; uncompressed data only).
  • bindings/c/src/filtered_search.hpp: the C API filtered search on a dynamic index now calls filtered_search (search window = max(k, window)) and falls back to the batch-iterator loop when the data type does not support it.

Tests

Existing tests.

Results

Not benchmarked yet for this version.

🤖 Generated with Claude Code

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.
Experiment: with a filter, MutableBuffer keeps a min-heap of entries to
visit and a max-heap of the best valid entries during the search,
instead of one sorted list. greedy_search is unchanged; cleanup() sorts
the results once at the end.
ValidBuilder can be given the search buffer. With a filter, it first
asks the buffer whether the entry would be dropped anyway (can_skip)
and, if so, returns it as invalid without looking up the external id or
calling the filter. Results are unchanged.
The filter skip in ValidBuilder asks can_skip; in heap mode the worst
kept entry is not back().
The existing [filtered] dynamic index test drives heap mode end to end.
ValidBuilder can be given the search buffer. With a filter, it first
asks the buffer whether the entry would be dropped anyway (can_skip)
and, if so, returns it as invalid without looking up the external id or
calling the filter. Results are unchanged.
The filter skip in ValidBuilder asks can_skip; in heap mode the worst
kept entry is not back().
In heap mode, MutableBuffer now calls the filter itself: a neighbor goes
to the results heap only if it could improve the results and passes the
filter. ValidBuilder is back to the main behaviour (deleted check only).

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