From ca8c736d70215d82e5535fb70cd20c290de415d5 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Wed, 15 Jul 2026 08:57:37 -0700 Subject: [PATCH 1/6] Add support for filtered TopK search in C API - Introduced `svs_id_filter_interface` to define filtering operations. - Implemented `svs_index_search_topK` to support an optional ID filter for search operations. - Updated existing search functions to use the new filtered search capabilities. - Added a new source file `filtered_search.hpp` containing the logic for filtered top-K search. - Modified existing samples and tests to demonstrate and validate the new filtering functionality. - Marked the previous `svs_index_search` function as deprecated, directing users to use `svs_index_search_topK` instead. --- bindings/c/CMakeLists.txt | 1 + bindings/c/include/svs/c_api/svs_c.h | 37 ++++ bindings/c/include/svs/c_api/svs_c_config.h | 9 + bindings/c/samples/dynamic.c | 8 +- bindings/c/samples/save_load.c | 22 +- bindings/c/samples/simple.c | 4 +- bindings/c/src/filtered_search.hpp | 201 +++++++++++++++++ bindings/c/src/index.hpp | 64 +++++- bindings/c/src/svs_c.cpp | 23 +- bindings/c/src/types_support.hpp | 42 ++++ bindings/c/tests/c_api_dynamic_index.cpp | 172 ++++++++++++++- bindings/c/tests/c_api_index.cpp | 228 ++++++++++++++++++-- 12 files changed, 780 insertions(+), 31 deletions(-) create mode 100644 bindings/c/src/filtered_search.hpp diff --git a/bindings/c/CMakeLists.txt b/bindings/c/CMakeLists.txt index dd9847d2..cb772737 100644 --- a/bindings/c/CMakeLists.txt +++ b/bindings/c/CMakeLists.txt @@ -24,6 +24,7 @@ set(SVS_C_API_HEADERS set(SVS_C_API_SOURCES src/algorithm.hpp src/error.hpp + src/filtered_search.hpp src/index.hpp src/index_builder.hpp src/storage.hpp diff --git a/bindings/c/include/svs/c_api/svs_c.h b/bindings/c/include/svs/c_api/svs_c.h index 7fd397d0..0e4c3138 100644 --- a/bindings/c/include/svs/c_api/svs_c.h +++ b/bindings/c/include/svs/c_api/svs_c.h @@ -89,6 +89,20 @@ struct svs_threadpool_interface { void* self; }; +struct svs_id_filter_interface_ops { + bool (*is_member)(void* self, size_t id); +}; + +struct svs_id_filter_interface { + struct svs_id_filter_interface_ops ops; + void* self; + // filter_rate provides the estimated selectivity of the filter, i.e., the fraction of + // IDs that are expected to pass the filter. A value of 0.01 indicates that 1% of IDs + // are expected to pass, while a value of 1.0 indicates that all IDs are expected to + // pass. If the filter does not provide an estimate, it should be set to 0.0. + float filter_rate; +}; + /// @brief Structure to hold search results struct svs_search_results { size_t num_queries; /// Number of query vectors @@ -398,6 +412,10 @@ SVS_API void svs_index_free(svs_index_h index); /// @param search_params The search parameters handle (can be NULL for defaults) /// @param out_err An optional error handle to capture errors /// @return A pointer to the search results structure +/// @deprecated Use svs_index_search_topK() instead, which additionally supports an +/// optional ID filter. This function is equivalent to calling svs_index_search_topK() +/// with a NULL id_filter. +SVS_DEPRECATED("Use svs_index_search_topK() instead") SVS_API svs_search_results_t svs_index_search( svs_index_h index, const float* queries, @@ -407,6 +425,25 @@ SVS_API svs_search_results_t svs_index_search( svs_error_h out_err /*=NULL*/ ); +/// @brief TopK search the index with the provided queries and an optional ID filter +/// @param index The index handle +/// @param queries Pointer to the query data (float array) +/// @param num_queries The number of query vectors +/// @param k The number of nearest neighbors to retrieve per query +/// @param search_params The search parameters handle (can be NULL for defaults) +/// @param id_filter The ID filter interface (can be NULL for no filtering) +/// @param out_err An optional error handle to capture errors +/// @return A pointer to the search results structure +SVS_API svs_search_results_t svs_index_search_topK( + svs_index_h index, + const float* queries, + size_t num_queries, + size_t k, + svs_search_params_h search_params /*=NULL*/, + svs_id_filter_interface* id_filter /*=NULL*/, + svs_error_h out_err /*=NULL*/ +); + /// @brief Free the search results structure /// @param results The search results structure to release SVS_API void svs_search_results_free(svs_search_results_t results); diff --git a/bindings/c/include/svs/c_api/svs_c_config.h b/bindings/c/include/svs/c_api/svs_c_config.h index 00398585..14de7efb 100644 --- a/bindings/c/include/svs/c_api/svs_c_config.h +++ b/bindings/c/include/svs/c_api/svs_c_config.h @@ -35,3 +35,12 @@ #else #define SVS_API SVS_HELPER_DLL_IMPORT #endif + +// Mark an API as deprecated, optionally providing a message for callers. +#if defined _WIN32 || defined __CYGWIN__ +#define SVS_DEPRECATED(msg) __declspec(deprecated(msg)) +#elif defined __GNUC__ || defined __clang__ +#define SVS_DEPRECATED(msg) __attribute__((deprecated(msg))) +#else +#define SVS_DEPRECATED(msg) +#endif diff --git a/bindings/c/samples/dynamic.c b/bindings/c/samples/dynamic.c index 3de193fe..14a548f7 100644 --- a/bindings/c/samples/dynamic.c +++ b/bindings/c/samples/dynamic.c @@ -205,7 +205,9 @@ int main() { // Search printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); - results = svs_index_search(index, queries, NUM_QUERIES, K, search_params, error); + results = svs_index_search_topK( + index, queries, NUM_QUERIES, K, search_params, NULL /* id_filter */, error + ); if (!results) { fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); ret = 1; @@ -250,7 +252,9 @@ int main() { // Search again after deletion printf("Searching again after deletion...\n"); - results = svs_index_search(index, queries, NUM_QUERIES, K, search_params, error); + results = svs_index_search_topK( + index, queries, NUM_QUERIES, K, search_params, NULL /* id_filter */, error + ); if (!results) { fprintf( stderr, diff --git a/bindings/c/samples/save_load.c b/bindings/c/samples/save_load.c index 43aaf1ab..64518495 100644 --- a/bindings/c/samples/save_load.c +++ b/bindings/c/samples/save_load.c @@ -166,8 +166,15 @@ int main() { // Search printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); - results = - svs_index_search(index, queries, NUM_QUERIES, K, NULL /* search_params */, error); + results = svs_index_search_topK( + index, + queries, + NUM_QUERIES, + K, + NULL /* search_params */, + NULL /* id_filter */, + error + ); if (!results) { fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); ret = 1; @@ -208,8 +215,15 @@ int main() { printf( "Searching loaded index for %d queries for top-%d neighbors...\n", NUM_QUERIES, K ); - loaded_results = - svs_index_search(index, queries, NUM_QUERIES, K, NULL /* search_params */, error); + loaded_results = svs_index_search_topK( + index, + queries, + NUM_QUERIES, + K, + NULL /* search_params */, + NULL /* id_filter */, + error + ); if (!loaded_results) { fprintf( stderr, "Failed to search loaded index: %s\n", svs_error_get_message(error) diff --git a/bindings/c/samples/simple.c b/bindings/c/samples/simple.c index da788465..75a18c13 100644 --- a/bindings/c/samples/simple.c +++ b/bindings/c/samples/simple.c @@ -173,7 +173,9 @@ int main() { // Search printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); - results = svs_index_search(index, queries, NUM_QUERIES, K, search_params, error); + results = svs_index_search_topK( + index, queries, NUM_QUERIES, K, search_params, NULL /* id_filter */, error + ); if (!results) { fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); ret = 1; diff --git a/bindings/c/src/filtered_search.hpp b/bindings/c/src/filtered_search.hpp new file mode 100644 index 00000000..676ae3c6 --- /dev/null +++ b/bindings/c/src/filtered_search.hpp @@ -0,0 +1,201 @@ +/* + * Copyright 2026 Intel Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "svs/c_api/svs_c.h" + +#include "types_support.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace svs::c_runtime { + +inline size_t +estimate_batch_size(size_t total, size_t hits, size_t goal, size_t hint, size_t limit) { + assert(total >= hits); + assert(goal > 0); + if (total == 0 || hits == 0 || hits >= goal) { + return std::min(hint, limit); + } + const auto hit_rate_inv = static_cast(total) / static_cast(hits); + size_t estimated = static_cast(static_cast(goal - hits) * hit_rate_inv); + estimated = std::max(estimated, size_t{1}); + return std::min(estimated, limit); +} + +inline bool hit_rate_sufficient(size_t total, size_t hits, float filter_rate) { + // by default, assume that the hit rate is sufficient + if (filter_rate <= 0.0f || total == 0) { + return true; + } + const auto hit_rate = static_cast(hits) / static_cast(total); + return hit_rate >= filter_rate; +} + +inline size_t estimate_initial_batch_size( + const IDFilterInterface* id_filter, + std::function sample_generator, + size_t min_sample_size, + size_t goal, + size_t hint, + size_t limit +) { + assert(id_filter != nullptr); + const auto filter_rate = id_filter->filter_rate(); + if (filter_rate <= 0.0f) { + return hint; // If filter rate is 0.0 or negative, return the hint as the initial + // batch size + } + + auto sample_size = + std::max({goal, min_sample_size, static_cast(1.f / filter_rate)}); + assert(sample_size > 0); + size_t hits = 0; + for (size_t i = 0; i < sample_size; ++i) { + size_t id = sample_generator(); + // Stop if the sample generator returns an invalid ID + if (id == static_cast(-1)) { + sample_size = i; // Adjust sample size to the number of valid samples + break; + } + if (id_filter->is_member(id)) { + hits++; + } + } + // if hit rate is less than filter_rate, return 0 - which means we should not even start + // the search + if (!hit_rate_sufficient(sample_size, hits, filter_rate)) { + return 0; + } + return estimate_batch_size(sample_size, hits, goal, hint, limit); +} + +inline void +pad_result(svs::QueryResult& result, size_t query_index, size_t neighbor_start) { + assert(query_index < result.n_queries()); + assert(neighbor_start <= result.n_neighbors()); + + static constexpr svs::Neighbor empty_neighbor{ + static_cast(-1), std::numeric_limits::infinity()}; + + for (size_t i = neighbor_start; i < result.n_neighbors(); ++i) { + result.set(empty_neighbor, query_index, i); + } +} + +inline void set_empty_result(svs::QueryResult& result) { + std::fill( + result.distances().begin(), + result.distances().end(), + std::numeric_limits::infinity() + ); + std::fill(result.indices().begin(), result.indices().end(), static_cast(-1)); +} + +// Perform a filtered nearest-neighbor search by iterating over batches of candidates and +// keeping only those that pass the filter. The batch size is estimated adaptively based on +// the observed hit rate. Results are written into `results`. +template +void filtered_topk_search( + IndexType& index, + svs::QueryResult& results, + svs::data::ConstSimpleDataView queries, + size_t initial_batch_hint, + const IDFilterInterface* id_filter, + const std::function& sample_generator +) { + // Minimum number of samples to estimate the filter hit rate. This is a trade-off + // between accuracy and performance. A larger sample size gives a more accurate + // estimate of the filter hit rate, but takes longer to compute. + const size_t MIN_SAMPLE_SIZE = 200; + + // Filtered search: we need to estimate the batch size based on the filter rate and + // the number of hits + const auto num_neighbors = results.n_neighbors(); + const auto index_size = index.size(); + + auto initial_batch_size = estimate_initial_batch_size( + id_filter, + sample_generator, + MIN_SAMPLE_SIZE, + queries.size(), + initial_batch_hint, + index_size + ); + if (initial_batch_size == 0) { + // If the batch size is 0, it means that the filter rate is too low than + // expected and we should not even start the search + set_empty_result(results); + return; + } + + const auto filter_rate = id_filter->filter_rate(); + + auto search_closure = [&](const auto& range, uint64_t SVS_UNUSED(tid)) { + for (auto i : range) { + auto query = queries.get_datum(i); + auto iterator = index.batch_iterator(query); + size_t found = 0; + size_t total_checked = 0; + auto batch_size = initial_batch_size; + do { + batch_size = estimate_batch_size( + total_checked, found, num_neighbors, batch_size, index_size + ); + iterator.next(batch_size); + total_checked += iterator.size(); + for (auto& neighbor : iterator.results()) { + if (id_filter->is_member(neighbor.id())) { + results.set(neighbor, i, found); + found++; + if (found == num_neighbors) { + break; + } + } + } + // TODO: clarify the contract here - should we return partial or no + // result if the hit rate is too low + if (found < num_neighbors && + !hit_rate_sufficient(total_checked, found, filter_rate)) { + found = 0; + break; + } + } while (found < num_neighbors && !iterator.done()); + + // Pad results if not enough neighbors found + pad_result(results, i, found); + } + }; + + svs::threads::parallel_for( + index.get_threadpool_handle(), + svs::threads::StaticPartition{queries.size()}, + search_closure + ); +} + +} // namespace svs::c_runtime diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index c38d0c6d..e6a48bf2 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -18,6 +18,7 @@ #include "svs/c_api/svs_c.h" #include "algorithm.hpp" +#include "filtered_search.hpp" #include "threadpool.hpp" #include @@ -33,6 +34,7 @@ #include namespace svs::c_runtime { + struct Index { svs_algorithm_type algorithm; ThreadPoolBuilder pool_builder; @@ -43,7 +45,8 @@ struct Index { virtual svs::QueryResult search( svs::data::ConstSimpleDataView queries, size_t num_neighbors, - const std::shared_ptr& search_params + const std::shared_ptr& search_params, + const IDFilterInterface* id_filter = nullptr ) = 0; virtual void save(const std::filesystem::path& directory) = 0; virtual size_t dimensions() const = 0; @@ -77,7 +80,8 @@ struct IndexVamana : public Index { svs::QueryResult search( svs::data::ConstSimpleDataView queries, size_t num_neighbors, - const std::shared_ptr& search_params + const std::shared_ptr& search_params, + const IDFilterInterface* id_filter ) override { auto vamana_search_params = std::static_pointer_cast(search_params); @@ -88,7 +92,21 @@ struct IndexVamana : public Index { params = vamana_search_params->get_search_parameters(); } - index.search(results.view(), queries, params); + if (id_filter == nullptr) { + index.search(results.view(), queries, params); + return results; + } + + std::mt19937 rng(42); + std::uniform_int_distribution dist(0, index.size() - 1); + auto sample_generator = [&]() -> size_t { return dist(rng); }; + + auto batch_hint = + std::max(num_neighbors, params.buffer_config_.get_search_window_size()); + + filtered_topk_search( + index, results, queries, batch_hint, id_filter, sample_generator + ); return results; } @@ -117,15 +135,24 @@ struct IndexVamana : public Index { struct DynamicIndexVamana : public DynamicIndex { svs::DynamicVamana index; + size_t max_id = 0; // Track the maximum ID added to the index DynamicIndexVamana(svs::DynamicVamana&& index, ThreadPoolBuilder pool_builder) : DynamicIndex(SVS_ALGORITHM_TYPE_VAMANA, pool_builder) - , index(std::move(index)) {} + , index(std::move(index)) { + auto all_ids = this->index.all_ids(); + assert( + !all_ids.empty() && + "DynamicVamana index should have at least one ID after construction." + ); + max_id = all_ids.empty() ? 0 : *std::max_element(all_ids.begin(), all_ids.end()); + } ~DynamicIndexVamana() = default; svs::QueryResult search( svs::data::ConstSimpleDataView queries, size_t num_neighbors, - const std::shared_ptr& search_params + const std::shared_ptr& search_params, + const IDFilterInterface* id_filter ) override { auto vamana_search_params = std::static_pointer_cast(search_params); @@ -136,7 +163,30 @@ struct DynamicIndexVamana : public DynamicIndex { params = vamana_search_params->get_search_parameters(); } - index.search(results.view(), queries, params); + if (id_filter == nullptr) { + index.search(results.view(), queries, params); + return results; + } + + std::mt19937 rng(42); + std::uniform_int_distribution dist(0, max_id); + auto sample_generator = [&]() -> size_t { + static constexpr size_t max_attempts = 4; + for (size_t attempt = 0; attempt < max_attempts; ++attempt) { + size_t id = dist(rng); + if (index.has_id(id)) { + return id; + } + } + return static_cast(-1); // Return an invalid ID if no valid ID is found + }; + + auto batch_hint = + std::max(num_neighbors, params.buffer_config_.get_search_window_size()); + + filtered_topk_search( + index, results, queries, batch_hint, id_filter, sample_generator + ); return results; } @@ -149,6 +199,8 @@ struct DynamicIndexVamana : public DynamicIndex { size_t add_points( svs::data::ConstSimpleDataView new_points, std::span ids ) override { + // Track the maximum ID added to the index for ids generator + max_id = std::max(max_id, *std::max_element(ids.begin(), ids.end())); auto old_size = index.size(); index.add_points(new_points, ids); // TODO: This is a bit of a hack - we should ideally return the number of points diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 85f2fa3e..6a22219c 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -564,6 +564,22 @@ extern "C" svs_search_results_t svs_index_search( size_t k, svs_search_params_h search_params, svs_error_h out_err +) { + // Deprecated: delegate to svs_index_search_topK without an ID filter to avoid + // duplicating the search and result-marshalling logic. + return svs_index_search_topK( + index, queries, num_queries, k, search_params, nullptr, out_err + ); +} + +extern "C" svs_search_results_t svs_index_search_topK( + svs_index_h index, + const float* queries, + size_t num_queries, + size_t k, + svs_search_params_h search_params, + svs_id_filter_interface* id_filter, + svs_error_h out_err ) { using namespace svs::c_runtime; return wrap_exceptions( @@ -579,8 +595,13 @@ extern "C" svs_search_results_t svs_index_search( queries, num_queries, index_ptr->dimensions() ); + IDFilterAdapter id_filter_adapter(id_filter); + auto search_results = index_ptr->search( - queries_view, k, search_params == nullptr ? nullptr : search_params->impl + queries_view, + k, + search_params == nullptr ? nullptr : search_params->impl, + id_filter == nullptr ? nullptr : &id_filter_adapter ); svs_search_results_t results = diff --git a/bindings/c/src/types_support.hpp b/bindings/c/src/types_support.hpp index b3f9b38b..e67e074e 100644 --- a/bindings/c/src/types_support.hpp +++ b/bindings/c/src/types_support.hpp @@ -55,5 +55,47 @@ inline svs::DataType to_data_type(svs_data_type_t data_type) { } } +struct IDFilterInterface { + virtual ~IDFilterInterface() = default; + virtual bool is_member(size_t id) const = 0; + // filter_rate() returns the estimated selectivity of the filter, i.e., the fraction of + // IDs that are expected to pass the filter. A value of 0.01 indicates that 1% of IDs + // are expected to pass, while a value of 1.0 indicates that all IDs are expected to + // pass. If the filter does not provide an estimate, it should return 0.0. + virtual float filter_rate() const = 0; + bool operator()(size_t id) const { return is_member(id); } +}; + +struct IDFilterAdapter : public IDFilterInterface { + const svs_id_filter_interface* c_filter; + + IDFilterAdapter(const svs_id_filter_interface* filter) + : c_filter(filter) { + if (c_filter != nullptr) { + const auto rate = c_filter->filter_rate; + if (rate < 0.0f || rate > 1.0f) { + throw std::invalid_argument( + "Filter rate must be between 0.0 and 1.0, inclusive." + ); + } + } + } + + bool is_member(size_t id) const override { + if (c_filter == nullptr || c_filter->ops.is_member == nullptr) { + return true; // If no filter is provided, consider all IDs as valid + } + return c_filter->ops.is_member(c_filter->self, id); + } + + float filter_rate() const override { + // If no filter is provided or the filter rate is NaN, return 0.0 + if (c_filter == nullptr || std::isnan(c_filter->filter_rate)) { + return 0.0f; // If no filter is provided, return 0.0 + } + return c_filter->filter_rate; + } +}; + } // namespace c_runtime } // namespace svs diff --git a/bindings/c/tests/c_api_dynamic_index.cpp b/bindings/c/tests/c_api_dynamic_index.cpp index 050726c3..3a7136f0 100644 --- a/bindings/c/tests/c_api_dynamic_index.cpp +++ b/bindings/c/tests/c_api_dynamic_index.cpp @@ -277,7 +277,7 @@ CATCH_TEST_CASE("C API Dynamic Index", "[c_api][index][dynamic]") { generate_test_data(queries, 2, DIMENSION); svs_search_results_t results = - svs_index_search(index, queries.data(), 2, K, nullptr, error); + svs_index_search_topK(index, queries.data(), 2, K, nullptr, nullptr, error); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == 2); @@ -343,8 +343,9 @@ CATCH_TEST_CASE("C API Dynamic Index", "[c_api][index][dynamic]") { std::vector queries; generate_test_data(queries, 2, DIMENSION); - svs_search_results_t results = - svs_index_search(loaded_index, queries.data(), 2, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + loaded_index, queries.data(), 2, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == 2); @@ -358,3 +359,168 @@ CATCH_TEST_CASE("C API Dynamic Index", "[c_api][index][dynamic]") { svs_algorithm_free(algorithm); svs_error_free(error); } + +namespace { + +// ID filter callback: accepts odd IDs only (~50% selectivity). +bool filter_is_odd(void* /*self*/, size_t id) { return (id % 2) == 1; } + +// ID filter callback: accepts IDs strictly below the threshold stored in `self`. +// Used to model a restrictive, low-selectivity filter. +bool filter_below_threshold(void* self, size_t id) { + return id < *static_cast(self); +} + +} // namespace + +CATCH_TEST_CASE( + "C API Dynamic Filtered Search topK", "[c_api][index][dynamic][search][filter]" +) { + const size_t NUM_VECTORS = 1000; + const size_t NUM_QUERIES = 5; + const size_t DIMENSION = 32; + const size_t K = 10; + const size_t NUM_THREADS = 4; + const size_t BLOCK_SIZE = 1024 * 1024; // 1 MB block size for testing + + std::vector data; + std::vector queries; + std::vector ids(NUM_VECTORS); + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, NUM_QUERIES, DIMENSION); + for (size_t i = 0; i < NUM_VECTORS; ++i) { + ids[i] = i; + } + + CATCH_SECTION("Normal filter for odd IDs") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_params_h search_params = svs_search_params_create_vamana(50, error); + CATCH_REQUIRE(search_params != nullptr); + + // ~50% of the IDs pass the filter. Provide a conservative filter_rate estimate + // (below the true selectivity) so the search is not short-circuited. + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_is_odd; + id_filter.self = nullptr; + id_filter.filter_rate = 0.4f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Every neighbor must be a valid, in-range odd ID. + CATCH_REQUIRE(idx != static_cast(-1)); + CATCH_REQUIRE(idx < NUM_VECTORS); + CATCH_REQUIRE((idx % 2) == 1); + // Distances must be finite and non-decreasing. + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + if (j > 0) { + CATCH_REQUIRE( + results->distances[q * K + j] >= results->distances[q * K + j - 1] + ); + } + } + } + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + CATCH_SECTION("Low-rate (restrictive) filter") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_params_h search_params = svs_search_params_create_vamana(100, error); + CATCH_REQUIRE(search_params != nullptr); + + // Only the lowest 10% of the IDs pass the filter. Provide a conservative + // filter_rate (below the true selectivity) so the search keeps iterating instead + // of giving up early. + size_t max_valid_id = NUM_VECTORS / 10; + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_below_threshold; + id_filter.self = &max_valid_id; + id_filter.filter_rate = 0.05f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + size_t total_found = 0; + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Padding (unspecified) entries are allowed for a restrictive filter, but + // any specified neighbor must pass the filter predicate. + if (idx != static_cast(-1)) { + CATCH_REQUIRE(idx < max_valid_id); + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + ++total_found; + } + } + } + // The restrictive filter still has plenty of matching vectors, so the search must + // return at least some valid neighbors. + CATCH_REQUIRE(total_found > 0); + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } +} diff --git a/bindings/c/tests/c_api_index.cpp b/bindings/c/tests/c_api_index.cpp index 07dce2c4..dd8849ac 100644 --- a/bindings/c/tests/c_api_index.cpp +++ b/bindings/c/tests/c_api_index.cpp @@ -71,8 +71,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(svs_error_ok(error)); // Perform search - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, search_params, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); @@ -126,8 +127,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(index != nullptr); // Search without explicit search parameters (uses defaults) - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == NUM_QUERIES); @@ -165,8 +167,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); CATCH_REQUIRE(index != nullptr); - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(results->num_queries == NUM_QUERIES); @@ -209,8 +212,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(index != nullptr); CATCH_REQUIRE(svs_error_ok(error)); - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == NUM_QUERIES); @@ -267,10 +271,49 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") CATCH_REQUIRE(svs_error_ok(error)); // Verify index works with custom threadpool + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_free(results); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + CATCH_SECTION("Deprecated svs_index_search wrapper") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + // Intentionally exercise the deprecated API to ensure the wrapper still delegates + // correctly to svs_index_search_topK. +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif svs_search_results_t results = svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, error); +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); svs_search_results_free(results); svs_index_free(index); @@ -376,8 +419,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") size_t k_values[] = {1, 5, 10, 20}; for (size_t i = 0; i < sizeof(k_values) / sizeof(k_values[0]); ++i) { size_t k = k_values[i]; - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, k, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, k, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(results->num_queries == NUM_QUERIES); @@ -412,8 +456,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") // Perform multiple searches for (size_t i = 0; i < 3; ++i) { - svs_search_results_t results = - svs_index_search(index, queries.data(), NUM_QUERIES, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == NUM_QUERIES); @@ -471,8 +516,9 @@ CATCH_TEST_CASE("C API Index Build and Search", "[c_api][index][build][search]") std::vector queries; generate_test_data(queries, 2, DIMENSION); - svs_search_results_t results = - svs_index_search(loaded_index, queries.data(), 2, K, nullptr, error); + svs_search_results_t results = svs_index_search_topK( + loaded_index, queries.data(), 2, K, nullptr, nullptr, error + ); CATCH_REQUIRE(results != nullptr); CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results->num_queries == 2); @@ -715,3 +761,157 @@ CATCH_TEST_CASE("C API Threadpool Management", "[c_api][index][threadpool]") { svs_error_free(error); } } + +namespace { + +// ID filter callback: accepts odd IDs only (~50% selectivity). +bool filter_is_odd(void* /*self*/, size_t id) { return (id % 2) == 1; } + +// ID filter callback: accepts IDs strictly below the threshold stored in `self`. +// Used to model a restrictive, low-selectivity filter. +bool filter_below_threshold(void* self, size_t id) { + return id < *static_cast(self); +} + +} // namespace + +CATCH_TEST_CASE("C API Filtered Search topK", "[c_api][index][search][filter]") { + const size_t NUM_VECTORS = 1000; + const size_t NUM_QUERIES = 5; + const size_t DIMENSION = 32; + const size_t K = 10; + const size_t NUM_THREADS = 4; + + std::vector data; + std::vector queries; + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, NUM_QUERIES, DIMENSION); + + CATCH_SECTION("Normal filter for odd IDs") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_params_h search_params = svs_search_params_create_vamana(50, error); + CATCH_REQUIRE(search_params != nullptr); + + // ~50% of the IDs pass the filter. Provide a conservative filter_rate estimate + // (below the true selectivity) so the search is not short-circuited. + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_is_odd; + id_filter.self = nullptr; + id_filter.filter_rate = 0.4f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Every neighbor must be a valid, in-range odd ID. + CATCH_REQUIRE(idx != static_cast(-1)); + CATCH_REQUIRE(idx < NUM_VECTORS); + CATCH_REQUIRE((idx % 2) == 1); + // Distances must be finite and non-decreasing. + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + if (j > 0) { + CATCH_REQUIRE( + results->distances[q * K + j] >= results->distances[q * K + j - 1] + ); + } + } + } + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + + CATCH_SECTION("Low-rate (restrictive) filter") { + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_NATIVE, NUM_THREADS, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_params_h search_params = svs_search_params_create_vamana(100, error); + CATCH_REQUIRE(search_params != nullptr); + + // Only the lowest 10% of the IDs pass the filter. Provide a conservative + // filter_rate (below the true selectivity) so the search keeps iterating instead + // of giving up early. + size_t max_valid_id = NUM_VECTORS / 10; + svs_id_filter_interface id_filter{}; + id_filter.ops.is_member = &filter_below_threshold; + id_filter.self = &max_valid_id; + id_filter.filter_rate = 0.05f; + + svs_search_results_t results = svs_index_search_topK( + index, queries.data(), NUM_QUERIES, K, search_params, &id_filter, error + ); + CATCH_REQUIRE(results != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results->num_queries == NUM_QUERIES); + + size_t total_found = 0; + for (size_t q = 0; q < NUM_QUERIES; ++q) { + CATCH_REQUIRE(results->results_per_query[q] == K); + for (size_t j = 0; j < K; ++j) { + size_t idx = results->indices[q * K + j]; + // Padding (unspecified) entries are allowed for a restrictive filter, but + // any specified neighbor must pass the filter predicate. + if (idx != static_cast(-1)) { + CATCH_REQUIRE(idx < max_valid_id); + CATCH_REQUIRE(std::isfinite(results->distances[q * K + j])); + ++total_found; + } + } + } + // The restrictive filter still has plenty of matching vectors, so the search must + // return at least some valid neighbors. + CATCH_REQUIRE(total_found > 0); + + svs_search_results_free(results); + svs_search_params_free(search_params); + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } +} From 760439e6b100cc5963b1266bf370acf544d89649 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Thu, 16 Jul 2026 03:50:20 -0700 Subject: [PATCH 2/6] Refactor id_filter interface type to use typedef for improved clarity --- bindings/c/include/svs/c_api/svs_c.h | 3 ++- bindings/c/src/svs_c.cpp | 2 +- bindings/c/src/types_support.hpp | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bindings/c/include/svs/c_api/svs_c.h b/bindings/c/include/svs/c_api/svs_c.h index 0e4c3138..072a0286 100644 --- a/bindings/c/include/svs/c_api/svs_c.h +++ b/bindings/c/include/svs/c_api/svs_c.h @@ -127,6 +127,7 @@ typedef enum svs_data_type svs_data_type_t; typedef enum svs_threadpool_kind svs_threadpool_kind_t; typedef struct svs_threadpool_interface* svs_threadpool_i; +typedef struct svs_id_filter_interface* svs_id_filter_i; typedef struct svs_search_results* svs_search_results_t; /// @brief Create an error handle @@ -440,7 +441,7 @@ SVS_API svs_search_results_t svs_index_search_topK( size_t num_queries, size_t k, svs_search_params_h search_params /*=NULL*/, - svs_id_filter_interface* id_filter /*=NULL*/, + svs_id_filter_i id_filter /*=NULL*/, svs_error_h out_err /*=NULL*/ ); diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 6a22219c..8e68ea40 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -578,7 +578,7 @@ extern "C" svs_search_results_t svs_index_search_topK( size_t num_queries, size_t k, svs_search_params_h search_params, - svs_id_filter_interface* id_filter, + svs_id_filter_i id_filter, svs_error_h out_err ) { using namespace svs::c_runtime; diff --git a/bindings/c/src/types_support.hpp b/bindings/c/src/types_support.hpp index e67e074e..5b261324 100644 --- a/bindings/c/src/types_support.hpp +++ b/bindings/c/src/types_support.hpp @@ -67,9 +67,9 @@ struct IDFilterInterface { }; struct IDFilterAdapter : public IDFilterInterface { - const svs_id_filter_interface* c_filter; + const svs_id_filter_i c_filter; - IDFilterAdapter(const svs_id_filter_interface* filter) + IDFilterAdapter(const svs_id_filter_i filter) : c_filter(filter) { if (c_filter != nullptr) { const auto rate = c_filter->filter_rate; From f31c43abed17a706c26a447e7160cb1cbde97f96 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Thu, 16 Jul 2026 07:10:48 -0700 Subject: [PATCH 3/6] Code review fixes --- bindings/c/src/filtered_search.hpp | 7 ++++--- bindings/c/src/index.hpp | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bindings/c/src/filtered_search.hpp b/bindings/c/src/filtered_search.hpp index 676ae3c6..985dc731 100644 --- a/bindings/c/src/filtered_search.hpp +++ b/bindings/c/src/filtered_search.hpp @@ -67,8 +67,9 @@ inline size_t estimate_initial_batch_size( assert(id_filter != nullptr); const auto filter_rate = id_filter->filter_rate(); if (filter_rate <= 0.0f) { - return hint; // If filter rate is 0.0 or negative, return the hint as the initial - // batch size + // If filter rate is 0.0 or negative, return the `hint` as the initial batch size - + // clamped to `limit` to avoid oversizing. + return std::min(hint, limit); } auto sample_size = @@ -142,7 +143,7 @@ void filtered_topk_search( id_filter, sample_generator, MIN_SAMPLE_SIZE, - queries.size(), + num_neighbors, initial_batch_hint, index_size ); diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index e6a48bf2..ac1fba17 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -30,6 +30,7 @@ #include #include +#include #include #include From 3940e4cbe4db05cb385454ad0fb110a983c3c890 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Thu, 16 Jul 2026 09:21:26 -0700 Subject: [PATCH 4/6] Enhance documentation for svs_index_search_topk to clarify ID filter functionality and memory management --- bindings/c/include/svs/c_api/svs_c.h | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bindings/c/include/svs/c_api/svs_c.h b/bindings/c/include/svs/c_api/svs_c.h index 072a0286..14298ab0 100644 --- a/bindings/c/include/svs/c_api/svs_c.h +++ b/bindings/c/include/svs/c_api/svs_c.h @@ -427,6 +427,17 @@ SVS_API svs_search_results_t svs_index_search( ); /// @brief TopK search the index with the provided queries and an optional ID filter +/// @details Performs a TopK search on the index with the provided queries and an optional +/// ID filter. The ID filter allows for filtering the search results based on specific IDs, +/// enabling more targeted searches. If the ID filter is NULL, the search will return the +/// top K results. If ID filter is provided, only the results that pass the filter will be +/// returned. The function returns a pointer to the search results structure, which contains +/// the indices and distances of the nearest neighbors for each query. If ID filter is +/// provided with `filter_rate > 0.0` then the function will account for the actual filter +/// hit rate during the search. If the actual observed filter hit rate is less than the +/// provided `filter_rate` value, the function returns an empty result set. +/// @note The search results structure must be freed using svs_search_results_free() to +/// avoid memory leaks. /// @param index The index handle /// @param queries Pointer to the query data (float array) /// @param num_queries The number of query vectors From dc0e1a4e29394a2357441a5cbdd8a4cf04c454d4 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Fri, 17 Jul 2026 03:24:35 -0700 Subject: [PATCH 5/6] Track minimum and maximum IDs in DynamicIndexVamana for improved ID management during sampling --- bindings/c/src/index.hpp | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index ac1fba17..2a330dc8 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -136,6 +136,7 @@ struct IndexVamana : public Index { struct DynamicIndexVamana : public DynamicIndex { svs::DynamicVamana index; + size_t min_id = 0; // Track the minimum ID added to the index size_t max_id = 0; // Track the maximum ID added to the index DynamicIndexVamana(svs::DynamicVamana&& index, ThreadPoolBuilder pool_builder) : DynamicIndex(SVS_ALGORITHM_TYPE_VAMANA, pool_builder) @@ -145,7 +146,9 @@ struct DynamicIndexVamana : public DynamicIndex { !all_ids.empty() && "DynamicVamana index should have at least one ID after construction." ); - max_id = all_ids.empty() ? 0 : *std::max_element(all_ids.begin(), all_ids.end()); + auto [min_it, max_it] = std::minmax_element(all_ids.begin(), all_ids.end()); + min_id = (min_it == all_ids.end()) ? 0 : *min_it; + max_id = (max_it == all_ids.end()) ? 0 : *max_it; } ~DynamicIndexVamana() = default; @@ -170,9 +173,21 @@ struct DynamicIndexVamana : public DynamicIndex { } std::mt19937 rng(42); - std::uniform_int_distribution dist(0, max_id); + std::uniform_int_distribution dist(min_id, max_id); + // DynamicVamana index IDs provided by user and may have any values and gaps, so we + // need to sample until we find a valid ID. + // The most reliable way would be get all IDs and sample from them, but that may be + // expensive for large indexes. So we sample from the range of IDs and check if they + // exist in the index. If not, we sample again. We limit the number of attempts to + // avoid infinite loops in case of sparse IDs. The maximum number of + // attempts is set to the ratio of the ID range to the index size, or at least 4 + // attempts. This ensures that we have a reasonable chance of finding a valid ID + // without excessive sampling. + // Note: (index.size() + 1) - to avoid division by zero in case the index is empty. + const size_t max_attempts = + std::max((max_id - min_id) / (index.size() + 1), size_t{4}); + auto sample_generator = [&]() -> size_t { - static constexpr size_t max_attempts = 4; for (size_t attempt = 0; attempt < max_attempts; ++attempt) { size_t id = dist(rng); if (index.has_id(id)) { @@ -201,7 +216,13 @@ struct DynamicIndexVamana : public DynamicIndex { svs::data::ConstSimpleDataView new_points, std::span ids ) override { // Track the maximum ID added to the index for ids generator - max_id = std::max(max_id, *std::max_element(ids.begin(), ids.end())); + auto [min_it, max_it] = std::minmax_element(ids.begin(), ids.end()); + if (min_it != ids.end()) { + min_id = std::min(min_id, *min_it); + } + if (max_it != ids.end()) { + max_id = std::max(max_id, *max_it); + } auto old_size = index.size(); index.add_points(new_points, ids); // TODO: This is a bit of a hack - we should ideally return the number of points From 203bcd36f4e4c7d6886e4733f588eff833626ee8 Mon Sep 17 00:00:00 2001 From: Rafik Saliev Date: Fri, 17 Jul 2026 03:56:06 -0700 Subject: [PATCH 6/6] Add documenting comments to filered search routines --- bindings/c/src/filtered_search.hpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bindings/c/src/filtered_search.hpp b/bindings/c/src/filtered_search.hpp index 985dc731..b75f4e69 100644 --- a/bindings/c/src/filtered_search.hpp +++ b/bindings/c/src/filtered_search.hpp @@ -34,6 +34,14 @@ namespace svs::c_runtime { +/// @brief Estimate the batch size for filtered search based on the number of total +/// candidates, hits, goal, hint, and limit. +/// @param total The total number of candidates. +/// @param hits The number of filter hits. +/// @param goal The target number of hits to achieve. +/// @param hint A hint for the batch size - usually based on prior knowledge. +/// @param limit The maximum allowed batch size. E.g. index size. +/// @return The estimated batch size. inline size_t estimate_batch_size(size_t total, size_t hits, size_t goal, size_t hint, size_t limit) { assert(total >= hits); @@ -47,6 +55,12 @@ estimate_batch_size(size_t total, size_t hits, size_t goal, size_t hint, size_t return std::min(estimated, limit); } +/// @brief Check if the actual hit rate is sufficient based on the minimum required filter +/// rate. +/// @param total The total number of candidates. +/// @param hits The number of filter hits. +/// @param filter_rate The minimum required filter rate. +/// @return True if the hit rate is sufficient, false otherwise. inline bool hit_rate_sufficient(size_t total, size_t hits, float filter_rate) { // by default, assume that the hit rate is sufficient if (filter_rate <= 0.0f || total == 0) { @@ -56,6 +70,15 @@ inline bool hit_rate_sufficient(size_t total, size_t hits, float filter_rate) { return hit_rate >= filter_rate; } +/// @brief Estimate the initial batch size for filtered search based on the actual filter +/// rate by generating sample IDs and filtering them through the ID filter. +/// @param id_filter The ID filter interface. +/// @param sample_generator A function that generates sample IDs. +/// @param min_sample_size The minimum sample size to consider. +/// @param goal The target number of hits to achieve - usually is K (from TopK). +/// @param hint A hint for the batch size - usually based on prior knowledge. +/// @param limit The maximum allowed batch size. E.g. index size. +/// @return The estimated initial batch size, or 0 if the hit rate is insufficient. inline size_t estimate_initial_batch_size( const IDFilterInterface* id_filter, std::function sample_generator, @@ -95,6 +118,10 @@ inline size_t estimate_initial_batch_size( return estimate_batch_size(sample_size, hits, goal, hint, limit); } +/// @brief Pad the result with empty neighbors starting from a specific index. +/// @param result The query result to pad. +/// @param query_index The index of the query within the result. +/// @param neighbor_start The starting index of neighbors to pad. inline void pad_result(svs::QueryResult& result, size_t query_index, size_t neighbor_start) { assert(query_index < result.n_queries()); @@ -108,6 +135,9 @@ pad_result(svs::QueryResult& result, size_t query_index, size_t neighbor } } +/// @brief Set the query result to an empty state, with all distances set to infinity and +/// all indices set to -1. +/// @param result The query result to set as empty. inline void set_empty_result(svs::QueryResult& result) { std::fill( result.distances().begin(),