Repository navigation
Conversation
rfsaliev
requested review from
ahuber21,
mihaic and
yuejiaointel
as code owners
November 13, 2026 13:58
rfsaliev
marked this pull request as draft
November 13, 2026 14:58
- Removed the sort() method from concurrent::MutableBuffer to streamline the search process. - Introduced predicated_buffer_type and predicated_scratchspace_type for handling searches with stop predicates. - Added DefaultStop and RangeStop predicates to control search termination based on conditions. - Enhanced MutableBuffer to maintain state information for valid candidates and total candidates. - Updated the search logic in MutableVamanaIndex to utilize the new predicated search functionality. - Added tests for predicated and range search to ensure correctness and performance. - Improved handling of candidate insertion and buffer state management.
…cates and update tests
…Index and related tests
…manaIndex with corresponding tests
rfsaliev
force-pushed
the
rfsaliev/range-search
branch
from
November 13, 2026 15:06
b81615a to
0a829c3
Compare
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.
Predicated search and range search for the Vamana indexes
Summary
Adds a pluggable stop predicate to the dynamic search buffer (
MutableBuffer) and uses it to provide two new search modes on all three Vamana indexes (VamanaIndex,MutableVamanaIndex,concurrent::MutableVamanaIndex):predicated_search: graph search that terminates when a caller-supplied predicate says so, instead of the fixed "search window fully visited" rule.range_search(radius): returns all neighbors within a distance bound instead of a fixed number of neighbors.Cancellation (the existing
cancelpredicate used for timeouts) is folded into the stop predicate for the new paths. A cancelled query now ends gracefully with the best results found so far instead of returning undefined rows.Existing
search()paths are unchanged in behavior and performance.Motivation
cancelwas checked at several redundant points per query and, on cancel, left result rows partially filled with stale IDs.Changes
Search buffer (
include/svs/index/vamana/dynamic_search_buffer.h)MutableBuffer<Idx, Cmp, Stop = DefaultStop>, so there is no runtime cost.done()is nowbest_unvisited == size() || stop(MutableBufferState{...}).MutableBufferStateis the predicate input:valid: valid results currently in the buffer;total_candidates: all candidates offered to the buffer;valid_visited: valid entries before the best unvisited one;best_unvisited_distance;search_window.DefaultStopreproduces the previousdone()exactly (valid_visited >= search_window).RangeStop<Cmp>{radius}visits at least the search window, then continues while the best unvisited candidate is within the radius. Both L2 (less) and inner product (greater) are handled through the comparator.CancelableStop<Stop>wraps any predicate with acancelcallback.total_candidatesis opt-in. It is counted only for predicates that declarestatic constexpr bool counts_total_candidates = true. Counting it unconditionally cost about 1–2% top-K QPS in the per-candidate insert path.roi_end_is replaced byvalid_visited_. The new counter holds the number of valid entries in[0, best_unvisited_).done()this way.unsafe_insert.next()now scans up tosize(), so a search that continues past the old window never re-expands a visited node.MutableBuffer::unbounded_capacity(SIZE_MAX). In the new APIs, a zero total capacity in the search parameters also means unbounded.sort()now recomputesbest_unvisited_in all cases; previously it was left stale when fewer thansearch_windowvalid entries were present.change_maxsize()keeps the cursor in range when shrinking.slack(), which wrapped for an unbounded capacity, is replaced by direct comparisons.sort()override became identical to the base version and was removed.Shared implementation (
include/svs/index/vamana/predicated_search.h, new)predicated_buffer_config()maps search parameters to a buffer configuration (zero capacity means unbounded).PredicatedNeighborBuildermarks every candidate valid (used by the static index).detail::predicated_batch_searchanddetail::range_batch_searchhold the batch loops shared by all three indexes. Each index supplies only its scratchspace, single-query search, ID translation and an optional per-query translation lock.Index APIs
Each of
VamanaIndex,MutableVamanaIndexandconcurrent::MutableVamanaIndexgains:predicated_searchwrites up tokresults per query and pads missing ones with sentinel neighbors.range_searchreturns in-radius neighbors with external IDs, sorted best to worst. A non-zero buffer capacity caps the number of results per query; zero means unbounded. The radius filter runs after any dataset-specific reranking done by anextensions::single_searchoverride, so it applies to the final distances.MutableBufferwith all candidates valid, because the staticSearchBufferhas no stop predicate.lock_storage_shared()for the whole batch, likesearch().LockedSearchadapter runs the per-query search without taking that lock again. Recursive shared acquisition is the documented hazard.lock_for_translation().no_external_idin batch rows, assearch()does, and are dropped from range results.supplement_search_buffertop-up and seqlockgreedy_search.Cancellation
greedy_search(both the plain and the concurrent version) now takes a templatedCancelthat defaults to a compile-timefalse. Existing callers passconst lib::DefaultPredicate&and behave exactly as before.cancelonce per hop, insideCancelableStop, plus once per query. That isnum_queries + 1fewer calls thansearch(); the test asserts this exactly.predicated_search, or empty vectors inrange_search.predicated_search(query, scratch)has nocancelparameter. Wrap the stop predicate inCancelableStopinstead.Compatibility
MutableBuffer's new template parameter is defaulted, so existing code compiles unchanged.MutableBuffermembers:unsafe_insert(neighbor, pos, offset)loses its now-unusedoffsetargument, matchingSearchBuffer::unsafe_insert.slack()is removed.best_unvisited()can now advance past the old window boundary. Termination and results are unchanged.Performance
Measured locally: dynamic index, 30k × 96 random vectors with 10% deleted, 5k queries, 32 threads, window and capacity 10–200. Each figure is the median of 6–8 interleaved runs.
search()vs.mainpredicated_searchwithDefaultStopvs.search()total_candidatescounting (rejected)The last row is why the counter is opt-in.
Testing
search_buffer.cpp:MutableBufferand fuzz tests pass unchanged.best_unvisited/valid_visitedagainst the reference model.RangeStopforlessandgreater,CancelableStop(including opt-in forwarding), and a filter-rate stopper built onvalid/total_candidates.vamana/index.cpp(static) andvamana/dynamic_index_2.cpp(dynamic), for L2 and inner product:DefaultStopgives results identical tosearch().cancelcall counts, sorted partial rows followed by sentinels, and empty or partial range results.concurrent/dynamic_index_2.cpp:delete_entries,consolidateandcompact. This section passed 20 of 20 repeated runs; it has not been run under ThreadSanitizer.Notes for reviewers
tests/svs/index/vamana/dynamic_index_2.cpp: two tests attach a global log sink that captures a local vector and never remove it. The added tests change heap layout enough to turn that latent bug into a SIGSEGV in a later test. The PR fix(test): add scope guard for logger sink cleanup in dynamic index tests #471 should merge first.dynamic_search_buffer.hand bothgreedy_search.h.cancel()check at the entry of the defaultextensions::single_search.search()onto the graceful-cancel model.range_searchfrom the batch iterator torange_search.