Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions bindings/c/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions bindings/c/include/svs/c_api/svs_c.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -113,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
Expand Down Expand Up @@ -398,6 +413,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,
Expand All @@ -407,6 +426,36 @@ 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
/// @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
/// @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_i 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);
Expand Down
9 changes: 9 additions & 0 deletions bindings/c/include/svs/c_api/svs_c_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions bindings/c/samples/dynamic.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 18 additions & 4 deletions bindings/c/samples/save_load.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion bindings/c/samples/simple.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
232 changes: 232 additions & 0 deletions bindings/c/src/filtered_search.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*
* 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 <svs/concepts/data.h>
#include <svs/core/query_result.h>
#include <svs/lib/misc.h>
#include <svs/lib/threads.h>

#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstddef>
#include <functional>
#include <limits>
#include <stdexcept>

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);
assert(goal > 0);
if (total == 0 || hits == 0 || hits >= goal) {
return std::min(hint, limit);
}
const auto hit_rate_inv = static_cast<float>(total) / static_cast<float>(hits);
size_t estimated = static_cast<size_t>(static_cast<float>(goal - hits) * hit_rate_inv);
estimated = std::max(estimated, size_t{1});
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) {
return true;
}
const auto hit_rate = static_cast<float>(hits) / static_cast<float>(total);
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<size_t()> 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) {
// 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 =
std::max({goal, min_sample_size, static_cast<size_t>(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<size_t>(-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);
Comment on lines +113 to +118

@rfsaliev rfsaliev Jul 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As expected: if actual 'sampled' filter rate is less than client's estimation, search should be interrupted to avoid too high latency.
See filter_stop approach in C++ Runtime.

}

/// @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<size_t>& 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<size_t> empty_neighbor{
static_cast<size_t>(-1), std::numeric_limits<float>::infinity()};

for (size_t i = neighbor_start; i < result.n_neighbors(); ++i) {
result.set(empty_neighbor, query_index, i);
}
}

/// @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<size_t>& result) {
std::fill(
result.distances().begin(),
result.distances().end(),
std::numeric_limits<float>::infinity()
);
std::fill(result.indices().begin(), result.indices().end(), static_cast<size_t>(-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 <typename IndexType>
void filtered_topk_search(
IndexType& index,
svs::QueryResult<size_t>& results,
svs::data::ConstSimpleDataView<float> queries,
size_t initial_batch_hint,
const IDFilterInterface* id_filter,
const std::function<size_t()>& 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,
num_neighbors,
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;
}
Comment thread
rfsaliev marked this conversation as resolved.
} 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
Loading
Loading