Skip to content
Open
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
27 changes: 27 additions & 0 deletions docs/docs/en/src/api/dataset.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,30 @@ destructor frees each `vectors_` separately.
- [Index](index_class.md) — the methods that consume and return datasets.
- [Search Request & Filters](search.md) — wrapping a query dataset in a `SearchRequest`.
- [Auxiliary Types](types.md) — `AttributeSet` and attribute value types.

## Per-search distance statistics

Results from maintained HGraph, BruteForce, IVF, Pyramid, SINDI, and SIMQ searches include an
additive `GetStatistics()` contract:

```json
{"distance_evaluations":1164,
"distance_evaluations_by_phase":{"routing":64,"approximate":1000,"rerank":100},
"distance_evaluations_by_backend":{"sq8":1000,"fp32":100,"unknown":0},
"complete":true}
```

One logical query-to-candidate distance or bound evaluation counts once. A batch of `N` counts
`N`; duplicate evaluations count each time; pre-distance rejects, filters, graph edges, prefetches,
heap operations, and skipped lower bounds count zero. A lower bound and later exact rerank count
separately. Phases are `routing`, `approximate`, and `rerank`; backends are stable representation
families such as `fp32`, `fp16`, `bf16`, `int8`, `sq8`, `sq4`, `pq`, `pq_fastscan`, `rabitq`,
`binary`, and sparse families, never ISA or batch variants.

The total equals the phase sum. Known backend values equal the total; unknown work is in `unknown`
and sets `complete` to `false`. Values are unsigned 64-bit JSON integers with saturating addition.
Legacy `dist_cmp` and `reorder_distance_count` remain available unchanged for compatibility.
Python preserves `(ids, distances)` tuple unpacking. C preserves `SearchResult_t`; call
`vsag_search_result_enable_statistics()` before a search and release returned statistics with
`vsag_search_result_destroy_statistics()`. Legacy callers retain `other_result` ownership and
incur no statistics allocation. Legacy HNSW and DiskANN are explicit non-goals.
17 changes: 17 additions & 0 deletions docs/docs/zh/src/api/dataset.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,20 @@ struct MultiVector {
- [Index](index_class.md) —— 消费并返回 dataset 的方法。
- [搜索请求与过滤器](search.md) —— 把查询 dataset 包进 `SearchRequest`。
- [辅助类型](types.md) —— `AttributeSet` 与属性值类型。

## 单次搜索距离统计

维护中的 HGraph、BruteForce、IVF、Pyramid、SINDI 和 SIMQ 搜索结果会在 `GetStatistics()` 中附加
统计信息。一次逻辑 query-to-candidate 距离或边界评估计数一次;批量 `N` 个候选计为 `N`,
重复评估每次计数,而距离调用之前被拒绝的候选、过滤检查、图边、预取、堆操作和跳过的下界不计数。
下界与之后的精确重排分别计数。阶段为 `routing`、`approximate`、`rerank`,backend 是 `fp32`、
`fp16`、`bf16`、`int8`、`sq8`、`sq4`、`pq`、`pq_fastscan`、`rabitq`、`binary`
和稀疏表示族,不包含 ISA 或批量变体。

`distance_evaluations` 等于阶段之和;已知 backend 之和等于总数。未知工作记入 `unknown` 并使
`complete` 为 `false`。数值是无符号 64 位 JSON 整数,加法饱和。旧的 `dist_cmp` 与
`reorder_distance_count` 保持兼容且含义不变。Python 保留 `(ids, distances)` 解包方式,C 保留
`SearchResult_t` 布局,统计信息通过新增的显式访问方式提供:C 调用
`vsag_search_result_enable_statistics()` 后获取统计信息,并用
`vsag_search_result_destroy_statistics()` 释放;未选择统计的旧调用保留 `other_result` 所有权,
不会产生统计分配。旧版 HNSW 和 DiskANN 不在此合约内。
12 changes: 12 additions & 0 deletions include/vsag/vsag_c_api.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ typedef struct SearchResult {
void* other_result; /** The other result of the search. */
} SearchResult_t; /** The search result. */

/** Opt in to statistics for the next search writing @p search_result. */
Error_t
vsag_search_result_enable_statistics(SearchResult_t* search_result);

/** Get statistics after a search for which statistics were explicitly enabled. */
const char*
vsag_search_result_get_statistics(const SearchResult_t* search_result);

/** Release statistics owned by @p search_result. */
void
vsag_search_result_destroy_statistics(SearchResult_t* search_result);

/**
* @brief Create a index factory object.
*
Expand Down
33 changes: 33 additions & 0 deletions python_bindings/index_binding.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,33 @@ class Index {
return py::make_tuple(ids, dists);
}

py::tuple
KnnSearchWithStatistics(py::array vector, uint64_t k, std::string& parameters) {
validate_dense_index_kind(dense_vector_kind_, "knn_search_with_statistics");
auto buf = validate_dense_array(vector, dense_vector_kind_, "vector");
auto query = vsag::Dataset::Make();
query->NumElements(1)->Dim(to_int64(static_cast<uint64_t>(buf.shape[0])))->Owner(false);
set_dense_vectors(query, buf, dense_vector_kind_);
py::array_t<int64_t> ids(k);
py::array_t<float> dists(k);
auto ids_view = ids.mutable_unchecked<1>();
auto dists_view = dists.mutable_unchecked<1>();
for (uint64_t i = 0; i < k; ++i) {
ids_view(i) = -1;
dists_view(i) = -1.0F;
}
std::string statistics = "{}";
if (auto result = index_->KnnSearch(query, to_int64(k), parameters); result.has_value()) {
statistics = result.value()->GetStatistics();
const auto count = static_cast<uint64_t>(result.value()->GetDim());
for (uint64_t i = 0; i < k && i < count; ++i) {
ids_view(i) = result.value()->GetIds()[i];
dists_view(i) = result.value()->GetDistances()[i];
}
}
return py::make_tuple(ids, dists, py::str(statistics));
}

py::tuple
SparseKnnSearch(const py::array_t<uint32_t>& index_pointers,
const py::array_t<uint32_t>& indices,
Expand Down Expand Up @@ -613,6 +640,12 @@ bind_index(py::module_& module) {
- The query dtype must match the index dtype declared in the index parameters
- Use numpy.uint16 raw-bit buffers for bfloat16 queries
)pbdoc")
.def("knn_search_with_statistics",
&Index::KnnSearchWithStatistics,
py::arg("vector"),
py::arg("k"),
py::arg("parameters"),
"Dense k-nearest-neighbor search returning (ids, distances, statistics_json).")
.def("knn_search",
&Index::SparseKnnSearch,
py::arg("index_pointers"),
Expand Down
14 changes: 10 additions & 4 deletions src/algorithm/bruteforce/bruteforce.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ BruteForce::KnnSearch(const DatasetPtr& query,
DatasetPtr
BruteForce::SearchWithRequest(const SearchRequest& request) const {
std::shared_lock read_lock(this->global_mutex_);
SearchStatistics statistics;
QueryContext query_context{.stats = &statistics};
Comment thread
wxyucs marked this conversation as resolved.

auto computer = this->make_search_computer(request.query_);

Expand All @@ -390,7 +392,7 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const {
auto radius = is_range ? request.radius_ : std::numeric_limits<float>::max();

if (total_count_.load() == 0) {
return make_empty_result();
return make_empty_result(statistics.Dump());
}

DistHeapPtr heap = nullptr;
Expand Down Expand Up @@ -437,7 +439,7 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const {
for (const auto& pair : label_to_inner_id) {
float dist = 0.0F;
const auto inner_id = pair.second;
this->inner_codes_->Query(&dist, computer, &inner_id, 1);
this->inner_codes_->Query(&dist, computer, &inner_id, 1, &query_context);
reasoning_ctx->SetTrueDistance(inner_id, dist);
}
}
Expand All @@ -453,6 +455,8 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const {

auto search_func = [&](InnerIdType start, InnerIdType end, const DistHeapPtr& cur_heap) {
uint32_t dist_cmp_local = 0;
QueryContext local_query_context = query_context;
local_query_context.stats = nullptr;
for (InnerIdType i = start; i < end; ++i) {
float dist = 0.0F;
if (attr_filter != nullptr and not attr_filter->CheckValid(i)) {
Expand All @@ -462,7 +466,7 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const {
continue;
}
if (ft == nullptr or ft->CheckValid(i)) {
inner_codes_->Query(&dist, computer, &i, 1);
inner_codes_->Query(&dist, computer, &i, 1, &local_query_context);
++dist_cmp_local;
if (reasoning != nullptr) {
reasoning->RecordVisit(i, dist, 0);
Expand All @@ -478,6 +482,8 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const {
}
}
dist_cmp.fetch_add(dist_cmp_local, std::memory_order_relaxed);
statistics.AddDistance(
SearchStatistics::DistancePhase::APPROXIMATE, inner_codes_->backend_, dist_cmp_local);
};

auto count = total_count_.load();
Expand Down Expand Up @@ -530,7 +536,7 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const {
result->Reasoning(reasoning_ctx->GenerateReport());
}

JsonType stats;
auto stats = JsonType::Parse(statistics.Dump());
stats["dist_cmp"].SetInt(dist_cmp.load(std::memory_order_relaxed));
result->Statistics(stats.Dump());

Expand Down
6 changes: 5 additions & 1 deletion src/algorithm/hgraph/hgraph_search.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ HGraph::KnnSearch(const DatasetPtr& query,
return make_empty_dataset_with_stats();
}
if (iter_filter_ctx->IsFirstUsed()) {
ctx.distance_phase = DistanceEvaluationPhase::ROUTING;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Preserve statistics for empty iterator results

In this iterator-search path, a reject-all filter can still perform routing/base distance evaluations but leave search_result empty. The function then returns DatasetImpl::MakeEmptyDataset() at the earlier empty-result branch before the statistics attachment, so the result has an empty statistics string and loses all counts produced under this phase. Attach stats.Dump() to that empty result just as the non-iterator path does.

for (auto i = static_cast<int64_t>(this->route_graphs_.size() - 1); i >= 0; --i) {
auto result = this->search_one_graph(query_data,
this->route_graphs_[i],
Expand All @@ -144,6 +145,7 @@ HGraph::KnnSearch(const DatasetPtr& query,
&ctx);
search_param.ep = result->Top().second;
}
ctx.distance_phase = DistanceEvaluationPhase::APPROXIMATE;
}

search_param.ef = std::max(params.ef_search, k);
Expand Down Expand Up @@ -433,7 +435,7 @@ HGraph::SearchWithRequest(const SearchRequest& request) const {
for (const auto& pair : label_to_inner_id) {
float dist = 0.0F;
const auto inner_id = pair.second;
precise_flatten->Query(&dist, computer, &inner_id, 1);
precise_flatten->Query(&dist, computer, &inner_id, 1, &ctx);
reasoning_ctx->SetTrueDistance(inner_id, dist);
}
ctx.reasoning_ctx = reasoning_ctx.get();
Expand All @@ -453,11 +455,13 @@ HGraph::SearchWithRequest(const SearchRequest& request) const {
auto vt = this->pool_->TakeOne();

const auto* raw_query = get_data(query);
ctx.distance_phase = DistanceEvaluationPhase::ROUTING;
for (auto i = static_cast<int64_t>(this->route_graphs_.size() - 1); i >= 0; --i) {
auto result = this->search_one_graph(
raw_query, this->route_graphs_[i], this->basic_flatten_codes_, search_param, vt, &ctx);
search_param.ep = result->Top().second;
}
ctx.distance_phase = DistanceEvaluationPhase::APPROXIMATE;

FilterPtr ft = this->create_search_filter(request.filter_, params.use_extra_info_filter);

Expand Down
6 changes: 6 additions & 0 deletions src/algorithm/ivf/flat_bucket_searcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ FlatBucketSearcher::Search(BucketIdType bucket_id,
}

bucket->ScanBucketById(dist.data(), computer, bucket_id);
if (param.query_context != nullptr and param.query_context->stats != nullptr and
bucket_size > 0) {
param.query_context->stats->AddDistance(SearchStatistics::DistancePhase::APPROXIMATE,
bucket->backend_,
static_cast<uint64_t>(bucket_size));
}

Filter* attr_ft = nullptr;
size_t tid = 0;
Expand Down
8 changes: 8 additions & 0 deletions src/algorithm/ivf/gno_imi_partition.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ GNOIMIPartition::ClassifyDatasForSearch(const void* datas,
auto* dist_to_t_data = dist_to_t.data();
auto* candidate_s_id_data = candidate_s_id.data();
auto* candidate_s_dist_data = candidate_s_dist.data();
uint64_t distance_evaluations = 0;

matmul(reinterpret_cast<const float*>(datas),
data_centroids_s_.data(),
Expand Down Expand Up @@ -294,6 +295,7 @@ GNOIMIPartition::ClassifyDatasForSearch(const void* datas,

auto cur_bucket_id_global =
static_cast<long>(cur_bucket_id_s) * bucket_count_t_ + cur_bucket_id_t;
++distance_evaluations;
if (heap.size() < buckets_per_data || dist_term_st < heap.top().first) {
heap.emplace(dist_term_st, cur_bucket_id_global);
}
Expand All @@ -308,6 +310,11 @@ GNOIMIPartition::ClassifyDatasForSearch(const void* datas,
heap.pop();
}
}
if (ctx != nullptr and ctx->stats != nullptr) {
ctx->stats->AddDistance(SearchStatistics::DistancePhase::ROUTING,
DistanceEvaluationBackend::FP32,
distance_evaluations);
}
return result;
}

Expand Down Expand Up @@ -422,6 +429,7 @@ GNOIMIPartition::inner_joint_classify_datas(const float* datas,

if (ctx != nullptr and ctx->stats != nullptr) {
ctx->stats->dist_cmp.fetch_add(dist_cmp, std::memory_order_relaxed);
ctx->stats->AddDistance(SearchStatistics::DistancePhase::ROUTING, "fp32", dist_cmp);
Comment thread
wxyucs marked this conversation as resolved.
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/algorithm/ivf/ivf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,10 @@ IVF::route_buckets_only(const DatasetPtr& query,
}
ids[idx] = static_cast<int64_t>(bucket_id);
distances[idx] = dist;
if (ctx.stats != nullptr) {
ctx.stats->AddDistance(SearchStatistics::DistancePhase::ROUTING,
DistanceEvaluationBackend::FP32);
}
}
}

Expand Down Expand Up @@ -1289,6 +1293,7 @@ IVF::SearchWithRequest(const SearchRequest& request) const {
bool is_range = (request.mode_ == SearchMode::RANGE_SEARCH);

auto param = this->create_search_param(request.params_str_, request.filter_);
param.query_context = &ctx;
Comment thread
wxyucs marked this conversation as resolved.

auto query = request.query_;
if (param.disable_bucket_scan) {
Expand Down Expand Up @@ -1345,6 +1350,10 @@ IVF::SearchWithRequest(const SearchRequest& request) const {
auto computer = this->bucket_->FactoryComputer(query_data);
for (const auto& [inner_id, bucket_id, offset_id] : locations) {
float dist = this->bucket_->QueryOneById(computer, bucket_id, offset_id);
if (ctx.stats != nullptr) {
ctx.stats->AddDistance(SearchStatistics::DistancePhase::APPROXIMATE,
this->bucket_->backend_);
}
reasoning_ctx->SetTrueDistance(inner_id, dist);
}
ctx.reasoning_ctx = reasoning_ctx.get();
Expand Down
8 changes: 7 additions & 1 deletion src/algorithm/ivf/ivf_nearest_partition.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ IVFNearestPartition::ClassifyDatas(const void* datas,
std::scoped_lock lock(dist_cmp_reduce_mutex);
// the return value of GetStatistics always has the same length as the input keys, and
// atoi("") returns a `0`.
dist_cmp += std::atoi(search_result->GetStatistics({"dist_cmp"})[0].c_str());
auto route_stats = search_result->GetStatistics({"dist_cmp", "distance_evaluations"});
dist_cmp += std::atoi(route_stats[0].c_str());
if (ctx != nullptr and ctx->stats != nullptr and route_stats.size() > 1) {
ctx->stats->AddDistance(SearchStatistics::DistancePhase::ROUTING,
"fp32",
std::strtoull(route_stats[1].c_str(), nullptr, 10));
}
};
if (thread_pool_ == nullptr) {
for (int64_t i = 0; i < count; ++i) {
Expand Down
11 changes: 9 additions & 2 deletions src/algorithm/simq/simq.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@ SIMQ::KnnSearch(const DatasetPtr& query,
uint64_t coarse_probe_count = 0;
auto coarse_results = coarse_search(
query_mvs[0].vectors_, query_mvs[0].len_, coarse_k, &coarse_dist_cmp, &coarse_probe_count);
stats.AddDistance(SearchStatistics::DistancePhase::ROUTING, "fp32", coarse_dist_cmp);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Use nested HGraph distance evaluations for SIMQ routing

coarse_dist_cmp is populated from the nested HGraph's legacy dist_cmp, but that counter increments only for visited neighbors and omits the entry-point distance computed at the start of every graph traversal. For example, a one-centroid HGraph can evaluate its entry point while returning dist_cmp == 0, so SIMQ reports zero routing evaluations despite doing one. Preserve the legacy SIMQ field, but aggregate the nested result's new distance_evaluations value into this contract.

uint64_t coarse_candidate_count = coarse_results.size();
if (static_cast<int64_t>(coarse_results.size()) > rerank_k) {
coarse_results.resize(rerank_k);
Expand All @@ -766,10 +767,13 @@ SIMQ::KnnSearch(const DatasetPtr& query,
// Single batched Query call (enables MultiRead in MultiVectorDataCell)
if (!batch_ids.empty()) {
std::vector<float> batch_dists(batch_ids.size());
QueryContext query_context{.stats = &stats,
.distance_phase = DistanceEvaluationPhase::RERANK};
mv_codes_->Query(batch_dists.data(),
computer,
batch_ids.data(),
static_cast<InnerIdType>(batch_ids.size()));
static_cast<InnerIdType>(batch_ids.size()),
&query_context);
stats.dist_cmp.fetch_add(static_cast<uint32_t>(batch_ids.size()),
std::memory_order_relaxed);
for (uint64_t i = 0; i < batch_ids.size(); i++) {
Expand Down Expand Up @@ -834,6 +838,7 @@ SIMQ::RangeSearch(const DatasetPtr& query,
uint64_t coarse_probe_count = 0;
auto coarse_results = coarse_search(
query_mvs[0].vectors_, query_mvs[0].len_, coarse_k, &coarse_dist_cmp, &coarse_probe_count);
stats.AddDistance(SearchStatistics::DistancePhase::ROUTING, "fp32", coarse_dist_cmp);
uint64_t coarse_candidate_count = coarse_results.size();
if (static_cast<int64_t>(coarse_results.size()) > rerank_k) {
coarse_results.resize(rerank_k);
Expand All @@ -849,7 +854,9 @@ SIMQ::RangeSearch(const DatasetPtr& query,
continue;
}
float dist = 0.0F;
mv_codes_->Query(&dist, computer, &doc_id, 1);
QueryContext query_context{.stats = &stats,
.distance_phase = DistanceEvaluationPhase::RERANK};
mv_codes_->Query(&dist, computer, &doc_id, 1, &query_context);
++stats.dist_cmp;
if (dist <= radius) {
in_range.emplace_back(dist, doc_id);
Expand Down
Loading
Loading