Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4fc740c
Add filtered search on the dynamic Vamana search buffer
yuejiaointel Oct 21, 2026
8b0f437
Make filtered search an optional argument of the existing search
yuejiaointel Oct 21, 2026
a498372
Run filtered search through the existing batch search path
yuejiaointel Oct 21, 2026
5cce383
Filter through ValidBuilder in the existing dynamic search
yuejiaointel Oct 21, 2026
c4d7396
Fix filtered test and orchestrator for the concurrent index
yuejiaointel Oct 22, 2026
1ecd4e8
Filtered search: skip the filter for entries the buffer would drop
yuejiaointel Oct 27, 2026
6593e85
Revert "Filtered search: skip the filter for entries the buffer would…
yuejiaointel Oct 27, 2026
4c42304
Allow filtered search for data types that opt in
yuejiaointel Oct 28, 2026
c788392
Apply clang-format
yuejiaointel Oct 29, 2026
682ae1f
Enable filtered search for all data types
yuejiaointel Oct 30, 2026
2f20b70
Apply clang-format
yuejiaointel Oct 30, 2026
94b22de
Name the filter argument `filter` everywhere
yuejiaointel Oct 30, 2026
9f8da25
Apply clang-format
yuejiaointel Oct 30, 2026
a389cf8
Keep the unfiltered search path inlined
yuejiaointel Oct 31, 2026
f42507a
Revert "Keep the unfiltered search path inlined"
yuejiaointel Nov 2, 2026
bc0a1cf
Choose the filtering builder at compile time
yuejiaointel Nov 2, 2026
dcfed41
Remove the added filtered-search test
yuejiaointel Nov 2, 2026
201e4ca
Share the search body between filtered and regular search
yuejiaointel Nov 3, 2026
7b71a60
Return to the ValidBuilder design before the compile-time changes
yuejiaointel Nov 3, 2026
71b6bf4
Choose filtered or regular search at compile time
yuejiaointel Nov 3, 2026
a78c6b0
Filtered search on the concurrent index
yuejiaointel Nov 3, 2026
697dac9
Skip the filter for entries the buffer would drop
yuejiaointel Nov 4, 2026
9f5f98e
Read external ids without a lock in filtered search
yuejiaointel Nov 4, 2026
c066008
Use the translator shared lock once per search in filtered search
yuejiaointel Nov 4, 2026
bc56c0e
Read internal-to-external ids from a grow-stable vector (concurrent i…
yuejiaointel Nov 4, 2026
bf6f5c4
Remove the filtered-search test changes
yuejiaointel Nov 5, 2026
66719ff
Remove dead code and trim comments in filtered search
yuejiaointel Nov 5, 2026
1a3aacb
Clarify the ValidBuilder comment
yuejiaointel Nov 5, 2026
cf574cb
Put the ValidBuilder comment on the class
yuejiaointel Nov 5, 2026
8931142
Shorten the ValidBuilder comment
yuejiaointel Nov 5, 2026
73d7128
Minimal filtered search
yuejiaointel Nov 5, 2026
b5046f9
Revert "Minimal filtered search"
yuejiaointel Nov 5, 2026
f987b13
Shorten comments and drop a redundant template argument
yuejiaointel Nov 5, 2026
1b9fea2
Merge remote-tracking branch 'origin/main' into feature/filtered-sear…
yuejiaointel Nov 6, 2026
0bc51cf
Address review: padding and FilteredValidBuilder
yuejiaointel Nov 6, 2026
cd561bd
Filtered search on the static Vamana index
yuejiaointel Nov 6, 2026
f6cc27a
Fix unused lambda capture in regular search
yuejiaointel Nov 7, 2026
0652eeb
Fix unused lambda capture in concurrent regular search
yuejiaointel Nov 7, 2026
372dcd7
Note the ABI effect of the new VamanaInterface virtual
yuejiaointel Nov 9, 2026
8eb7a02
Revert "Note the ABI effect of the new VamanaInterface virtual"
yuejiaointel Nov 9, 2026
f19ceb8
Pass the filter to the search top-up explicitly
yuejiaointel Nov 9, 2026
4cbd1fd
Keep FilteredSearch for now
yuejiaointel Nov 9, 2026
85c297d
Do not apply the filter in the search top-up yet
yuejiaointel Nov 10, 2026
f886b62
Skip candidates a full buffer would drop in greedy_search
yuejiaointel Nov 10, 2026
b305936
Fail when the filter calls into the concurrent index
yuejiaointel Nov 10, 2026
b01b6fe
Use only the translator in the dynamic filtered builder
yuejiaointel Nov 12, 2026
6490ea9
Address review: small fixes
yuejiaointel Nov 12, 2026
a68e145
Shrink the filtered search diff
yuejiaointel Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions include/svs/concepts/distance.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

#include <concepts>
#include <cstddef>
#include <limits>
#include <utility>
#include <vector>

Expand Down Expand Up @@ -215,6 +216,12 @@ template <typename T> auto comparator(const T& /*x*/) -> compare_t<T> {
return typename T::compare{};
}

/// Distance reported for a missing neighbor: it sorts after every real distance.
template <typename T> float worst_distance(const T& x) {
return comparator(x)(0.0f, 1.0f) ? std::numeric_limits<float>::infinity()
: -std::numeric_limits<float>::infinity();
}

/////
///// Traits
/////
Expand Down
131 changes: 120 additions & 11 deletions include/svs/concurrent/dynamic_index.h
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,59 @@ class ValidBuilder {
const lib::SegmentedVector<SlotMetadata>& status_;
};

// The index whose user filter runs on this thread, so re-entering it fails fast.
inline thread_local const void* filtering_index = nullptr;

struct FilterScope {
explicit FilterScope(const void* index)
: previous_{filtering_index} {
filtering_index = index;
}
~FilterScope() { filtering_index = previous_; }
const void* previous_;
};

inline void check_not_in_filter(const void* index) {
if (filtering_index == index) {
throw ANNEXCEPTION("filter must not call into the same index");
}
}

// Separate template so the regular search compiles without any filter check.
template <typename Filter> class FilteredValidBuilder {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Filter somehow interacts with the index, UB is possible.
search already takes comaction_mutex_ shared, if Filter calls a method that also takes this mutex. It is double lock and UB.

Is it a possible case?

@yuejiaointel yuejiaointel Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Def a valid concern and is a problem for inline search. This PR will call user's filter in search, and if that filter calls index somehow, that could trigger dead lock because index holds the same lock. But it is an edge case? I have added a few lines to fail in this case + doc in comment

public:
FilteredValidBuilder(
const lib::SegmentedVector<SlotMetadata>& status,
const IDTranslator& translator,
const Filter& filter,
const void* owner
)
: status_{status}
, translator_{&translator}
, filter_{&filter}
, owner_{owner} {}

template <typename I>
PredicatedSearchNeighbor<I> operator()(I i, float distance) const {
bool invalid =
std::atomic_ref<SlotMetadata>(const_cast<SlotMetadata&>(getindex(status_, i)))
.load(std::memory_order_acquire) != SlotMetadata::Valid;
if (!invalid) {
// Lock-free read; an erased slot has no external id and fails.
size_t external = translator_->get_external_or(i, no_external_id);
FilterScope scope{owner_};
invalid = external == no_external_id || !(*filter_)(external);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the desired behavior in case of concurrent thread remaps the entry during filtered search? I.e. what if the (*filter_)(external) was true at the beginning of the operation, but becomes false by the end.

@yuejiaointel yuejiaointel Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good concern! Current behavior:

  1. greedy search, inline filter checking
  2. buffer.cleanup() remove invalid entries
  3. copy back to result table
  4. translate internal id to external user id
  5. just added: check filter again for results, and can return less than k results

So if remap happens during search step 5 could catch it

}
return PredicatedSearchNeighbor<I>(i, distance, !invalid);
}

private:
const lib::SegmentedVector<SlotMetadata>& status_;
const IDTranslator* translator_;
const Filter* filter_;
const void* owner_;
};

template <graphs::MemoryGraph Graph, typename Data, typename Dist>
class MutableVamanaIndex {
friend class MultiMutableVamanaIndex<Graph, Data, Dist>;
Expand Down Expand Up @@ -606,11 +659,7 @@ class MutableVamanaIndex {
size_t metadata_bytes = status_.capacity() * sizeof(SlotMetadata);
metadata_bytes +=
entry_point_.capacity() * sizeof(typename entry_point_type::value_type);
// The IDTranslator holds two tsl::robin_map instances (external->internal and
// internal->external), neither of which exposes its allocated byte count. We
// approximate the storage as the id pair held in each of the two directions. This
// ignores the maps' load-factor slack and control bytes, so it is an estimate of
// the hash-map overhead that is accurate to within a few percent.
// Estimate: one id pair per entry for each translation direction.
metadata_bytes += 2 * translator_.size() *
(sizeof(IDTranslator::external_id_type) +
sizeof(IDTranslator::internal_id_type));
Expand Down Expand Up @@ -810,6 +859,9 @@ class MutableVamanaIndex {
[&](const auto is, uint64_t /*tid*/) {
for (auto i : is) {
for (size_t j = 0, jmax = getsize<1>(ids); j < jmax; ++j) {
if (ids.at(i, j) == static_cast<size_t>(-1)) {
continue; // Padding from a filtered search.
}
auto internal = lib::narrow_cast<Idx>(ids.at(i, j));
ids.at(i, j) = unsafe_translate_internal_id(internal);
}
Expand Down Expand Up @@ -882,13 +934,19 @@ class MutableVamanaIndex {
// This is an internal method, mostly used to help implement the batch iterator.
ValidBuilder internal_search_builder() const { return ValidBuilder{status_}; }

template <typename Filter>
FilteredValidBuilder<Filter> internal_search_builder(const Filter& filter) const {
return FilteredValidBuilder<Filter>{status_, translator_, filter, this};
}

/// @brief RAII shared lock guarding data_/graph_ against compact()'s shrink
/// (which frees segments). Used by searches, iterators and mutations that
/// may overlap each other but must exclude compaction.
///
/// Hold this through internal-to-external ID translation. Acquire the translation
/// lock inside this guard, preserving the compact -> translator lock order.
[[nodiscard]] std::shared_lock<std::shared_mutex> lock_storage_shared() const {
check_not_in_filter(this);
std::shared_lock gatekeeper_lock{*compact_gatekeeper_};
return std::shared_lock<std::shared_mutex>(*compact_mutex_);
}
Expand All @@ -900,18 +958,28 @@ class MutableVamanaIndex {
return std::shared_lock<std::shared_mutex>(*translator_mutex_);
}

template <bool Filtered = false>
auto greedy_search_closure(
GreedySearchPrefetchParameters prefetch_parameters,
const lib::DefaultPredicate& cancel = lib::Returns(lib::Const<false>())
const lib::DefaultPredicate& cancel = lib::Returns(lib::Const<false>()),
const std::function<bool(size_t)>* filter = nullptr
) const {
return [&, prefetch_parameters](
const auto& query, auto& accessor, auto& distance, auto& buffer
) {
return [&,
prefetch_parameters,
filter](const auto& query, auto& accessor, auto& distance, auto& buffer) {
static_cast<void>(filter); // Unused in regular search.
const Idx seed = entry_point();
if (seed == NO_ENTRY) {
buffer.clear();
return;
}
auto builder = [&] {
if constexpr (Filtered) {
return internal_search_builder(*filter);
} else {
return internal_search_builder();
}
}();
// Perform the greedy search using the provided resources.
concurrent::greedy_search(
graph_,
Expand All @@ -921,7 +989,7 @@ class MutableVamanaIndex {
distance,
buffer,
vamana::EntryPointInitializer<Idx>{std::span<const Idx>{&seed, 1}},
internal_search_builder(),
builder,
prefetch_parameters,
cancel
);
Expand Down Expand Up @@ -957,7 +1025,8 @@ class MutableVamanaIndex {
QueryResultView<I> results,
const Queries& queries,
const search_parameters_type& sp,
const lib::DefaultPredicate& cancel = lib::Returns(lib::Const<false>())
const lib::DefaultPredicate& cancel = lib::Returns(lib::Const<false>()),
const std::function<bool(size_t)>* filter = nullptr
) {
// Internal IDs must retain their meaning until translation has completed.
auto compact_lock = lock_storage_shared();
Expand All @@ -982,6 +1051,25 @@ class MutableVamanaIndex {
auto scratch =
extensions::per_thread_batch_search_setup(data_, distance_);

if (filter != nullptr) {
[&]() __attribute__((noinline)) {
extensions::per_thread_batch_search(
data_,
buffer,
scratch,
queries,
results,
threads::UnitRange{is},
greedy_search_closure<true>(
prefetch_parameters, cancel, filter
),
*this,
cancel
);
}
();
return;
}
extensions::per_thread_batch_search(
data_,
buffer,
Expand All @@ -1005,6 +1093,26 @@ class MutableVamanaIndex {
// After the search procedure, the indices in `results` are internal.
// Perform one more pass to convert these to external ids.
translate_to_external(results.indices());

if (filter != nullptr) {
// A concurrent remap may have changed the answer: re-check the final ids.
const float worst = distance::worst_distance(distance_);
for (size_t i = 0; i < results.n_queries(); ++i) {
for (size_t j = 0; j < results.n_neighbors(); ++j) {
auto id = results.index(i, j);
if (id != static_cast<I>(-1)) {
FilterScope scope{this};
if (!(*filter)(id)) {
results.index(i, j) = static_cast<I>(-1);
}
}
// Also covers ids a concurrent erase left without a translation.
if (results.index(i, j) == static_cast<I>(-1)) {
results.distance(i, j) = worst;
}
}
}
}
}

///
Expand Down Expand Up @@ -1400,6 +1508,7 @@ class MutableVamanaIndex {
/// improve performance but requires more working memory.
///
void compact(Idx batch_size = 1'000) {
check_not_in_filter(this);
std::lock_guard gatekeeper_lock{*compact_gatekeeper_};
std::lock_guard compact_lock{*compact_mutex_};

Expand Down
4 changes: 3 additions & 1 deletion include/svs/concurrent/greedy_search.h
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ void greedy_search(
// Compute distance and update search buffer.
auto dist =
distance::compute(distance_function, query, accessor(dataset, id));
search_buffer.insert(builder(id, dist));
if (!search_buffer.can_skip(dist)) {
search_buffer.insert(builder(id, dist));
}
}

// Validate that no concurrent write occurred during the read.
Expand Down
Loading
Loading