From eecb1ee2aebadadb426a34546795b20d6848d2c0 Mon Sep 17 00:00:00 2001 From: Xiangyu Wang Date: Mon, 27 Jul 2026 21:05:11 +0800 Subject: [PATCH] feat: expose per-search distance evaluation statistics Signed-off-by: Xiangyu Wang Assisted-by: OpenAI Codex:gpt-5 --- docs/docs/en/src/api/dataset.md | 30 +++ docs/docs/zh/src/api/dataset.md | 20 ++ include/vsag/vsag_c_api.h | 16 ++ python_bindings/index_binding.cpp | 117 ++++++++++ src/algorithm/bruteforce/bruteforce.cpp | 21 +- src/algorithm/hgraph/hgraph_mci_test.cpp | 6 + src/algorithm/hgraph/hgraph_search.cpp | 18 +- src/algorithm/ivf/flat_bucket_searcher.cpp | 6 + src/algorithm/ivf/gno_imi_partition.cpp | 14 ++ src/algorithm/ivf/gno_imi_partition_test.cpp | 21 ++ src/algorithm/ivf/graph_bucket_searcher.cpp | 3 +- src/algorithm/ivf/ivf.cpp | 14 ++ src/algorithm/ivf/ivf_nearest_partition.cpp | 8 +- src/algorithm/pyramid/pyramid_test.cpp | 60 +++++- src/algorithm/simq/simq.cpp | 59 ++++-- src/algorithm/simq/simq.h | 3 +- src/algorithm/sindi/sindi.cpp | 127 ++++++++--- src/algorithm/sindi/sindi.h | 11 +- src/analyzer/sindi_analyzer.cpp | 2 +- src/datacell/bucket_datacell.h | 4 + src/datacell/bucket_datacell_test.cpp | 2 + src/datacell/bucket_interface.h | 2 + src/datacell/flatten_datacell.h | 10 + src/datacell/flatten_interface.h | 1 + src/datacell/multi_vector_datacell.inl | 6 + src/datacell/multi_vector_datacell_test.cpp | 15 +- src/datacell/rabitq_split_datacell.h | 19 ++ src/datacell/sparse_dmq_datacell.cpp | 4 +- src/datacell/sparse_dmq_datacell.h | 1 + src/datacell/sparse_term_datacell.cpp | 22 +- src/datacell/sparse_term_datacell.h | 54 ++++- src/datacell/sparse_term_datacell_test.cpp | 24 ++- src/datacell/sparse_vector_datacell.h | 3 + src/datacell/sparse_vector_datacell.inl | 3 + src/datacell/sparse_vector_datacell_test.cpp | 7 +- src/impl/distance_provider_for_graph.cpp | 27 ++- src/impl/distance_provider_for_graph.h | 6 + src/impl/inner_search_param.h | 2 + src/impl/reorder/flatten_reorder.cpp | 27 ++- src/impl/searcher/basic_searcher.cpp | 4 + src/impl/searcher/mci_searcher.cpp | 3 + src/index/index_impl.h | 43 +++- src/index/index_impl_test.cpp | 14 ++ .../transform_quantizer_test.cpp | 3 + src/query_context.h | 200 ++++++++++++++++++ src/query_context_test.cpp | 90 ++++++++ src/vsag_c_api.cpp | 140 ++++++++++++ src/vsag_c_api_test.cpp | 125 +++++++++++ tests/python/test_hgraph.py | 43 ++++ tests/python/test_sindi.py | 71 +++++++ tests/test_brute_force.cpp | 48 +++++ tests/test_hgraph.cpp | 26 +++ tests/test_hgraph_rabitq_split.cpp | 25 +++ tests/test_ivf.cpp | 9 + tests/test_simq.cpp | 44 ++++ tests/test_sindi.cpp | 23 ++ 56 files changed, 1610 insertions(+), 96 deletions(-) create mode 100644 src/query_context_test.cpp create mode 100644 tests/python/test_sindi.py diff --git a/docs/docs/en/src/api/dataset.md b/docs/docs/en/src/api/dataset.md index 9380427658..f47f95d00f 100644 --- a/docs/docs/en/src/api/dataset.md +++ b/docs/docs/en/src/api/dataset.md @@ -154,3 +154,33 @@ 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. Opt in with +`knn_search_with_statistics`: the dense overload returns one statistics JSON string, while the +sparse CSR overload returns one string per query. `range_search_with_statistics` returns the range +arrays plus one statistics JSON string. 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. diff --git a/docs/docs/zh/src/api/dataset.md b/docs/docs/zh/src/api/dataset.md index 40c1ce3579..0f07e77dab 100644 --- a/docs/docs/zh/src/api/dataset.md +++ b/docs/docs/zh/src/api/dataset.md @@ -151,3 +151,23 @@ 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)` 解包方式;可通过 +`knn_search_with_statistics` 显式获取统计信息:稠密重载返回一个统计 JSON 字符串,稀疏 CSR +重载为每个查询返回一个字符串。`range_search_with_statistics` 返回范围搜索数组及一个统计 +JSON 字符串。C 保留 +`SearchResult_t` 布局,统计信息通过新增的显式访问方式提供:C 调用 +`vsag_search_result_enable_statistics()` 后获取统计信息,并用 +`vsag_search_result_destroy_statistics()` 释放;未选择统计的旧调用保留 `other_result` 所有权, +不会产生统计分配。旧版 HNSW 和 DiskANN 不在此合约内。 diff --git a/include/vsag/vsag_c_api.h b/include/vsag/vsag_c_api.h index 748ffe0822..a1fdd4dffb 100644 --- a/include/vsag/vsag_c_api.h +++ b/include/vsag/vsag_c_api.h @@ -48,6 +48,22 @@ 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. + * The returned pointer remains valid until vsag_search_result_destroy_statistics() is called for + * the same result. Callers must synchronize concurrent get/destroy operations. + */ +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. * diff --git a/python_bindings/index_binding.cpp b/python_bindings/index_binding.cpp index dddc586296..509b17a553 100644 --- a/python_bindings/index_binding.cpp +++ b/python_bindings/index_binding.cpp @@ -352,6 +352,38 @@ class Index { return py::make_tuple(ids, dists); } + py::tuple + KnnSearchWithStatistics(const py::array& vector, uint64_t k, const 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(buf.shape[0])))->Owner(false); + set_dense_vectors(query, buf, dense_vector_kind_); + uint64_t ids_shape[1]{k}; + uint64_t ids_strides[1]{sizeof(int64_t)}; + uint64_t dists_shape[1]{k}; + uint64_t dists_strides[1]{sizeof(float)}; + py::array_t ids(ids_shape, ids_strides); + py::array_t dists(dists_shape, dists_strides); + 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; + } + auto result = index_->KnnSearch(query, to_int64(k), parameters); + if (not result.has_value()) { + throw std::runtime_error(fmt::format("knn search failed: {}", result.error().message)); + } + const auto statistics = result.value()->GetStatistics(); + const auto count = static_cast(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& index_pointers, const py::array_t& indices, @@ -385,6 +417,43 @@ class Index { return py::make_tuple(res_ids, res_dists); } + py::tuple + SparseKnnSearchWithStatistics(const py::array_t& index_pointers, + const py::array_t& indices, + const py::array_t& values, + uint32_t k, + const std::string& parameters) { + auto batch = build_sparse_vectors_from_csr(index_pointers, indices, values); + std::vector shape{batch.num_elements, k}; + py::array_t res_ids(shape); + py::array_t res_dists(shape); + auto ids_view = res_ids.mutable_unchecked<2>(); + auto dists_view = res_dists.mutable_unchecked<2>(); + py::list statistics; + + for (uint32_t i = 0; i < batch.num_elements; ++i) { + for (uint32_t j = 0; j < k; ++j) { + ids_view(i, j) = -1; + dists_view(i, j) = -1.0F; + } + auto query = vsag::Dataset::Make(); + query->Owner(false)->NumElements(1)->SparseVectors(batch.sparse_vectors.data() + i); + auto result = index_->KnnSearch(query, k, parameters); + if (not result.has_value()) { + throw std::runtime_error( + fmt::format("sparse knn search failed: {}", result.error().message)); + } + const auto count = static_cast(result.value()->GetDim()); + for (uint32_t j = 0; j < k and j < count; ++j) { + ids_view(i, j) = result.value()->GetIds()[j]; + dists_view(i, j) = result.value()->GetDistances()[j]; + } + statistics.append(py::str(result.value()->GetStatistics())); + } + + return py::make_tuple(res_ids, res_dists, statistics); + } + py::object RangeSearch(py::array point, float threshold, std::string& parameters) { validate_dense_index_kind(dense_vector_kind_, "range_search"); @@ -413,6 +482,33 @@ class Index { return py::make_tuple(labels, dists); } + py::tuple + RangeSearchWithStatistics(const py::array& point, + float threshold, + const std::string& parameters) { + validate_dense_index_kind(dense_vector_kind_, "range_search_with_statistics"); + auto buf = validate_dense_array(point, dense_vector_kind_, "point"); + auto query = vsag::Dataset::Make(); + query->NumElements(1)->Dim(to_int64(static_cast(buf.shape[0])))->Owner(false); + set_dense_vectors(query, buf, dense_vector_kind_); + + auto result = index_->RangeSearch(query, threshold, parameters); + if (not result.has_value()) { + throw std::runtime_error( + fmt::format("range search failed: {}", result.error().message)); + } + const auto count = static_cast(result.value()->GetDim()); + py::array_t labels(count); + py::array_t dists(count); + auto* labels_data = labels.mutable_data(); + auto* dists_data = dists.mutable_data(); + for (py::ssize_t i = 0; i < count; ++i) { + labels_data[i] = result.value()->GetIds()[i]; + dists_data[i] = result.value()->GetDistances()[i]; + } + return py::make_tuple(labels, dists, py::str(result.value()->GetStatistics())); + } + [[nodiscard]] int64_t GetNumElements() const { return index_->GetNumElements(); @@ -618,6 +714,21 @@ 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_with_statistics", + &Index::SparseKnnSearchWithStatistics, + py::arg("index_pointers"), + py::arg("indices"), + py::arg("values"), + py::arg("k"), + py::arg("parameters"), + "Sparse k-nearest-neighbor search returning " + "(ids, distances, statistics_json_per_query).") .def("knn_search", &Index::SparseKnnSearch, py::arg("index_pointers"), @@ -661,6 +772,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("range_search_with_statistics", + &Index::RangeSearchWithStatistics, + py::arg("point"), + py::arg("threshold"), + py::arg("parameters"), + "Dense range search returning (ids, distances, statistics_json).") .def("save", &Index::Save, py::arg("filename"), diff --git a/src/algorithm/bruteforce/bruteforce.cpp b/src/algorithm/bruteforce/bruteforce.cpp index a6e6a510fe..fb5fa9c085 100644 --- a/src/algorithm/bruteforce/bruteforce.cpp +++ b/src/algorithm/bruteforce/bruteforce.cpp @@ -371,6 +371,8 @@ DatasetPtr BruteForce::SearchWithRequest(const SearchRequest& request) const { ValidateSearchThreshold(request.threshold_); std::shared_lock read_lock(this->global_mutex_); + SearchStatistics statistics; + QueryContext query_context{.stats = &statistics}; const bool use_custom_distance = request.distance_batch_func_ != nullptr; if (use_custom_distance) { @@ -407,7 +409,7 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const { auto radius = is_range ? request.radius_ : std::numeric_limits::max(); if (total_count_.load() == 0) { - return make_empty_result(); + return make_empty_result(statistics.Dump()); } DistHeapPtr heap = nullptr; @@ -458,8 +460,10 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const { const auto label = this->label_table_->GetLabelById(inner_id); request.distance_batch_func_(&label, 1, &dist); CHECK_ARGUMENT(std::isfinite(dist), "distance callback must return finite scores"); + statistics.AddDistance(SearchStatistics::DistancePhase::APPROXIMATE, + DistanceEvaluationBackend::UNKNOWN); } else { - 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); } @@ -507,7 +511,10 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const { custom_inner_ids.clear(); custom_labels.clear(); }; - + QueryContext local_query_context = query_context; + // Worker distances are counted locally and merged once below. Disable only the datacell's + // per-call statistics sink; all non-statistics query context fields remain available. + local_query_context.stats = nullptr; for (InnerIdType i = start; i < end; ++i) { if (attr_filter != nullptr and not attr_filter->CheckValid(i)) { if (reasoning != nullptr) { @@ -524,7 +531,7 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const { } } else { float dist = 0.0F; - 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); @@ -544,6 +551,10 @@ BruteForce::SearchWithRequest(const SearchRequest& request) const { } flush_custom_batch(); dist_cmp.fetch_add(dist_cmp_local, std::memory_order_relaxed); + statistics.AddDistance( + SearchStatistics::DistancePhase::APPROXIMATE, + use_custom_distance ? DistanceEvaluationBackend::UNKNOWN : inner_codes_->backend_, + dist_cmp_local); }; auto count = total_count_.load(); @@ -616,7 +627,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()); diff --git a/src/algorithm/hgraph/hgraph_mci_test.cpp b/src/algorithm/hgraph/hgraph_mci_test.cpp index 32c3b5eba3..d748ab7105 100644 --- a/src/algorithm/hgraph/hgraph_mci_test.cpp +++ b/src/algorithm/hgraph/hgraph_mci_test.cpp @@ -270,6 +270,12 @@ TEST_CASE("HGraph companion MCI incrementally updates cliques after Add", "[ut][ REQUIRE(result.has_value()); REQUIRE(result.value()->GetDim() > 0); REQUIRE(result.value()->GetStatistics({"mci_hybrid_route"})[0] == R"("mci")"); + const auto search_statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(search_statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() > 0); + REQUIRE(search_statistics["distance_evaluations"].GetUint64() == + search_statistics["dist_cmp"].GetUint64()); + REQUIRE(search_statistics["distance_evaluations_by_backend"]["fp32"].GetUint64() == + search_statistics["distance_evaluations"].GetUint64()); const auto expected_seed_count = static_cast(std::ceil(std::sqrt(static_cast(total)) * 0.5)); REQUIRE(std::stoull(result.value()->GetStatistics({"mci_seed_count"})[0]) == diff --git a/src/algorithm/hgraph/hgraph_search.cpp b/src/algorithm/hgraph/hgraph_search.cpp index d258c58f79..6a9eeeb6cc 100644 --- a/src/algorithm/hgraph/hgraph_search.cpp +++ b/src/algorithm/hgraph/hgraph_search.cpp @@ -29,13 +29,18 @@ namespace vsag { static DatasetPtr -make_empty_dataset_with_stats() { - SearchStatistics stats; +make_empty_dataset_with_stats(const SearchStatistics& stats) { auto dataset_result = DatasetImpl::MakeEmptyDataset(); dataset_result->Statistics(stats.Dump()); return dataset_result; } +static DatasetPtr +make_empty_dataset_with_stats() { + SearchStatistics stats; + return make_empty_dataset_with_stats(stats); +} + DatasetPtr HGraph::KnnSearch(const DatasetPtr& query, int64_t k, @@ -141,6 +146,7 @@ HGraph::KnnSearch(const DatasetPtr& query, return make_empty_dataset_with_stats(); } if (iter_filter_ctx->IsFirstUsed()) { + ScopedDistancePhase routing_phase(ctx, DistanceEvaluationPhase::ROUTING); for (auto i = static_cast(this->route_graphs_.size() - 1); i >= 0; --i) { auto result = this->search_one_graph(query_data, this->route_graphs_[i], @@ -230,7 +236,7 @@ HGraph::KnnSearch(const DatasetPtr& query, if (not iter_filter_ctx->Empty()) { continue; } - return DatasetImpl::MakeEmptyDataset(); + return make_empty_dataset_with_stats(stats); } auto count = static_cast(search_result->Size()); auto [dataset_results, dists, ids] = create_fast_dataset(count, ctx.alloc); @@ -507,8 +513,10 @@ HGraph::SearchWithRequest(const SearchRequest& request) const { const auto label = this->label_table_->GetLabelById(inner_id); request.distance_batch_func_(&label, 1, &dist); CHECK_ARGUMENT(std::isfinite(dist), "distance callback must return finite scores"); + stats.AddDistance(SearchStatistics::DistancePhase::APPROXIMATE, + DistanceEvaluationBackend::UNKNOWN); } else { - precise_flatten->Query(&dist, computer, &inner_id, 1); + precise_flatten->Query(&dist, computer, &inner_id, 1, &ctx); } reasoning_ctx->SetTrueDistance(inner_id, dist); } @@ -549,6 +557,7 @@ HGraph::SearchWithRequest(const SearchRequest& request) const { auto& vt = vt_guard.visited_list; const auto* raw_query = use_custom_distance ? nullptr : get_data(query); + ctx.distance_phase = DistanceEvaluationPhase::ROUTING; for (auto i = static_cast(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); @@ -557,6 +566,7 @@ HGraph::SearchWithRequest(const SearchRequest& request) const { search_param.ep = result->Top().second; } } + ctx.distance_phase = DistanceEvaluationPhase::APPROXIMATE; FilterPtr ft = this->create_search_filter(request.filter_, params.use_extra_info_filter); diff --git a/src/algorithm/ivf/flat_bucket_searcher.cpp b/src/algorithm/ivf/flat_bucket_searcher.cpp index 2f30ebfa87..81e314fedb 100644 --- a/src/algorithm/ivf/flat_bucket_searcher.cpp +++ b/src/algorithm/ivf/flat_bucket_searcher.cpp @@ -41,6 +41,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(bucket_size)); + } Filter* attr_ft = nullptr; size_t tid = 0; diff --git a/src/algorithm/ivf/gno_imi_partition.cpp b/src/algorithm/ivf/gno_imi_partition.cpp index 37fb7f7455..bdd9c0e37e 100644 --- a/src/algorithm/ivf/gno_imi_partition.cpp +++ b/src/algorithm/ivf/gno_imi_partition.cpp @@ -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(datas), data_centroids_s_.data(), @@ -265,6 +266,7 @@ GNOIMIPartition::ClassifyDatasForSearch(const void* datas, MaxHeap heap(this->allocator_); for (uint64_t j = 0; j < bucket_count_s_; ++j) { auto dist_term_s = norms_s_[j] - dist_to_s_data[i * bucket_count_s_ + j]; + ++distance_evaluations; if (heap.size() < candidate_count_s || dist_term_s < heap.top().first) { heap.emplace(dist_term_s, j); } @@ -294,6 +296,7 @@ GNOIMIPartition::ClassifyDatasForSearch(const void* datas, auto cur_bucket_id_global = static_cast(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); } @@ -308,6 +311,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; } @@ -375,12 +383,14 @@ GNOIMIPartition::inner_joint_classify_datas(const float* datas, // precomputed_terms_st: |t|^2 + 2st float total_err = 0.0; uint32_t dist_cmp = 0; + uint64_t distance_evaluations = 0; for (uint64_t i = 0; i < count; ++i) { auto data_norm = FP32ComputeIP(datas + i * dim_, datas + i * dim_, dim_); for (BucketIdType j = 0; j < bucket_count_s_; ++j) { precomputed_terms_s[j].first = norms_s_[j] - dist_to_s[i * bucket_count_s_ + j] + data_norm / 2; precomputed_terms_s[j].second = j; + ++distance_evaluations; } std::sort(precomputed_terms_s.begin(), precomputed_terms_s.end()); @@ -401,6 +411,7 @@ GNOIMIPartition::inner_joint_classify_datas(const float* datas, float dist = cur_precomputed_term_s - dist_to_t[i * bucket_count_t_ + k] + precomputed_terms_st_[cur_bucket_id_global]; ++dist_cmp; + ++distance_evaluations; if (heap.size() < buckets_per_data || dist < heap.top().first) { heap.emplace(dist, cur_bucket_id_global); @@ -422,6 +433,9 @@ 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, + DistanceEvaluationBackend::FP32, + distance_evaluations); } } diff --git a/src/algorithm/ivf/gno_imi_partition_test.cpp b/src/algorithm/ivf/gno_imi_partition_test.cpp index 3b48c9d293..56cce57571 100644 --- a/src/algorithm/ivf/gno_imi_partition_test.cpp +++ b/src/algorithm/ivf/gno_imi_partition_test.cpp @@ -25,6 +25,8 @@ using namespace vsag; TEST_CASE("GNO-IMI Partition Basic Test", "[ut][GNOIMIPartition]") { + constexpr uint64_t first_order_bucket_count = 10; + constexpr uint64_t second_order_bucket_count = 10; auto allocator = SafeAllocator::FactoryDefaultAllocator(); int64_t dim = 128; IndexCommonParam param; @@ -53,6 +55,15 @@ TEST_CASE("GNO-IMI Partition Basic Test", "[ut][GNOIMIPartition]") { auto class_result = partition->ClassifyDatas(vec.data(), data_count, 1, nullptr); REQUIRE(class_result.size() == data_count); + SearchStatistics build_stats; + QueryContext build_ctx{.stats = &build_stats}; + constexpr uint64_t build_query_count = 2; + partition->ClassifyDatas(vec.data(), build_query_count, 1, &build_ctx); + auto build_statistics = JsonType::Parse(build_stats.Dump()); + REQUIRE(build_statistics["distance_evaluations_by_phase"]["routing"].GetUint64() == + build_statistics["dist_cmp"].GetUint64() + + build_query_count * first_order_bucket_count); + param_str = R"( { "ivf": { @@ -66,6 +77,16 @@ TEST_CASE("GNO-IMI Partition Basic Test", "[ut][GNOIMIPartition]") { inner_search_param.first_order_scan_ratio = search_param.first_order_scan_ratio; REQUIRE(inner_search_param.scan_bucket_size == 1); REQUIRE(inner_search_param.first_order_scan_ratio == 0.1f); + SearchStatistics search_stats; + QueryContext search_ctx{.stats = &search_stats}; + partition->ClassifyDatasForSearch(vec.data(), 1, inner_search_param, &search_ctx); + auto search_statistics = JsonType::Parse(search_stats.Dump()); + const auto scanned_first_order_buckets = static_cast(std::max( + std::floor(first_order_bucket_count * inner_search_param.first_order_scan_ratio), 1.0F)); + const auto expected_search_evaluations = + first_order_bucket_count + scanned_first_order_buckets * second_order_bucket_count; + REQUIRE(search_statistics["distance_evaluations_by_phase"]["routing"].GetUint64() == + expected_search_evaluations); uint64_t match_count = 0; for (int64_t i = 0; i < data_count; ++i) { auto query = Dataset::Make(); diff --git a/src/algorithm/ivf/graph_bucket_searcher.cpp b/src/algorithm/ivf/graph_bucket_searcher.cpp index 7ac6b112be..42cf1d8f6d 100644 --- a/src/algorithm/ivf/graph_bucket_searcher.cpp +++ b/src/algorithm/ivf/graph_bucket_searcher.cpp @@ -128,7 +128,8 @@ GraphBucketSearcher::search_graph(BucketIdType bucket_id, // needs query distances, so use the no-factory provider and make pairwise misuse explicit. BucketDistanceProvider distance_provider(bucket, bucket_id, computer, ids, buckets_per_data); auto visited = std::make_shared(bucket_size, allocator_); - QueryContext query_context{}; + QueryContext query_context = + param.query_context == nullptr ? QueryContext{} : *param.query_context; query_context.reasoning_ctx = reasoning_ctx; BasicSearcher searcher(allocator_); auto top_candidates = searcher.Search( diff --git a/src/algorithm/ivf/ivf.cpp b/src/algorithm/ivf/ivf.cpp index d5479b4433..344df6a9ef 100644 --- a/src/algorithm/ivf/ivf.cpp +++ b/src/algorithm/ivf/ivf.cpp @@ -1441,6 +1441,10 @@ IVF::route_buckets_only(const DatasetPtr& query, } ids[idx] = static_cast(bucket_id); distances[idx] = dist; + if (ctx.stats != nullptr) { + ctx.stats->AddDistance(SearchStatistics::DistancePhase::ROUTING, + DistanceEvaluationBackend::FP32); + } } } @@ -1661,6 +1665,11 @@ IVF::search_with_custom_distance(const DatasetPtr& query, } request.distance_batch_func_( candidate_labels.data(), candidate_labels.size(), scores.data()); + if (ctx.stats != nullptr) { + ctx.stats->AddDistance(SearchStatistics::DistancePhase::APPROXIMATE, + DistanceEvaluationBackend::UNKNOWN, + candidate_ids.size()); + } for (uint64_t i = 0; i < candidate_ids.size(); ++i) { CHECK_ARGUMENT(std::isfinite(scores[i]), "custom query distance callback must return finite scores"); @@ -1828,6 +1837,7 @@ IVF::SearchWithRequest(const SearchRequest& request) const { "IVF custom query distance does not support parallel search"); param.enable_reorder = false; } + param.query_context = &ctx; auto query = request.query_; if (use_custom_distance) { @@ -1909,6 +1919,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(); diff --git a/src/algorithm/ivf/ivf_nearest_partition.cpp b/src/algorithm/ivf/ivf_nearest_partition.cpp index 46985dd584..19c6eae49c 100644 --- a/src/algorithm/ivf/ivf_nearest_partition.cpp +++ b/src/algorithm/ivf/ivf_nearest_partition.cpp @@ -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) { diff --git a/src/algorithm/pyramid/pyramid_test.cpp b/src/algorithm/pyramid/pyramid_test.cpp index d325e884ef..88b508c637 100644 --- a/src/algorithm/pyramid/pyramid_test.cpp +++ b/src/algorithm/pyramid/pyramid_test.cpp @@ -36,7 +36,8 @@ PyramidTestIndex MakePyramidIndex(uint32_t index_min_size, uint64_t build_thread_count = 1, bool use_rabitq_with_sq8 = false, - bool split_rabitq = false) { + bool split_rabitq = false, + bool use_reorder = false) { PyramidTestIndex result; vsag::IndexCommonParam common_param; common_param.dim_ = PYRAMID_TEST_DIM; @@ -64,13 +65,17 @@ MakePyramidIndex(uint32_t index_min_size, } external_param[vsag::PYRAMID_INDEX_MIN_SIZE].SetInt(index_min_size); external_param[vsag::PYRAMID_BUILD_THREAD_COUNT].SetUint64(build_thread_count); + external_param[vsag::PYRAMID_USE_REORDER].SetBool(use_rabitq_with_sq8 or split_rabitq or + use_reorder); if (use_rabitq_with_sq8) { external_param[vsag::PYRAMID_BASE_QUANTIZATION_TYPE].SetString("rabitq"); external_param[vsag::PYRAMID_PRECISE_QUANTIZATION_TYPE].SetString("sq8"); external_param[vsag::PYRAMID_BASE_IO_TYPE].SetString("block_memory_io"); external_param[vsag::PYRAMID_PRECISE_IO_TYPE].SetString("block_memory_io"); - external_param[vsag::PYRAMID_USE_REORDER].SetBool(true); external_param[vsag::PYRAMID_RABITQ_BITS_PER_DIM_BASE].SetUint64(1); + } else if (use_reorder) { + external_param[vsag::PYRAMID_PRECISE_QUANTIZATION_TYPE].SetString( + vsag::QUANTIZATION_TYPE_VALUE_FP32); } auto param = vsag::Pyramid::CheckAndMappingExternalParam(external_param, common_param); result.index = std::make_shared(param, common_param); @@ -100,6 +105,20 @@ GetPyramidDuplicateRatio(const std::shared_ptr& index) { return stats["duplicate_ratio"].GetFloat(); } +void +RequirePyramidSearchStatistics(const vsag::DatasetPtr& result, uint64_t approximate) { + auto statistics = vsag::JsonType::Parse(result->GetStatistics()); + REQUIRE(statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() == approximate); + REQUIRE(statistics["distance_evaluations_by_phase"]["rerank"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations"].GetUint64() == + statistics["distance_evaluations_by_phase"]["routing"].GetUint64() + + statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() + + statistics["distance_evaluations_by_phase"]["rerank"].GetUint64()); + REQUIRE(statistics["distance_evaluations_by_backend"]["fp32"].GetUint64() == + statistics["distance_evaluations"].GetUint64()); + REQUIRE(statistics["complete"].GetBool()); +} + } // namespace TEST_CASE("Split function tests", "[ut][pyramid]") { @@ -249,3 +268,40 @@ TEST_CASE("Pyramid Build stores RaBitQ and SQ8 codes in parallel", "[ut][pyramid } } } + +TEST_CASE("Pyramid reports statistics for flat and graph leaves", "[ut][pyramid][statistics]") { + auto test_index = MakePyramidIndex(3, 1, false, false, true); + const auto& index = test_index.index; + std::vector vectors = { + 0.0F, + 0.0F, + 0.0F, + 0.0F, + 1.0F, + 1.0F, + 1.0F, + 1.0F, + 2.0F, + 2.0F, + 2.0F, + 2.0F, + }; + std::vector ids = {100, 101, 102}; + std::vector paths(3, "tenant"); + auto query = MakePyramidDataset(vectors.data(), nullptr, paths.data(), 1); + const auto parameters = R"({"pyramid":{"ef_search":10}})"; + + REQUIRE(index->Add(MakePyramidDataset(vectors.data(), ids.data(), paths.data(), 2)).empty()); + auto flat_result = index->KnnSearch(query, 1, parameters, vsag::FilterPtr{}); + RequirePyramidSearchStatistics(flat_result, 2); + + REQUIRE(index + ->Add(MakePyramidDataset( + vectors.data() + 2 * PYRAMID_TEST_DIM, ids.data() + 2, paths.data() + 2, 1)) + .empty()); + auto graph_result = index->KnnSearch(query, 1, parameters, vsag::FilterPtr{}); + auto graph_statistics = vsag::JsonType::Parse(graph_result->GetStatistics()); + REQUIRE(graph_statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() > 0); + RequirePyramidSearchStatistics( + graph_result, graph_statistics["distance_evaluations_by_phase"]["approximate"].GetUint64()); +} diff --git a/src/algorithm/simq/simq.cpp b/src/algorithm/simq/simq.cpp index 72e2af5185..075be54948 100644 --- a/src/algorithm/simq/simq.cpp +++ b/src/algorithm/simq/simq.cpp @@ -72,16 +72,18 @@ dump_simq_statistics(const SearchStatistics& stats, return json.Dump(); } -uint64_t -read_dist_cmp(const DatasetPtr& result_ds) { +std::pair +read_coarse_statistics(const DatasetPtr& result_ds) { if (result_ds == nullptr) { - return 0; + return {0, 0}; } - auto values = result_ds->GetStatistics({"dist_cmp"}); - if (values.empty() || values[0].empty()) { - return 0; - } - return std::strtoull(values[0].c_str(), nullptr, 10); + auto values = result_ds->GetStatistics({"dist_cmp", "distance_evaluations"}); + const auto read_value = [&values](uint64_t index) { + return index < values.size() and not values[index].empty() + ? std::strtoull(values[index].c_str(), nullptr, 10) + : 0; + }; + return {read_value(0), read_value(1)}; } class HGraphDynamicClustering { @@ -644,7 +646,8 @@ SIMQ::coarse_search(const float* query_tokens, uint32_t query_token_count, int64_t coarse_k, uint64_t* coarse_dist_cmp, - uint64_t* coarse_probe_count) const { + uint64_t* coarse_probe_count, + uint64_t* coarse_distance_evaluations) const { // All buffers are local — safe for concurrent searches under shared_lock. std::unordered_map score_map; score_map.reserve(static_cast(coarse_k) * static_cast(max_cluster_size_)); @@ -667,8 +670,13 @@ SIMQ::coarse_search(const float* query_tokens, query_ds->NumElements(1)->Dim(dim_)->Float32Vectors(qt)->Owner(false); auto result_ds = rep_hgraph_->KnnSearch( query_ds, actual_coarse_k, R"({"hgraph": {"ef_search": 100}})", nullptr); + const auto [nested_dist_cmp, nested_distance_evaluations] = + read_coarse_statistics(result_ds); if (coarse_dist_cmp != nullptr) { - *coarse_dist_cmp += read_dist_cmp(result_ds); + *coarse_dist_cmp += nested_dist_cmp; + } + if (coarse_distance_evaluations != nullptr) { + *coarse_distance_evaluations += nested_distance_evaluations; } int64_t nres = result_ds->GetDim(); @@ -741,8 +749,15 @@ SIMQ::KnnSearch(const DatasetPtr& query, uint64_t coarse_dist_cmp = 0; 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); + uint64_t coarse_distance_evaluations = 0; + auto coarse_results = coarse_search(query_mvs[0].vectors_, + query_mvs[0].len_, + coarse_k, + &coarse_dist_cmp, + &coarse_probe_count, + &coarse_distance_evaluations); + stats.AddDistance( + SearchStatistics::DistancePhase::ROUTING, "fp32", coarse_distance_evaluations); uint64_t coarse_candidate_count = coarse_results.size(); if (static_cast(coarse_results.size()) > rerank_k) { coarse_results.resize(rerank_k); @@ -769,10 +784,13 @@ SIMQ::KnnSearch(const DatasetPtr& query, // Single batched Query call (enables MultiRead in MultiVectorDataCell) if (!batch_ids.empty()) { std::vector 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(batch_ids.size())); + static_cast(batch_ids.size()), + &query_context); stats.dist_cmp.fetch_add(static_cast(batch_ids.size()), std::memory_order_relaxed); for (uint64_t i = 0; i < batch_ids.size(); i++) { @@ -851,8 +869,15 @@ SIMQ::RangeSearch(const DatasetPtr& query, uint64_t coarse_dist_cmp = 0; 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); + uint64_t coarse_distance_evaluations = 0; + auto coarse_results = coarse_search(query_mvs[0].vectors_, + query_mvs[0].len_, + coarse_k, + &coarse_dist_cmp, + &coarse_probe_count, + &coarse_distance_evaluations); + stats.AddDistance( + SearchStatistics::DistancePhase::ROUTING, "fp32", coarse_distance_evaluations); uint64_t coarse_candidate_count = coarse_results.size(); if (static_cast(coarse_results.size()) > rerank_k) { coarse_results.resize(rerank_k); @@ -868,7 +893,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); diff --git a/src/algorithm/simq/simq.h b/src/algorithm/simq/simq.h index adca9da986..12f053964d 100644 --- a/src/algorithm/simq/simq.h +++ b/src/algorithm/simq/simq.h @@ -100,7 +100,8 @@ class SIMQ : public InnerIndexInterface { uint32_t query_token_count, int64_t coarse_k, uint64_t* coarse_dist_cmp = nullptr, - uint64_t* coarse_probe_count = nullptr) const; + uint64_t* coarse_probe_count = nullptr, + uint64_t* coarse_distance_evaluations = nullptr) const; void serialize_rep_hgraph(StreamWriter& writer) const; diff --git a/src/algorithm/sindi/sindi.cpp b/src/algorithm/sindi/sindi.cpp index 325f9892f9..18c7beaaaa 100644 --- a/src/algorithm/sindi/sindi.cpp +++ b/src/algorithm/sindi/sindi.cpp @@ -54,6 +54,18 @@ constexpr const char* SINDI_RERANK_FLAT_FORMAT_KEY = "sindi_rerank_flat_format"; constexpr int64_t SINDI_RERANK_FLAT_FORMAT_DATACELL = 2; constexpr int64_t SINDI_RERANK_FLAT_FORMAT_DMQ = 3; +DistanceEvaluationBackend +sparse_backend(SparseValueQuantizationType quant_type) { + switch (quant_type) { + case SparseValueQuantizationType::SQ8: + return DistanceEvaluationBackend::SPARSE_SQ8; + case SparseValueQuantizationType::FP16: + return DistanceEvaluationBackend::SPARSE_FP16; + default: + return DistanceEvaluationBackend::SPARSE_FP32; + } +} + uint32_t sparse_value_code_size(SparseValueQuantizationType type) { switch (type) { @@ -616,13 +628,16 @@ SINDI::KnnSearch(const DatasetPtr& query, inner_param.is_inner_id_allowed = this->create_search_filter(filter); + SearchStatistics statistics; SparseVector effective_query = sparse_query; Vector tmp_ids(allocator); Vector tmp_vals(allocator); if (remap_term_ids_) { effective_query = remap_sparse_vector_for_query(sparse_query, tmp_ids, tmp_vals); if (effective_query.len_ == 0) { - return make_empty_result(); + auto result = make_empty_result(); + result->Statistics(statistics.Dump()); + return result; } } @@ -631,12 +646,23 @@ SINDI::KnnSearch(const DatasetPtr& query, DatasetPtr result; const bool use_term_lists_heap_insert = UseTermListsHeapInsert(search_param, threshold); if (immutable_data_ != nullptr) { - result = immutable_search_impl( - computer, inner_param, allocator, use_term_lists_heap_insert, rerank_query); + result = immutable_search_impl(computer, + inner_param, + allocator, + use_term_lists_heap_insert, + rerank_query, + nullptr, + &statistics); } else { - result = search_impl( - computer, inner_param, allocator, use_term_lists_heap_insert, rerank_query); - } + result = search_impl(computer, + inner_param, + allocator, + use_term_lists_heap_insert, + rerank_query, + nullptr, + &statistics); + } + result->Statistics(statistics.Dump()); return FilterDatasetByThreshold(result, threshold, allocator, k); } @@ -678,12 +704,14 @@ SINDI::map_immutable_query_terms(const ImmutableSINDIWindow& window, } } -void +uint64_t SINDI::scan_immutable_window_by_mapped_terms(float* dists, const ImmutableSINDIWindow& window, const SparseTermComputerPtr& computer, - const ImmutableMappedQueryTerms& mapped_terms) const { + const ImmutableMappedQueryTerms& mapped_terms, + SparseEvaluationTracker& evaluation_tracker) const { const auto value_code_size = immutable_data_->value_code_size; + evaluation_tracker.BeginWindow(); for (uint32_t pos = 0; pos < mapped_terms.size(); ++pos) { const auto term_index = mapped_terms[pos].first; const auto it = mapped_terms[pos].second; @@ -705,6 +733,7 @@ SINDI::scan_immutable_window_by_mapped_terms(float* dists, const auto term_count = static_cast(static_cast(doc_count) * computer->term_retain_ratio_); const auto* ids = window.id_payloads.data() + begin_doc; + evaluation_tracker.Mark(ids, term_count); const auto* values = window.value_payloads.data() + static_cast(begin_doc) * value_code_size; if (sparse_value_quant_type_ == SparseValueQuantizationType::SQ8) { @@ -716,6 +745,7 @@ SINDI::scan_immutable_window_by_mapped_terms(float* dists, } } computer->ResetTerm(); + return evaluation_tracker.Count(); } template @@ -939,7 +969,8 @@ SINDI::immutable_search_impl(const SparseTermComputerPtr& computer, Allocator* allocator, bool use_term_lists_heap_insert, const SparseVector* original_query, - ReasoningContext* reasoning_ctx) const { + ReasoningContext* reasoning_ctx, + SearchStatistics* statistics) const { Allocator* search_allocator = allocator != nullptr ? allocator : allocator_; MaxHeap heap(search_allocator); int64_t k = 0; @@ -948,6 +979,7 @@ SINDI::immutable_search_impl(const SparseTermComputerPtr& computer, } Vector dists(window_size_, 0.0F, search_allocator); + SparseEvaluationTracker evaluation_tracker(window_size_, search_allocator); ImmutableMappedQueryTerms mapped_terms(search_allocator); auto filter = inner_param.is_inner_id_allowed; const auto [min_window_id, max_window_id] = this->get_min_max_window_id(filter); @@ -959,7 +991,13 @@ SINDI::immutable_search_impl(const SparseTermComputerPtr& computer, const auto window_start_id = static_cast(cur) * window_size_; map_immutable_query_terms(window, computer, mapped_terms); std::fill(dists.begin(), dists.end(), 0.0F); - scan_immutable_window_by_mapped_terms(dists.data(), window, computer, mapped_terms); + const auto evaluated = scan_immutable_window_by_mapped_terms( + dists.data(), window, computer, mapped_terms, evaluation_tracker); + if (statistics != nullptr) { + statistics->AddDistance(SearchStatistics::DistancePhase::APPROXIMATE, + sparse_backend(sparse_value_quant_type_), + evaluated); + } if (reasoning_ctx != nullptr) { selected_buckets->push_back(static_cast(cur)); @@ -1020,7 +1058,10 @@ SINDI::immutable_search_impl(const SparseTermComputerPtr& computer, for (auto i = 0; i < candidate_size; i++) { auto inner_id = heap.top().second; float high_precise_distance = 0.0F; - rerank_flat_->Query(&high_precise_distance, rerank_computer, &inner_id, 1); + QueryContext query_context{.stats = statistics, + .distance_phase = DistanceEvaluationPhase::RERANK}; + rerank_flat_->Query( + &high_precise_distance, rerank_computer, &inner_id, 1, &query_context); auto label = label_table_->GetLabelById(inner_id); if (reasoning_ctx != nullptr) { reasoning_ctx->RecordReorder( @@ -1093,7 +1134,8 @@ SINDI::search_impl(const SparseTermComputerPtr& computer, Allocator* allocator, bool use_term_lists_heap_insert, const SparseVector* original_query, - ReasoningContext* reasoning_ctx) const { + ReasoningContext* reasoning_ctx, + SearchStatistics* statistics) const { // computer and heap MaxHeap heap(allocator); int64_t k = 0; @@ -1104,6 +1146,7 @@ SINDI::search_impl(const SparseTermComputerPtr& computer, // window iteration Vector dists(window_size_, 0.0, allocator); + SparseEvaluationTracker evaluation_tracker(window_size_, allocator); auto filter = inner_param.is_inner_id_allowed; const auto [min_window_id, max_window_id] = this->get_min_max_window_id(filter); auto selected_buckets = @@ -1113,7 +1156,12 @@ SINDI::search_impl(const SparseTermComputerPtr& computer, auto term_list = this->window_term_list_[cur]; // compute - term_list->Query(dists.data(), computer); + const auto evaluated = term_list->Query(dists.data(), computer, evaluation_tracker); + if (statistics != nullptr) { + statistics->AddDistance(SearchStatistics::DistancePhase::APPROXIMATE, + sparse_backend(sparse_value_quant_type_), + evaluated); + } if (reasoning_ctx != nullptr) { selected_buckets->push_back(static_cast(cur)); @@ -1165,7 +1213,10 @@ SINDI::search_impl(const SparseTermComputerPtr& computer, for (auto i = 0; i < candidate_size; i++) { auto inner_id = heap.top().second; float high_precise_distance = 0.0F; - rerank_flat_->Query(&high_precise_distance, rerank_computer, &inner_id, 1); + QueryContext query_context{.stats = statistics, + .distance_phase = DistanceEvaluationPhase::RERANK}; + rerank_flat_->Query( + &high_precise_distance, rerank_computer, &inner_id, 1, &query_context); auto label = label_table_->GetLabelById(inner_id); if (reasoning_ctx != nullptr) { reasoning_ctx->RecordReorder( @@ -1265,24 +1316,41 @@ SINDI::RangeSearch(const DatasetPtr& query, inner_param.is_inner_id_allowed = this->create_search_filter(filter); + SearchStatistics statistics; SparseVector effective_query = sparse_query; Vector tmp_ids(allocator_); Vector tmp_vals(allocator_); if (remap_term_ids_) { effective_query = remap_sparse_vector_for_query(sparse_query, tmp_ids, tmp_vals); if (effective_query.len_ == 0) { - return make_empty_result(); + auto result = make_empty_result(); + result->Statistics(statistics.Dump()); + return result; } } auto computer = std::make_shared(effective_query, search_param, allocator_); const SparseVector* rerank_query = (remap_term_ids_ && use_reorder_) ? &sparse_query : nullptr; if (immutable_data_ != nullptr) { - return immutable_search_impl( - computer, inner_param, allocator_, UseTermListsHeapInsert(search_param), rerank_query); + auto result = immutable_search_impl(computer, + inner_param, + allocator_, + UseTermListsHeapInsert(search_param), + rerank_query, + nullptr, + &statistics); + result->Statistics(statistics.Dump()); + return result; } - return search_impl( - computer, inner_param, allocator_, UseTermListsHeapInsert(search_param), rerank_query); + auto result = search_impl(computer, + inner_param, + allocator_, + UseTermListsHeapInsert(search_param), + rerank_query, + nullptr, + &statistics); + result->Statistics(statistics.Dump()); + return result; } DatasetPtr @@ -1303,6 +1371,7 @@ SINDI::SearchWithRequest(const SearchRequest& request) const { Allocator* allocator = select_query_allocator(request.search_allocator_, this->allocator_); bool is_range = (request.mode_ == SearchMode::RANGE_SEARCH); + SearchStatistics statistics; InnerSearchParam inner_param; const bool filter_enabled = request.enable_filter_ and request.filter_ != nullptr; @@ -1339,7 +1408,10 @@ SINDI::SearchWithRequest(const SearchRequest& request) const { if (remap_term_ids_) { effective_query = remap_sparse_vector_for_query(sparse_query, tmp_ids, tmp_vals); if (effective_query.len_ == 0) { - return make_empty_result(); + auto result = make_empty_result(); + result->Statistics(statistics.Dump()); + this->AttachReasoningReport(result, reasoning_ctx.get()); + return result; } } @@ -1356,14 +1428,16 @@ SINDI::SearchWithRequest(const SearchRequest& request) const { allocator, UseTermListsHeapInsert(search_param), rerank_query, - reasoning_ctx.get()); + reasoning_ctx.get(), + &statistics); } else { result = search_impl(computer, inner_param, allocator, UseTermListsHeapInsert(search_param), rerank_query, - reasoning_ctx.get()); + reasoning_ctx.get(), + &statistics); } } else { CHECK_ARGUMENT(search_param.n_candidate <= SPARSE_AMPLIFICATION_FACTOR * request.topk_, @@ -1379,17 +1453,20 @@ SINDI::SearchWithRequest(const SearchRequest& request) const { allocator, UseTermListsHeapInsert(search_param), rerank_query, - reasoning_ctx.get()); + reasoning_ctx.get(), + &statistics); } else { result = search_impl(computer, inner_param, allocator, UseTermListsHeapInsert(search_param), rerank_query, - reasoning_ctx.get()); + reasoning_ctx.get(), + &statistics); } } + result->Statistics(statistics.Dump()); this->AttachReasoningReport(result, reasoning_ctx.get()); return result; } @@ -2325,7 +2402,7 @@ SINDI::CalDistanceById(const DatasetPtr& query, auto window_start_id = cur_window * window_size_; auto term_list = this->window_term_list_[cur_window]; std::fill(window_dists.data(), window_dists.data() + window_size_, 0.0F); - term_list->Query(window_dists.data(), computer); + term_list->QueryWithoutTracking(window_dists.data(), computer); for (const auto position : positions) { row_distances[position] = 1.0F + window_dists[inner_ids[position] - window_start_id]; diff --git a/src/algorithm/sindi/sindi.h b/src/algorithm/sindi/sindi.h index 8c6cfb8b8f..6f78687877 100644 --- a/src/algorithm/sindi/sindi.h +++ b/src/algorithm/sindi/sindi.h @@ -220,7 +220,8 @@ class SINDI : public InnerIndexInterface { Allocator* allocator, bool use_term_lists_heap_insert, const SparseVector* original_query = nullptr, - ReasoningContext* reasoning_ctx = nullptr) const; + ReasoningContext* reasoning_ctx = nullptr, + SearchStatistics* statistics = nullptr) const; template DatasetPtr @@ -229,7 +230,8 @@ class SINDI : public InnerIndexInterface { Allocator* allocator, bool use_term_lists_heap_insert, const SparseVector* original_query = nullptr, - ReasoningContext* reasoning_ctx = nullptr) const; + ReasoningContext* reasoning_ctx = nullptr, + SearchStatistics* statistics = nullptr) const; bool UseTermListsHeapInsert(const SINDISearchParameter& search_param, @@ -290,11 +292,12 @@ class SINDI : public InnerIndexInterface { const SparseTermComputerPtr& computer, ImmutableMappedQueryTerms& mapped_terms) const; - void + uint64_t scan_immutable_window_by_mapped_terms(float* dists, const ImmutableSINDIWindow& window, const SparseTermComputerPtr& computer, - const ImmutableMappedQueryTerms& mapped_terms) const; + const ImmutableMappedQueryTerms& mapped_terms, + SparseEvaluationTracker& evaluation_tracker) const; template void diff --git a/src/analyzer/sindi_analyzer.cpp b/src/analyzer/sindi_analyzer.cpp index 3350e8989a..1d67c34962 100644 --- a/src/analyzer/sindi_analyzer.cpp +++ b/src/analyzer/sindi_analyzer.cpp @@ -228,7 +228,7 @@ SINDIAnalyzer::collect_coarse_candidates(const SparseVector& query, for (int64_t cur = 0; cur < static_cast(sindi_->window_term_list_.size()); ++cur) { auto window_start_id = static_cast(cur * sindi_->window_size_); auto term_list = sindi_->window_term_list_[cur]; - term_list->Query(dists.data(), computer); + term_list->QueryWithoutTracking(dists.data(), computer); if (use_term_lists_heap_insert) { term_list->InsertHeapByTermLists( dists.data(), computer, heap, inner_param, window_start_id); diff --git a/src/datacell/bucket_datacell.h b/src/datacell/bucket_datacell.h index dcdc0b5b80..0056603e2d 100644 --- a/src/datacell/bucket_datacell.h +++ b/src/datacell/bucket_datacell.h @@ -231,6 +231,8 @@ BucketDataCell::BucketDataCell(const QuantizerParamPtr& quant this->quantizer_ = std::make_shared(quantization_param, common_param); this->code_size_ = quantizer_->GetCodeSize(); this->use_residual_ = use_residual; + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*quantizer_)); datas_.Resize(bucket_count); } @@ -546,6 +548,8 @@ void BucketDataCell::Deserialize(lvalue_or_rvalue reader) { BucketInterface::Deserialize(reader); quantizer_->Deserialize(reader); + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*quantizer_)); for (BucketIdType i = 0; i < this->bucket_count_; ++i) { datas_[i].Deserialize(reader); StreamReader::ReadVector(reader, inner_ids_[i]); diff --git a/src/datacell/bucket_datacell_test.cpp b/src/datacell/bucket_datacell_test.cpp index a4d737cae4..c1572fc055 100644 --- a/src/datacell/bucket_datacell_test.cpp +++ b/src/datacell/bucket_datacell_test.cpp @@ -101,7 +101,9 @@ BucketInterfaceTest::BasicTest(int64_t dim, uint64_t base_count, float error) { } void BucketInterfaceTest::TestSerializeAndDeserialize(int64_t dim, const BucketInterfacePtr& other) { + other->backend_ = DistanceEvaluationBackend::UNKNOWN; test_serializion(*this->bucket_, *other); + REQUIRE(other->backend_ == SearchStatistics::BackendFromName(other->GetQuantizerName())); int64_t query_count = 100; auto queries = fixtures::generate_vectors(query_count, dim, random()); diff --git a/src/datacell/bucket_interface.h b/src/datacell/bucket_interface.h index a40a87213a..30b88010fd 100644 --- a/src/datacell/bucket_interface.h +++ b/src/datacell/bucket_interface.h @@ -21,6 +21,7 @@ #include "bucket_datacell_parameter.h" #include "index_common_param.h" #include "quantization/computer.h" +#include "query_context.h" #include "storage/stream_reader.h" #include "storage/stream_writer.h" #include "typing.h" @@ -138,6 +139,7 @@ class BucketInterface { uint32_t code_size_{0}; IVFPartitionStrategyPtr strategy_{nullptr}; bool use_residual_{false}; + DistanceEvaluationBackend backend_{DistanceEvaluationBackend::UNKNOWN}; }; } // namespace vsag diff --git a/src/datacell/flatten_datacell.h b/src/datacell/flatten_datacell.h index f029b70188..670d377aad 100644 --- a/src/datacell/flatten_datacell.h +++ b/src/datacell/flatten_datacell.h @@ -179,6 +179,8 @@ class FlattenDataCell : public FlattenInterface { SetQuantizer(std::shared_ptr> quantizer) { this->quantizer_ = quantizer; this->code_size_ = quantizer_->GetCodeSize(); + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*quantizer_)); } inline void @@ -244,6 +246,8 @@ FlattenDataCell::FlattenDataCell(const QuantizerParamPtr& qua this->quantizer_ = std::make_shared(quantization_param, common_param); this->io_ = std::make_shared(io_param, common_param); this->code_size_ = quantizer_->GetCodeSize(); + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*quantizer_)); } template @@ -367,6 +371,8 @@ FlattenDataCell::query(float* result_dists, } computer->ScanBatchDists(id_count, codes.data, result_dists); + if (ctx != nullptr and ctx->stats != nullptr) + ctx->stats->AddDistance(ctx->distance_phase, backend_, id_count); return; } @@ -441,6 +447,8 @@ FlattenDataCell::query(float* result_dists, this->io_->Release(codes); } } + if (ctx != nullptr and ctx->stats != nullptr) + ctx->stats->AddDistance(ctx->distance_phase, backend_, static_cast(id_count)); } template @@ -497,6 +505,8 @@ FlattenDataCell::Deserialize(lvalue_or_rvalue r FlattenInterface::Deserialize(reader); this->io_->Deserialize(reader); this->quantizer_->Deserialize(reader); + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*this->quantizer_)); } template diff --git a/src/datacell/flatten_interface.h b/src/datacell/flatten_interface.h index 6f1a5376f0..10acddcf0f 100644 --- a/src/datacell/flatten_interface.h +++ b/src/datacell/flatten_interface.h @@ -295,6 +295,7 @@ class FlattenInterface { uint32_t code_size_{0}; uint32_t prefetch_stride_code_{1}; uint32_t prefetch_depth_code_{1}; + DistanceEvaluationBackend backend_{DistanceEvaluationBackend::UNKNOWN}; }; } // namespace vsag diff --git a/src/datacell/multi_vector_datacell.inl b/src/datacell/multi_vector_datacell.inl index 70eec97fa6..567a37f23d 100644 --- a/src/datacell/multi_vector_datacell.inl +++ b/src/datacell/multi_vector_datacell.inl @@ -34,6 +34,8 @@ MultiVectorDataCell::MultiVectorDataCell( multi_vector_dim_(static_cast(common_param.dim_)), metric_(common_param.metric_) { this->quantizer_ = std::make_shared(quantization_param, common_param); + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*this->quantizer_)); this->io_ = std::make_shared(io_param, common_param); this->offset_io_ = std::make_shared(Options::Instance().block_size_limit(), allocator_); @@ -177,6 +179,8 @@ MultiVectorDataCell::Deserialize(lvalue_or_rvalueoffset_io_->Deserialize(reader); this->io_->Deserialize(reader); this->quantizer_->Deserialize(reader); + this->backend_ = + QuantizerDistanceBackend::Get(static_cast(*this->quantizer_)); } template @@ -249,6 +253,8 @@ MultiVectorDataCell::Query(float* result_dists, all_codes.data + cursor + sizeof(uint32_t), token_count, result_dists + i); cursor += data_sizes[i]; } + if (ctx != nullptr and ctx->stats != nullptr) + ctx->stats->AddDistance(ctx->distance_phase, backend_, id_count); } template diff --git a/src/datacell/multi_vector_datacell_test.cpp b/src/datacell/multi_vector_datacell_test.cpp index f3068aa48b..4a4a90907b 100644 --- a/src/datacell/multi_vector_datacell_test.cpp +++ b/src/datacell/multi_vector_datacell_test.cpp @@ -259,21 +259,26 @@ TEST_CASE("MultiVectorDataCell Serialize/Deserialize round-trip", "[ut][MultiVec original->Serialize(writer); FlattenInterfacePtr restored = MakeMultiVectorDataCell(io_type, dim, allocator); + restored->backend_ = DistanceEvaluationBackend::UNKNOWN; IOStreamReader reader(ss); restored->Deserialize(reader); REQUIRE(restored->TotalCount() == original->TotalCount()); + REQUIRE(restored->backend_ == DistanceEvaluationBackend::FP32); std::vector dists_after(3, 0.0F); + SearchStatistics stats; + QueryContext ctx{.stats = &stats}; { ComputerInterfacePtr computer = restored->FactoryComputer(&query_mv); - restored->Query(dists_after.data(), - computer, - idx.data(), - static_cast(idx.size()), - nullptr); + restored->Query( + dists_after.data(), computer, idx.data(), static_cast(idx.size()), &ctx); } + JsonType statistics = JsonType::Parse(stats.Dump()); + REQUIRE(statistics["distance_evaluations_by_backend"]["fp32"].GetUint64() == 3); + REQUIRE(statistics["complete"].GetBool()); + for (uint64_t i = 0; i < 3; ++i) { REQUIRE(dists_before[i] == dists_after[i]); } diff --git a/src/datacell/rabitq_split_datacell.h b/src/datacell/rabitq_split_datacell.h index 283d0cf837..5dcef67e84 100644 --- a/src/datacell/rabitq_split_datacell.h +++ b/src/datacell/rabitq_split_datacell.h @@ -202,6 +202,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil QueryContext* ctx = nullptr) override { if (this->optimized_build_active_) { this->query_optimized_build_codes(result_dists, computer, idx, id_count); + this->add_distance_evaluations(ctx, id_count); return; } auto* comp = static_cast>*>(computer.get()); @@ -210,9 +211,11 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil if constexpr (OneBitIOTmpl::InMemory and not SupplementIOTmpl::InMemory) { this->query_full_dist_by_supplement_multiread( result_dists, comp, idx, id_count, ctx); + this->add_distance_evaluations(ctx, id_count); return; } this->query_full_dist_by_multiread(result_dists, comp, idx, id_count, ctx); + this->add_distance_evaluations(ctx, id_count); return; } } @@ -227,6 +230,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil } this->compute_full_dist(idx[i], comp, result_dists + i, ctx); } + this->add_distance_evaluations(ctx, id_count); } void @@ -238,6 +242,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil QueryContext* ctx = nullptr) override { if (this->optimized_build_active_) { this->query_optimized_build_codes(result_dists, computer, idx, id_count); + this->add_distance_evaluations(ctx, id_count); return; } auto* comp = static_cast>*>(computer.get()); @@ -246,10 +251,12 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil if constexpr (OneBitIOTmpl::InMemory and not SupplementIOTmpl::InMemory) { this->query_full_dist_by_supplement_multiread( result_dists, comp, idx, id_count, ctx, hint_dists); + this->add_distance_evaluations(ctx, id_count); return; } this->query_full_dist_by_multiread( result_dists, comp, idx, id_count, ctx, hint_dists); + this->add_distance_evaluations(ctx, id_count); return; } } @@ -266,6 +273,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil hint_dists == nullptr ? std::numeric_limits::max() : hint_dists[i]; this->compute_full_dist(idx[i], comp, result_dists + i, ctx, hint); } + this->add_distance_evaluations(ctx, id_count); } void @@ -277,6 +285,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil QueryContext* ctx = nullptr) override { if (this->optimized_build_active_) { this->query_optimized_build_codes(result_dists, computer, idx, id_count); + this->add_distance_evaluations(ctx, id_count); return; } auto* comp = static_cast>*>(computer.get()); @@ -325,6 +334,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil this->release_one_bit_code(one_bit_code, one_bit_need_release); this->release_supplement_code(supplement_code, supplement_need_release); } + this->add_distance_evaluations(ctx, id_count); } void @@ -339,6 +349,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil if (lower_bounds != nullptr) { std::fill(lower_bounds, lower_bounds + id_count, std::numeric_limits::max()); } + this->add_distance_evaluations(ctx, id_count); return; } auto* comp = static_cast>*>(computer.get()); @@ -347,6 +358,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil if (id_count > 1) { this->query_one_bit_lower_bound_by_multiread( result_dists, lower_bounds, comp, idx, id_count, ctx); + this->add_distance_evaluations(ctx, id_count); return; } } @@ -458,6 +470,7 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil } this->release_one_bit_code(one_bit_code, one_bit_need_release); } + this->add_distance_evaluations(ctx, id_count); } ComputerInterfacePtr @@ -1204,6 +1217,12 @@ class RaBitQSplitDataCell : public FlattenInterface, public FlattenOptimizedBuil return ctx == nullptr ? std::numeric_limits::quiet_NaN() : ctx->rabitq_error_rate; } + void + add_distance_evaluations(QueryContext* ctx, uint64_t count) const { + if (ctx != nullptr and ctx->stats != nullptr and count > 0) + ctx->stats->AddDistance(ctx->distance_phase, DistanceEvaluationBackend::RABITQ, count); + } + void add_filter_count(QueryContext* ctx, uint64_t count) const { if (ctx != nullptr and ctx->stats != nullptr) { diff --git a/src/datacell/sparse_dmq_datacell.cpp b/src/datacell/sparse_dmq_datacell.cpp index a67691f3f6..83b2eba050 100644 --- a/src/datacell/sparse_dmq_datacell.cpp +++ b/src/datacell/sparse_dmq_datacell.cpp @@ -50,7 +50,6 @@ SparseDmqDataCell::Query(float* result_dists, const InnerIdType* idx, InnerIdType id_count, QueryContext* ctx) { - (void)ctx; CHECK_ARGUMENT(result_dists != nullptr, "SparseDmqDataCell result buffer is null"); if (id_count != 0) { CHECK_ARGUMENT(idx != nullptr, "SparseDmqDataCell ids are null"); @@ -61,6 +60,9 @@ SparseDmqDataCell::Query(float* result_dists, for (InnerIdType index = 0; index < id_count; ++index) { dmq_computer->ComputeDist(GetCode(idx[index]), result_dists + index); } + if (ctx != nullptr and ctx->stats != nullptr) { + ctx->stats->AddDistance(ctx->distance_phase, backend_, id_count); + } } ComputerInterfacePtr diff --git a/src/datacell/sparse_dmq_datacell.h b/src/datacell/sparse_dmq_datacell.h index c1bc206b1b..f7785b849f 100644 --- a/src/datacell/sparse_dmq_datacell.h +++ b/src/datacell/sparse_dmq_datacell.h @@ -103,6 +103,7 @@ class SparseDmqDataCell : public FlattenInterface { private: Allocator* allocator_{nullptr}; std::shared_ptr quantizer_; + DistanceEvaluationBackend backend_{DistanceEvaluationBackend::SPARSE_SQ8}; Vector offsets_; Vector codes_; }; diff --git a/src/datacell/sparse_term_datacell.cpp b/src/datacell/sparse_term_datacell.cpp index 922515f6e5..01497af2c4 100644 --- a/src/datacell/sparse_term_datacell.cpp +++ b/src/datacell/sparse_term_datacell.cpp @@ -25,7 +25,24 @@ namespace vsag { void -SparseTermDataCell::Query(float* global_dists, const SparseTermComputerPtr& computer) const { +SparseTermDataCell::QueryWithoutTracking(float* global_dists, + const SparseTermComputerPtr& computer) const { + query_impl(global_dists, computer, nullptr); +} + +uint64_t +SparseTermDataCell::Query(float* global_dists, + const SparseTermComputerPtr& computer, + SparseEvaluationTracker& evaluation_tracker) const { + evaluation_tracker.BeginWindow(); + query_impl(global_dists, computer, &evaluation_tracker); + return evaluation_tracker.Count(); +} + +void +SparseTermDataCell::query_impl(float* global_dists, + const SparseTermComputerPtr& computer, + SparseEvaluationTracker* evaluation_tracker) const { while (computer->HasNextTerm()) { auto it = computer->NextTermIter(); auto term = computer->GetTerm(it); @@ -43,6 +60,9 @@ SparseTermDataCell::Query(float* global_dists, const SparseTermComputerPtr& comp auto term_size = static_cast(static_cast(term_sizes_[term]) * computer->term_retain_ratio_); + if (evaluation_tracker != nullptr) { + evaluation_tracker->Mark(term_ids_[term]->data(), term_size); + } if (sparse_value_quant_type_ == SparseValueQuantizationType::SQ8) { computer->ScanForAccumulateSQ8( diff --git a/src/datacell/sparse_term_datacell.h b/src/datacell/sparse_term_datacell.h index 703fcda63b..57a6ca8442 100644 --- a/src/datacell/sparse_term_datacell.h +++ b/src/datacell/sparse_term_datacell.h @@ -15,6 +15,9 @@ #pragma once +#include +#include + #include "algorithm/sindi/sindi_parameter.h" #include "impl/searcher/basic_searcher.h" #include "quantization/sparse_quantization//sparse_term_computer.h" @@ -26,6 +29,45 @@ namespace vsag { +class SparseEvaluationTracker { +public: + SparseEvaluationTracker(uint32_t capacity, Allocator* allocator) + : generations_(capacity, 0, allocator) { + } + + void + BeginWindow() { + if (generation_ == std::numeric_limits::max()) { + std::fill(generations_.begin(), generations_.end(), 0); + generation_ = 1; + } else { + ++generation_; + } + evaluated_ = 0; + } + + void + Mark(const uint16_t* ids, uint32_t count) { + for (uint32_t i = 0; i < count; ++i) { + auto id = ids[i]; + if (generations_[id] != generation_) { + generations_[id] = generation_; + ++evaluated_; + } + } + } + + [[nodiscard]] uint64_t + Count() const { + return evaluated_; + } + +private: + Vector generations_; + uint16_t generation_{0}; + uint64_t evaluated_{0}; +}; + DEFINE_POINTER(SparseTermDataCell); class SparseTermDataCell { public: @@ -47,7 +89,12 @@ class SparseTermDataCell { } void - Query(float* global_dists, const SparseTermComputerPtr& computer) const; + QueryWithoutTracking(float* global_dists, const SparseTermComputerPtr& computer) const; + + uint64_t + Query(float* global_dists, + const SparseTermComputerPtr& computer, + SparseEvaluationTracker& evaluation_tracker) const; /** * @brief Insert candidates into heap by iterating through term lists @@ -119,6 +166,11 @@ class SparseTermDataCell { GetMemoryUsage() const; private: + void + query_impl(float* global_dists, + const SparseTermComputerPtr& computer, + SparseEvaluationTracker* evaluation_tracker) const; + template void insert_candidate_into_heap(uint32_t id, diff --git a/src/datacell/sparse_term_datacell_test.cpp b/src/datacell/sparse_term_datacell_test.cpp index 3137a7b8f1..925c48ddb8 100644 --- a/src/datacell/sparse_term_datacell_test.cpp +++ b/src/datacell/sparse_term_datacell_test.cpp @@ -132,7 +132,17 @@ TEST_CASE("SparseTermDatacell Basic Test", "[ut][SparseTermDatacell]") { SECTION("test query") { std::vector dists(count_base, 0); - data_cell->Query(dists.data(), computer); + SparseEvaluationTracker evaluation_tracker(count_base, allocator.get()); + const auto evaluated = data_cell->Query(dists.data(), computer, evaluation_tracker); + REQUIRE(evaluated == count_base); + for (auto i = 0; i < dists.size(); i++) { + REQUIRE(std::abs(dists[i] - exp_dists[i]) < 1e-3); + } + std::fill(dists.begin(), dists.end(), 0.0F); + REQUIRE(data_cell->Query(dists.data(), computer, evaluation_tracker) == count_base); + + std::fill(dists.begin(), dists.end(), 0.0F); + data_cell->QueryWithoutTracking(dists.data(), computer); for (auto i = 0; i < dists.size(); i++) { REQUIRE(std::abs(dists[i] - exp_dists[i]) < 1e-3); } @@ -145,7 +155,7 @@ TEST_CASE("SparseTermDatacell Basic Test", "[ut][SparseTermDatacell]") { inner_param.ef = topk; MaxHeap heap(allocator.get()); std::vector dists(count_base, 0); - data_cell->Query(dists.data(), computer); + data_cell->QueryWithoutTracking(dists.data(), computer); data_cell->InsertHeapByTermLists( dists.data(), computer, heap, inner_param, 0); @@ -165,7 +175,7 @@ TEST_CASE("SparseTermDatacell Basic Test", "[ut][SparseTermDatacell]") { } std::vector dists2(count_base, 0); - data_cell->Query(dists2.data(), computer); + data_cell->QueryWithoutTracking(dists2.data(), computer); MaxHeap heap2(allocator.get()); data_cell->InsertHeapByDists( dists2.data(), dists2.size(), heap2, inner_param, 0); @@ -192,7 +202,7 @@ TEST_CASE("SparseTermDatacell Basic Test", "[ut][SparseTermDatacell]") { auto pos = count_base - range_topk - 1; // note that we retrieval dist < dists[pos] InnerSearchParam inner_param; std::vector dists(count_base, 0); - data_cell->Query(dists.data(), computer); + data_cell->QueryWithoutTracking(dists.data(), computer); inner_param.radius = dists[pos]; MaxHeap heap(allocator.get()); @@ -213,7 +223,7 @@ TEST_CASE("SparseTermDatacell Basic Test", "[ut][SparseTermDatacell]") { } std::vector dists2(count_base, 0); - data_cell->Query(dists2.data(), computer); + data_cell->QueryWithoutTracking(dists2.data(), computer); MaxHeap heap2(allocator.get()); data_cell->InsertHeapByDists( dists2.data(), dists2.size(), heap2, inner_param, 0); @@ -320,7 +330,7 @@ TEST_CASE("SparseTermDatacell FP16 Roundtrip Test", "[ut][SparseTermDatacell]") auto computer = std::make_shared(query_sv, search_params, allocator.get()); std::vector dists(4, 0.0F); - data_cell->Query(dists.data(), computer); + data_cell->QueryWithoutTracking(dists.data(), computer); REQUIRE(std::abs(dists[base_id] + 7.5F) < 1e-3F); REQUIRE(std::abs(data_cell->CalcDistanceByInnerId(computer, base_id) - (1.0F - 7.5F)) < 1e-3F); @@ -473,7 +483,7 @@ TEST_CASE("SparseTermDatacell Last Term Test", "[ut][SparseTermDatacell]") { std::make_shared(sv_query, search_params, allocator.get()); std::vector dists(2, 0); - data_cell->Query(dists.data(), computer); + data_cell->QueryWithoutTracking(dists.data(), computer); REQUIRE(std::abs(dists[0] - (-0.1f)) < 1e-2f); REQUIRE(std::abs(dists[1] - (-0.1f)) < 1e-2f); } diff --git a/src/datacell/sparse_vector_datacell.h b/src/datacell/sparse_vector_datacell.h index 19ccce0d2c..194b8a5686 100644 --- a/src/datacell/sparse_vector_datacell.h +++ b/src/datacell/sparse_vector_datacell.h @@ -41,6 +41,8 @@ class SparseVectorDataCell : public FlattenInterface { QueryContext* ctx = nullptr) override { auto comp = std::static_pointer_cast>(computer); this->query(result_dists, comp, idx, id_count); + if (ctx != nullptr and ctx->stats != nullptr and id_count > 0) + ctx->stats->AddDistance(ctx->distance_phase, backend_, id_count); } ComputerInterfacePtr @@ -195,6 +197,7 @@ class SparseVectorDataCell : public FlattenInterface { std::shared_ptr> io_{nullptr}; Allocator* const allocator_{nullptr}; + DistanceEvaluationBackend backend_{DistanceEvaluationBackend::UNKNOWN}; std::shared_ptr offset_io_{nullptr}; uint64_t current_offset_{0}; uint64_t max_code_size_{0}; diff --git a/src/datacell/sparse_vector_datacell.inl b/src/datacell/sparse_vector_datacell.inl index 0e04d94f12..4c9d848343 100644 --- a/src/datacell/sparse_vector_datacell.inl +++ b/src/datacell/sparse_vector_datacell.inl @@ -103,6 +103,8 @@ SparseVectorDataCell::Deserialize(lvalue_or_rvaluequantizer_->Deserialize(reader); + backend_ = + QuantizerDistanceBackend::Get(static_cast(*this->quantizer_)); } template @@ -280,6 +282,7 @@ SparseVectorDataCell::SparseVectorDataCell( const IndexCommonParam& common_param) : allocator_(common_param.allocator_.get()) { this->quantizer_ = std::make_shared(quantization_param, common_param); + this->backend_ = SearchStatistics::BackendFromName(this->quantizer_->Name()); this->io_ = std::make_shared(io_param, common_param); this->offset_io_ = std::make_shared(Options::Instance().block_size_limit(), allocator_); diff --git a/src/datacell/sparse_vector_datacell_test.cpp b/src/datacell/sparse_vector_datacell_test.cpp index d18714093e..25273f6bb8 100644 --- a/src/datacell/sparse_vector_datacell_test.cpp +++ b/src/datacell/sparse_vector_datacell_test.cpp @@ -369,7 +369,12 @@ TEST_CASE("SparseDataCell New Format Sentinel", "[ut][SparseDataCell]") { std::vector all_ids(base_count); std::iota(all_ids.begin(), all_ids.end(), 0); std::vector dist(base_count); - reloaded->Query(dist.data(), computer, all_ids.data(), base_count); + SearchStatistics stats; + QueryContext ctx{.stats = &stats}; + reloaded->Query(dist.data(), computer, all_ids.data(), base_count, &ctx); + JsonType statistics = JsonType::Parse(stats.Dump()); + REQUIRE(statistics["distance_evaluations_by_backend"]["sparse_fp32"].GetUint64() == base_count); + REQUIRE(statistics["complete"].GetBool()); for (uint32_t i = 0; i < base_count; ++i) { fixtures::dist_t expected = fixtures::GetSparseDistance(query_sparse_vectors[0], sparse_vectors[i]); diff --git a/src/impl/distance_provider_for_graph.cpp b/src/impl/distance_provider_for_graph.cpp index eb27cdb870..33a0a3031f 100644 --- a/src/impl/distance_provider_for_graph.cpp +++ b/src/impl/distance_provider_for_graph.cpp @@ -45,11 +45,34 @@ BucketDistanceProvider::BucketDistanceProvider(std::shared_ptr } float -BucketDistanceProvider::QueryDistance(InnerIdType id, QueryContext* /*ctx*/) const { +BucketDistanceProvider::QueryDistance(InnerIdType id, QueryContext* ctx) const { if (not IsValid(id)) { return std::numeric_limits::infinity(); } - return bucket_->QueryOneById(computer_, bucket_id_, id); + const auto distance = bucket_->QueryOneById(computer_, bucket_id_, id); + if (ctx != nullptr and ctx->stats != nullptr) { + ctx->stats->AddDistance(ctx->distance_phase, bucket_->backend_); + } + return distance; +} + +void +BucketDistanceProvider::BatchQueryDistance(float* distances, + const InnerIdType* ids, + InnerIdType count, + QueryContext* ctx) const { + uint64_t evaluated = 0; + for (InnerIdType i = 0; i < count; ++i) { + if (not IsValid(ids[i])) { + distances[i] = std::numeric_limits::infinity(); + continue; + } + distances[i] = bucket_->QueryOneById(computer_, bucket_id_, ids[i]); + ++evaluated; + } + if (ctx != nullptr and ctx->stats != nullptr) { + ctx->stats->AddDistance(ctx->distance_phase, bucket_->backend_, evaluated); + } } float diff --git a/src/impl/distance_provider_for_graph.h b/src/impl/distance_provider_for_graph.h index d962022373..aa7f07815f 100644 --- a/src/impl/distance_provider_for_graph.h +++ b/src/impl/distance_provider_for_graph.h @@ -136,6 +136,12 @@ class BucketDistanceProvider final : public DistanceProviderForGraph { [[nodiscard]] float QueryDistance(InnerIdType id, QueryContext* ctx = nullptr) const override; + void + BatchQueryDistance(float* distances, + const InnerIdType* ids, + InnerIdType count, + QueryContext* ctx = nullptr) const override; + [[nodiscard]] float PairwiseDistance(InnerIdType id1, InnerIdType id2, diff --git a/src/impl/inner_search_param.h b/src/impl/inner_search_param.h index fce948cb4c..643d7b9944 100644 --- a/src/impl/inner_search_param.h +++ b/src/impl/inner_search_param.h @@ -29,6 +29,7 @@ namespace vsag { DEFINE_POINTER(Filter); DEFINE_POINTER(Executor); +struct QueryContext; enum InnerSearchMode { KNN_SEARCH = 1, RANGE_SEARCH = 2 }; @@ -63,6 +64,7 @@ class InnerSearchParam { float first_order_scan_ratio{1.0F}; std::optional distance_threshold{std::nullopt}; std::vector executors; + QueryContext* query_context{nullptr}; // deal with duplicate ids mutable int64_t duplicate_id{-1}; diff --git a/src/impl/reorder/flatten_reorder.cpp b/src/impl/reorder/flatten_reorder.cpp index 5092c8ff7b..cb825497cf 100644 --- a/src/impl/reorder/flatten_reorder.cpp +++ b/src/impl/reorder/flatten_reorder.cpp @@ -84,7 +84,10 @@ FlattenReorder::Reorder(const vsag::DistHeapPtr& input, ids[i] = candidate_result[i].second; } add_reorder_distance_count(ctx, heap_candidate_size); - flatten_->Query(dists.data(), computer, ids.data(), heap_candidate_size, &ctx); + { + ScopedDistancePhase scoped(ctx, DistanceEvaluationPhase::RERANK); + flatten_->Query(dists.data(), computer, ids.data(), heap_candidate_size, &ctx); + } for (uint64_t i = 0; i < heap_candidate_size; ++i) { if (ctx.reasoning_ctx != nullptr) { ctx.reasoning_ctx->RecordReorder( @@ -138,6 +141,7 @@ FlattenReorder::Reorder(const vsag::DistHeapPtr& input, const uint64_t heap_unique_size = candidate_size; if (heap_unique_size > 0) { add_reorder_lower_bound_probe_count(ctx, heap_unique_size); + ScopedDistancePhase scoped_phase(ctx, DistanceEvaluationPhase::RERANK); flatten_->QueryWithDistanceLowerBound(lower_bound_probe_dists.data(), lower_bounds.data(), computer, @@ -176,8 +180,11 @@ FlattenReorder::Reorder(const vsag::DistHeapPtr& input, if (not lower_bounds_available) { add_reorder_distance_count(ctx, candidate_size); - flatten_->Query( - lower_bound_probe_dists.data(), computer, all_ids.data(), candidate_size, &ctx); + { + ScopedDistancePhase scoped(ctx, DistanceEvaluationPhase::RERANK); + flatten_->Query( + lower_bound_probe_dists.data(), computer, all_ids.data(), candidate_size, &ctx); + } for (uint64_t i = 0; i < candidate_size; ++i) { if (ctx.reasoning_ctx != nullptr) { ctx.reasoning_ctx->RecordReorder( @@ -225,8 +232,11 @@ FlattenReorder::Reorder(const vsag::DistHeapPtr& input, : std::numeric_limits::max(); } add_reorder_distance_count(ctx, bootstrap_size); - flatten_->QueryWithDistanceHint( - dists.data(), hint_dists.data(), computer, ids.data(), bootstrap_size, &ctx); + { + ScopedDistancePhase scoped(ctx, DistanceEvaluationPhase::RERANK); + flatten_->QueryWithDistanceHint( + dists.data(), hint_dists.data(), computer, ids.data(), bootstrap_size, &ctx); + } for (uint64_t i = 0; i < bootstrap_size; ++i) { if (ctx.reasoning_ctx != nullptr) { const auto idx = order[i]; @@ -266,8 +276,11 @@ FlattenReorder::Reorder(const vsag::DistHeapPtr& input, } add_reorder_distance_count(ctx, batch_count); - flatten_->QueryWithDistanceHint( - dists.data(), hint_dists.data(), computer, ids.data(), batch_count, &ctx); + { + ScopedDistancePhase scoped(ctx, DistanceEvaluationPhase::RERANK); + flatten_->QueryWithDistanceHint( + dists.data(), hint_dists.data(), computer, ids.data(), batch_count, &ctx); + } for (uint64_t i = 0; i < batch_count; ++i) { if (ctx.reasoning_ctx != nullptr) { ctx.reasoning_ctx->RecordReorder( diff --git a/src/impl/searcher/basic_searcher.cpp b/src/impl/searcher/basic_searcher.cpp index 16e39c635d..043115953d 100644 --- a/src/impl/searcher/basic_searcher.cpp +++ b/src/impl/searcher/basic_searcher.cpp @@ -633,6 +633,10 @@ BasicSearcher::search_impl(const GraphInterfacePtr& graph, } inner_search_param.distance_batch_func( custom_labels.data(), batch_count, scores + offset); + if (ctx != nullptr and ctx->stats != nullptr) { + ctx->stats->AddDistance( + ctx->distance_phase, DistanceEvaluationBackend::UNKNOWN, batch_count); + } for (uint64_t i = 0; i < batch_count; ++i) { CHECK_ARGUMENT(std::isfinite(scores[offset + i]), "distance callback must return finite scores"); diff --git a/src/impl/searcher/mci_searcher.cpp b/src/impl/searcher/mci_searcher.cpp index 7bd9cd5f6a..3eef1e7c22 100644 --- a/src/impl/searcher/mci_searcher.cpp +++ b/src/impl/searcher/mci_searcher.cpp @@ -228,6 +228,9 @@ search_precise_float_csr(const CliqueDataCellBaseView& view, if (ctx != nullptr and ctx->stats != nullptr) { ctx->stats->dist_cmp.fetch_add(dist_cmp, std::memory_order_relaxed); ctx->stats->hops.fetch_add(hops, std::memory_order_relaxed); + ctx->stats->AddDistance(SearchStatistics::DistancePhase::APPROXIMATE, + DistanceEvaluationBackend::FP32, + dist_cmp); } return result_heap; } diff --git a/src/index/index_impl.h b/src/index/index_impl.h index f0451c7242..87e135ef3f 100644 --- a/src/index/index_impl.h +++ b/src/index/index_impl.h @@ -19,6 +19,7 @@ #include "algorithm/inner_index_interface.h" #include "common.h" #include "index_common_param.h" +#include "query_context.h" #include "utils/search_threshold.h" #include "vsag/index.h" namespace vsag { @@ -57,7 +58,7 @@ class IndexImpl : public Index { #define CHECK_QUERY_RETURN_EMPTY_DATASET(query) \ if ((query)->GetNumElements() == 0) { \ - return DatasetImpl::MakeEmptyDataset(); \ + return make_empty_search_result(); \ } #define CHECK_IMMUTABLE_INDEX(operation_str) \ if (this->inner_index_->immutable_.load(std::memory_order_acquire)) { \ @@ -309,7 +310,7 @@ class IndexImpl : public Index { } CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->KnnSearch(query, k, parameters, invalid)); } @@ -325,7 +326,7 @@ class IndexImpl : public Index { } CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->KnnSearch(query, k, parameters, filter)); } @@ -341,7 +342,7 @@ class IndexImpl : public Index { } CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->KnnSearch(query, k, parameters, filter)); } @@ -354,7 +355,7 @@ class IndexImpl : public Index { } CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(search_param.parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } if (search_param.is_iter_filter) { SAFE_CALL(return this->inner_index_->KnnSearch(query, @@ -383,7 +384,7 @@ class IndexImpl : public Index { } CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->KnnSearch( query, k, parameters, filter, nullptr, iter_ctx, is_last_filter)); @@ -422,7 +423,7 @@ class IndexImpl : public Index { int64_t limited_size = -1) const override { CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->RangeSearch(query, radius, parameters, limited_size)); } @@ -435,7 +436,7 @@ class IndexImpl : public Index { int64_t limited_size = -1) const override { CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->RangeSearch( query, radius, parameters, invalid, limited_size)); @@ -449,7 +450,7 @@ class IndexImpl : public Index { int64_t limited_size = -1) const override { CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->RangeSearch( query, radius, parameters, filter, limited_size)); @@ -463,7 +464,7 @@ class IndexImpl : public Index { int64_t limited_size = -1) const override { CHECK_QUERY_RETURN_EMPTY_DATASET(query); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(parameters)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } SAFE_CALL(return this->inner_index_->RangeSearch( query, radius, parameters, filter, limited_size)); @@ -499,7 +500,7 @@ class IndexImpl : public Index { SearchWithRequest(const SearchRequest& request) const override { SAFE_CALL(ValidateSearchThreshold(request.threshold_); if (GetNumElements() == 0 && !this->ShouldSkipEmptyCheck(request.params_str_)) { - return DatasetImpl::MakeEmptyDataset(); + return make_empty_search_result(); } return this->inner_index_->SearchWithRequest(request)); } @@ -590,6 +591,26 @@ class IndexImpl : public Index { } } + DatasetPtr + make_empty_search_result() const { + auto result = DatasetImpl::MakeEmptyDataset(); + switch (GetIndexType()) { + case IndexType::HGRAPH: + case IndexType::IVF: + case IndexType::PYRAMID: + case IndexType::BRUTEFORCE: + case IndexType::SINDI: + case IndexType::SIMQ: { + SearchStatistics statistics; + result->Statistics(statistics.Dump()); + break; + } + default: + break; + } + return result; + } + bool ShouldSkipEmptyCheck(const std::string& params_str) const { if (GetNumElements() != 0 || params_str.empty()) { diff --git a/src/index/index_impl_test.cpp b/src/index/index_impl_test.cpp index be8083044b..ea45060b9a 100644 --- a/src/index/index_impl_test.cpp +++ b/src/index/index_impl_test.cpp @@ -148,42 +148,56 @@ TEST_CASE("index empty input test", "[ut][index_impl]") { vsag::FilterPtr filter_ptr = nullptr; vsag::IteratorContext* iter_ctx = nullptr; vsag::SearchParam search_param(true, parameters, filter_ptr, nullptr); + auto require_zero_statistics = [](const vsag::DatasetPtr& result) { + auto statistics = vsag::JsonType::Parse(result->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() == 0); + REQUIRE(statistics["complete"].GetBool()); + }; auto search_result = index->KnnSearch(query, k, parameters, invalid); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->KnnSearch(query, k, parameters, filter); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->KnnSearch(query, k, parameters, filter_ptr); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->KnnSearch(query, k, search_param); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->KnnSearch(query, k, parameters, filter_ptr, iter_ctx, true); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->RangeSearch(query, radius, parameters, limited_size); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->RangeSearch(query, radius, parameters, invalid, limited_size); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->RangeSearch(query, radius, parameters, filter, limited_size); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); search_result = index->RangeSearch(query, radius, parameters, filter_ptr, limited_size); REQUIRE(search_result.has_value()); REQUIRE(search_result.value()->GetDim() == 0); + require_zero_statistics(search_result.value()); } class IdentifyAllocator : public vsag::Allocator { diff --git a/src/quantization/transform_quantization/transform_quantizer_test.cpp b/src/quantization/transform_quantization/transform_quantizer_test.cpp index d9d7ddb915..6f19817962 100644 --- a/src/quantization/transform_quantization/transform_quantizer_test.cpp +++ b/src/quantization/transform_quantization/transform_quantizer_test.cpp @@ -20,6 +20,7 @@ #include "impl/allocator/safe_allocator.h" #include "index_common_param.h" #include "quantization/quantizer_test.h" +#include "query_context.h" #include "unittest.h" using namespace vsag; @@ -48,6 +49,8 @@ TestComputeMetricTQ(std::string tq_chain, uint64_t dim, int count, float error = TransformQuantizer quantizer(param, common_param); REQUIRE(quantizer.NameImpl() == QUANTIZATION_TYPE_VALUE_TQ); + REQUIRE(QuantizerDistanceBackend>::Get(quantizer) == + DistanceEvaluationBackend::FP32); TestComputeCodes, metric>(quantizer, dim, count, error); TestComputer, metric>(quantizer, dim, count, error); } diff --git a/src/query_context.h b/src/query_context.h index e2e6b3462a..c358b2fc26 100644 --- a/src/query_context.h +++ b/src/query_context.h @@ -15,27 +15,196 @@ #pragma once +#include +#include #include +#include #include #include +#include +#include "metric_type.h" #include "typing.h" #include "vsag/allocator.h" namespace vsag { +enum class DistanceEvaluationPhase : uint8_t { ROUTING = 0, APPROXIMATE = 1, RERANK = 2 }; + +enum class DistanceEvaluationBackend : uint8_t { + FP32 = 0, + FP16, + BF16, + INT8, + SQ8, + SQ4, + SQ8_UNIFORM, + SQ4_UNIFORM, + PQ, + PQ_FASTSCAN, + RABITQ, + BINARY, + SPARSE_FP32, + SPARSE_FP16, + SPARSE_SQ8, + UNKNOWN, +}; + class SearchStatistics; class ReasoningContext; +template +class TransformQuantizer; struct QueryContext { Allocator* alloc = nullptr; SearchStatistics* stats = nullptr; ReasoningContext* reasoning_ctx = nullptr; float rabitq_error_rate = std::numeric_limits::quiet_NaN(); + DistanceEvaluationPhase distance_phase = DistanceEvaluationPhase::APPROXIMATE; +}; + +class ScopedDistancePhase { +public: + ScopedDistancePhase(QueryContext& context, DistanceEvaluationPhase phase) + : context_(context), previous_phase_(context.distance_phase) { + context_.distance_phase = phase; + } + + ~ScopedDistancePhase() { + context_.distance_phase = previous_phase_; + } + + ScopedDistancePhase(const ScopedDistancePhase&) = delete; + ScopedDistancePhase& + operator=(const ScopedDistancePhase&) = delete; + +private: + QueryContext& context_; + DistanceEvaluationPhase previous_phase_; }; class SearchStatistics { public: + using DistancePhase = DistanceEvaluationPhase; + + static const char* + PhaseName(DistancePhase phase) { + switch (phase) { + case DistancePhase::ROUTING: + return "routing"; + case DistancePhase::APPROXIMATE: + return "approximate"; + case DistancePhase::RERANK: + return "rerank"; + default: + return "approximate"; + } + } + + static DistanceEvaluationBackend + BackendFromName(const std::string& name) { + // Quantizer names may be decorated (for example, QUANTIZATION_ADAPTER_sq8_uniform), so + // exact matching is insufficient. Keep overlapping families in most-specific-first order. + if (name.find("sparse") != std::string::npos) { + if (name.find("sq8") != std::string::npos) + return DistanceEvaluationBackend::SPARSE_SQ8; + if (name.find("fp16") != std::string::npos) + return DistanceEvaluationBackend::SPARSE_FP16; + return DistanceEvaluationBackend::SPARSE_FP32; + } + if (name.find("rabitq") != std::string::npos) + return DistanceEvaluationBackend::RABITQ; + if (name.find("pq_fastscan") != std::string::npos || name.find("pqfs") != std::string::npos) + return DistanceEvaluationBackend::PQ_FASTSCAN; + if (name.find("pq") != std::string::npos) + return DistanceEvaluationBackend::PQ; + if (name.find("sq8_uniform") != std::string::npos) + return DistanceEvaluationBackend::SQ8_UNIFORM; + if (name.find("sq4_uniform") != std::string::npos) + return DistanceEvaluationBackend::SQ4_UNIFORM; + if (name.find("sq8") != std::string::npos) + return DistanceEvaluationBackend::SQ8; + if (name.find("sq4") != std::string::npos) + return DistanceEvaluationBackend::SQ4; + if (name.find("bf16") != std::string::npos) + return DistanceEvaluationBackend::BF16; + if (name.find("fp16") != std::string::npos) + return DistanceEvaluationBackend::FP16; + if (name.find("int8") != std::string::npos) + return DistanceEvaluationBackend::INT8; + if (name.find("binary") != std::string::npos) + return DistanceEvaluationBackend::BINARY; + if (name.find("fp32") != std::string::npos) + return DistanceEvaluationBackend::FP32; + return DistanceEvaluationBackend::UNKNOWN; + } + + static const char* + BackendName(DistanceEvaluationBackend backend) { + static constexpr const char* names[] = {"fp32", + "fp16", + "bf16", + "int8", + "sq8", + "sq4", + "sq8_uniform", + "sq4_uniform", + "pq", + "pq_fastscan", + "rabitq", + "binary", + "sparse_fp32", + "sparse_fp16", + "sparse_sq8", + "unknown"}; + return names[static_cast(backend)]; + } + + static bool + SaturatingAdd(std::atomic& value, uint64_t amount) { + if (amount == 0) { + return false; + } + auto current = value.load(std::memory_order_relaxed); + while (true) { + const bool overflowed = std::numeric_limits::max() - current < amount; + auto next = overflowed ? std::numeric_limits::max() : current + amount; + if (value.compare_exchange_weak( + current, next, std::memory_order_relaxed, std::memory_order_relaxed)) + return overflowed; + } + } + + void + AddDistance(DistancePhase phase, DistanceEvaluationBackend backend, uint64_t count = 1) { + if (count == 0) { + return; + } + bool overflowed = SaturatingAdd(distance_evaluations, count); + overflowed = + SaturatingAdd(distance_evaluations_by_phase[static_cast(phase)], count) or + overflowed; + auto backend_index = static_cast(backend); + overflowed = + SaturatingAdd(distance_evaluations_by_backend[backend_index], count) or overflowed; + if (overflowed) { + complete.store(false, std::memory_order_relaxed); + } + if (backend == DistanceEvaluationBackend::UNKNOWN) { + complete.store(false, std::memory_order_relaxed); + } + } + + void + AddDistance(DistancePhase phase, const char* backend, uint64_t count = 1) { + AddDistance(phase, BackendFromName(backend), count); + } + + void + AddDistance(DistancePhase phase, const std::string& backend, uint64_t count = 1) { + AddDistance(phase, BackendFromName(backend), count); + } + [[nodiscard]] JsonType ToJson() const { JsonType j; @@ -55,6 +224,17 @@ class SearchStatistics { rabitq_reorder_hint_full_count.load(std::memory_order_relaxed)); j["rabitq_reorder_fallback_full_count"].SetInt( rabitq_reorder_fallback_full_count.load(std::memory_order_relaxed)); + j["distance_evaluations"].SetUint64(distance_evaluations.load(std::memory_order_relaxed)); + for (size_t i = 0; i < 3; ++i) { + j["distance_evaluations_by_phase"][PhaseName(static_cast(i))].SetUint64( + distance_evaluations_by_phase[i].load(std::memory_order_relaxed)); + } + for (size_t i = 0; i < distance_evaluations_by_backend.size(); ++i) { + const auto backend = static_cast(i); + j["distance_evaluations_by_backend"][BackendName(backend)].SetUint64( + distance_evaluations_by_backend[i].load(std::memory_order_relaxed)); + } + j["complete"].SetBool(complete.load(std::memory_order_relaxed)); return j; } @@ -76,6 +256,26 @@ class SearchStatistics { std::atomic rabitq_filter_fallback_full_count{0}; std::atomic rabitq_reorder_hint_full_count{0}; std::atomic rabitq_reorder_fallback_full_count{0}; + std::atomic distance_evaluations{0}; + std::array, 3> distance_evaluations_by_phase{}; + std::array, 16> distance_evaluations_by_backend{}; + std::atomic complete{true}; +}; + +template +struct QuantizerDistanceBackend { + static DistanceEvaluationBackend + Get(const QuantTmpl& quantizer) { + return SearchStatistics::BackendFromName(quantizer.Name()); + } +}; + +template +struct QuantizerDistanceBackend> { + static DistanceEvaluationBackend + Get(const TransformQuantizer& quantizer) { + return SearchStatistics::BackendFromName(quantizer.quantizer_->Name()); + } }; inline Allocator* diff --git a/src/query_context_test.cpp b/src/query_context_test.cpp new file mode 100644 index 0000000000..e5f066a422 --- /dev/null +++ b/src/query_context_test.cpp @@ -0,0 +1,90 @@ +// Copyright 2024-present the vsag project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +#include "query_context.h" + +#include +#include + +#include "unittest.h" + +TEST_CASE("SearchStatistics distance contract", "[ut][search_statistics]") { + vsag::SearchStatistics stats; + stats.AddDistance(vsag::SearchStatistics::DistancePhase::ROUTING, "fp32", 2); + stats.AddDistance(vsag::SearchStatistics::DistancePhase::APPROXIMATE, "sq8", 3); + stats.AddDistance(vsag::SearchStatistics::DistancePhase::RERANK, "fp32", 1); + + auto json = vsag::JsonType::Parse(stats.Dump()); + CHECK(json["distance_evaluations"].GetUint64() == 6); + CHECK(json["distance_evaluations_by_phase"]["routing"].GetUint64() == 2); + CHECK(json["distance_evaluations_by_phase"]["approximate"].GetUint64() == 3); + CHECK(json["distance_evaluations_by_phase"]["rerank"].GetUint64() == 1); + CHECK(json["distance_evaluations_by_backend"]["fp32"].GetUint64() == 3); + CHECK(json["distance_evaluations_by_backend"]["sq8"].GetUint64() == 3); + CHECK(json["distance_evaluations_by_phase"]["routing"].GetUint64() + + json["distance_evaluations_by_phase"]["approximate"].GetUint64() + + json["distance_evaluations_by_phase"]["rerank"].GetUint64() == + json["distance_evaluations"].GetUint64()); + CHECK(json["distance_evaluations_by_backend"]["fp32"].GetUint64() + + json["distance_evaluations_by_backend"]["sq8"].GetUint64() == + json["distance_evaluations"].GetUint64()); + CHECK(json["complete"].GetBool()); +} + +TEST_CASE("SearchStatistics unknown backend is incomplete", "[ut][search_statistics]") { + vsag::SearchStatistics stats; + stats.AddDistance(vsag::SearchStatistics::DistancePhase::APPROXIMATE, "future_backend", 0); + auto json = vsag::JsonType::Parse(stats.Dump()); + CHECK(json["distance_evaluations"].GetUint64() == 0); + CHECK(json["distance_evaluations_by_backend"]["unknown"].GetUint64() == 0); + CHECK(json["complete"].GetBool()); + + stats.AddDistance(vsag::SearchStatistics::DistancePhase::APPROXIMATE, "future_backend", 4); + json = vsag::JsonType::Parse(stats.Dump()); + CHECK(json["distance_evaluations"].GetUint64() == 4); + CHECK(json["distance_evaluations_by_backend"]["unknown"].GetUint64() == 4); + CHECK_FALSE(json["complete"].GetBool()); +} + +TEST_CASE("SearchStatistics addition saturates", "[ut][search_statistics]") { + vsag::SearchStatistics stats; + stats.AddDistance(vsag::SearchStatistics::DistancePhase::APPROXIMATE, + "fp32", + std::numeric_limits::max()); + CHECK(vsag::SearchStatistics::SaturatingAdd(stats.distance_evaluations, 0) == false); + CHECK(stats.complete.load()); + stats.AddDistance(vsag::SearchStatistics::DistancePhase::APPROXIMATE, "fp32", 1); + auto json = vsag::JsonType::Parse(stats.Dump()); + CHECK(json["distance_evaluations"].GetUint64() == std::numeric_limits::max()); + CHECK_FALSE(json["complete"].GetBool()); +} + +TEST_CASE("SearchStatistics classifies stable backend names", "[ut][search_statistics]") { + CHECK(vsag::SearchStatistics::BackendFromName("pqfs") == + vsag::DistanceEvaluationBackend::PQ_FASTSCAN); + CHECK(vsag::SearchStatistics::BackendFromName("pq_fastscan") == + vsag::DistanceEvaluationBackend::PQ_FASTSCAN); + CHECK(vsag::SearchStatistics::BackendName(vsag::DistanceEvaluationBackend::PQ_FASTSCAN) == + std::string("pq_fastscan")); + CHECK(vsag::SearchStatistics::BackendFromName("int8") == vsag::DistanceEvaluationBackend::INT8); + CHECK(vsag::SearchStatistics::BackendFromName("QUANTIZATION_ADAPTER_sq8_uniform") == + vsag::DistanceEvaluationBackend::SQ8_UNIFORM); + CHECK(vsag::SearchStatistics::BackendFromName("QUANTIZATION_ADAPTER_pq_fastscan") == + vsag::DistanceEvaluationBackend::PQ_FASTSCAN); + CHECK(vsag::SearchStatistics::BackendFromName("float_custom") == + vsag::DistanceEvaluationBackend::UNKNOWN); +} + +TEST_CASE("ScopedDistancePhase restores after exceptions", "[ut][search_statistics]") { + vsag::QueryContext context; + context.distance_phase = vsag::DistanceEvaluationPhase::ROUTING; + try { + vsag::ScopedDistancePhase scoped(context, vsag::DistanceEvaluationPhase::RERANK); + CHECK(context.distance_phase == vsag::DistanceEvaluationPhase::RERANK); + throw std::runtime_error("injected failure"); + } catch (const std::runtime_error&) { + } + CHECK(context.distance_phase == vsag::DistanceEvaluationPhase::ROUTING); +} diff --git a/src/vsag_c_api.cpp b/src/vsag_c_api.cpp index f9734893a2..c8ba6701fe 100644 --- a/src/vsag_c_api.cpp +++ b/src/vsag_c_api.cpp @@ -20,8 +20,14 @@ #include #include #include +#include +#include +#include #include +#include #include +#include +#include Error_t success = {VSAG_SUCCESS, "success"}; @@ -60,6 +66,120 @@ make_error(const std::string& msg) { return err; } +namespace { +std::mutex statistics_mutex; +std::unordered_set statistics_requests; +std::unordered_map> statistics_results; + +void +cancel_statistics_request(SearchResult_t* search_result) noexcept { + if (search_result == nullptr) { + return; + } + try { + std::lock_guard lock(statistics_mutex); + if (statistics_requests.erase(search_result) != 0) { + search_result->other_result = nullptr; + } + } catch (...) { + } +} +} // namespace + +Error_t +vsag_search_result_enable_statistics(SearchResult_t* search_result) { + try { + if (search_result == nullptr) { + return make_error("search result is NULL"); + } + std::lock_guard lock(statistics_mutex); + if (statistics_requests.count(search_result) != 0 || + statistics_results.count(search_result) != 0) { + return make_error("search result statistics are already enabled"); + } + statistics_requests.insert(search_result); + search_result->other_result = nullptr; + return success; + } catch (const std::exception& e) { + return make_error(e); + } catch (...) { + return make_error("unknown error while enabling search statistics"); + } +} + +const char* +vsag_search_result_get_statistics(const SearchResult_t* search_result) { + try { + if (search_result == nullptr) { + return nullptr; + } + std::lock_guard lock(statistics_mutex); + auto it = statistics_results.find(search_result); + if (it == statistics_results.end()) { + return nullptr; + } + return it->second->c_str(); + } catch (...) { + return nullptr; + } +} + +void +vsag_search_result_destroy_statistics(SearchResult_t* search_result) { + try { + if (search_result != nullptr) { + std::lock_guard lock(statistics_mutex); + const bool registered = statistics_requests.erase(search_result) != 0 || + statistics_results.erase(search_result) != 0; + if (registered) { + search_result->other_result = nullptr; + } + } + } catch (...) { + } +} + +static void +attach_statistics(SearchResult_t* search_result, const vsag::DatasetPtr& dataset) { + if (dataset == nullptr) { + throw std::invalid_argument("search result dataset is NULL"); + } + std::lock_guard lock(statistics_mutex); + auto request = statistics_requests.find(search_result); + if (request == statistics_requests.end()) { + return; + } + auto statistics = std::make_unique(dataset->GetStatistics()); + auto [it, inserted] = statistics_results.emplace(search_result, std::move(statistics)); + if (not inserted) { + throw std::runtime_error("search result statistics already exist"); + } + search_result->other_result = it->second.get(); + statistics_requests.erase(request); +} + +class StatisticsRequestGuard { +public: + explicit StatisticsRequestGuard(SearchResult_t* search_result) : search_result_(search_result) { + } + + ~StatisticsRequestGuard() { + if (not consumed_) { + cancel_statistics_request(search_result_); + } + } + + void + Attach(const vsag::DatasetPtr& dataset) { + attach_statistics(search_result_, dataset); + consumed_ = true; + } + +private: + SearchResult_t* search_result_{nullptr}; + bool consumed_{false}; +}; + #define VSAG_CHECK_RESULT(expr) \ do { \ auto _vsag_result = (expr); \ @@ -172,6 +292,10 @@ vsag_index_knn_search(vsag_index_t index, const char* parameters, SearchResult_t* search_result) { try { + if (search_result == nullptr) { + return make_error("search result is NULL"); + } + StatisticsRequestGuard statistics_guard(search_result); auto* vsag_index = static_cast(index); if (vsag_index != nullptr) { if (k <= 0) { @@ -196,6 +320,7 @@ vsag_index_knn_search(vsag_index_t index, search_result->dists[i] = dists_view[i]; } search_result->count = to_write; + statistics_guard.Attach(result.value()); } else { return make_error(result.error()); } @@ -230,6 +355,10 @@ vsag_index_knn_search_with_filter(vsag_index_t index, FilterFunc_t filter, SearchResult_t* search_result) { try { + if (search_result == nullptr) { + return make_error("search result is NULL"); + } + StatisticsRequestGuard statistics_guard(search_result); auto* vsag_index = static_cast(index); if (vsag_index != nullptr) { if (k <= 0) { @@ -255,6 +384,7 @@ vsag_index_knn_search_with_filter(vsag_index_t index, search_result->dists[i] = dists_view[i]; } search_result->count = to_write; + statistics_guard.Attach(result.value()); } else { return make_error(result.error()); } @@ -275,6 +405,10 @@ vsag_index_range_search(vsag_index_t index, const char* parameters, SearchResult_t* search_result) { try { + if (search_result == nullptr) { + return make_error("search result is NULL"); + } + StatisticsRequestGuard statistics_guard(search_result); auto* vsag_index = static_cast(index); if (vsag_index != nullptr) { if (k <= 0) { @@ -302,6 +436,7 @@ vsag_index_range_search(vsag_index_t index, search_result->dists[i] = dists_view[i]; } search_result->count = to_write; + statistics_guard.Attach(result.value()); } else { return make_error(result.error()); } @@ -322,6 +457,10 @@ vsag_index_range_search_with_filter(vsag_index_t index, FilterFunc_t filter, SearchResult_t* search_result) { try { + if (search_result == nullptr) { + return make_error("search result is NULL"); + } + StatisticsRequestGuard statistics_guard(search_result); auto* vsag_index = static_cast(index); if (vsag_index != nullptr) { if (k <= 0) { @@ -351,6 +490,7 @@ vsag_index_range_search_with_filter(vsag_index_t index, search_result->dists[i] = dists_view[i]; } search_result->count = to_write; + statistics_guard.Attach(result.value()); } else { return make_error(result.error()); } diff --git a/src/vsag_c_api_test.cpp b/src/vsag_c_api_test.cpp index 86da023590..3479a6dfe7 100644 --- a/src/vsag_c_api_test.cpp +++ b/src/vsag_c_api_test.cpp @@ -75,6 +75,7 @@ class VsagTestCase { SearchResult_t result; result.dists = scores.data(); result.ids = results.data(); + result.other_result = nullptr; for (int i = 0; i < num_vectors; ++i) { auto ret = vsag_index_knn_search( index, datas.data() + i * dim, dim, topk, hgraph_search_parameters, &result); @@ -155,6 +156,25 @@ TEST_CASE("vsag_c_api basic test", "[vsag_c_api][ut]") { vsag_index_destroy(index); } +TEST_CASE("vsag_c_api statistics are opt-in and owned", "[vsag_c_api][ut]") { + auto index = vsag_index_factory(index_name, index_param); + REQUIRE(index != nullptr); + VsagTestCase test_case; + REQUIRE(vsag_index_build(index, test_case.datas.data(), test_case.ids.data(), dim, num_vectors) + .code == VSAG_SUCCESS); + std::vector ids(1); + std::vector dists(1); + SearchResult_t result{dists.data(), ids.data(), 0, nullptr}; + REQUIRE(vsag_search_result_enable_statistics(&result).code == VSAG_SUCCESS); + REQUIRE(vsag_index_knn_search( + index, test_case.datas.data(), dim, 1, hgraph_search_parameters, &result) + .code == VSAG_SUCCESS); + REQUIRE(vsag_search_result_get_statistics(&result) != nullptr); + vsag_search_result_destroy_statistics(&result); + REQUIRE(vsag_search_result_get_statistics(&result) == nullptr); + vsag_index_destroy(index); +} + TEST_CASE("vsag_c_api factory and destroy", "[vsag_c_api][ut]") { // Test factory with valid parameters auto index = vsag_index_factory(index_name, index_param); @@ -549,6 +569,30 @@ TEST_CASE("vsag_c_api search error paths", "[vsag_c_api][ut]") { auto filter_func = [](int64_t id) -> bool { return id >= 0; }; + REQUIRE(vsag_index_knn_search( + index, test_case.datas.data(), dim, topk, hgraph_search_parameters, nullptr) + .code != VSAG_SUCCESS); + REQUIRE(vsag_index_knn_search_with_filter(index, + test_case.datas.data(), + dim, + topk, + hgraph_search_parameters, + filter_func, + nullptr) + .code != VSAG_SUCCESS); + REQUIRE(vsag_index_range_search( + index, test_case.datas.data(), dim, 10.0F, topk, hgraph_search_parameters, nullptr) + .code != VSAG_SUCCESS); + REQUIRE(vsag_index_range_search_with_filter(index, + test_case.datas.data(), + dim, + 10.0F, + topk, + hgraph_search_parameters, + filter_func, + nullptr) + .code != VSAG_SUCCESS); + constexpr const char* invalid_search_parameters = "not-json"; ret = vsag_index_knn_search( index, test_case.datas.data(), dim, topk, invalid_search_parameters, &result); @@ -705,6 +749,87 @@ TEST_CASE("vsag_c_api range search k validation", "[vsag_c_api][ut]") { vsag_index_destroy(index); } +TEST_CASE("vsag_c_api search statistics ownership", "[vsag_c_api][ut]") { + REQUIRE(vsag_search_result_get_statistics(nullptr) == nullptr); + + auto index = vsag_index_factory(index_name, index_param); + REQUIRE(index != nullptr); + VsagTestCase test_case; + auto ret = + vsag_index_build(index, test_case.datas.data(), test_case.ids.data(), dim, num_vectors); + REQUIRE(ret.code == VSAG_SUCCESS); + + std::vector ids(3); + std::vector dists(3); + SearchResult_t result{.dists = dists.data(), .ids = ids.data()}; + REQUIRE(vsag_search_result_enable_statistics(&result).code == VSAG_SUCCESS); + ret = vsag_index_knn_search( + index, test_case.datas.data(), dim, 3, hgraph_search_parameters, &result); + REQUIRE(ret.code == VSAG_SUCCESS); + REQUIRE(vsag_search_result_get_statistics(&result) != nullptr); + vsag_search_result_destroy_statistics(&result); + REQUIRE(result.other_result == nullptr); + REQUIRE(vsag_search_result_get_statistics(&result) == nullptr); + + vsag_index_destroy(index); +} + +TEST_CASE("vsag_c_api statistics opt-in tolerates legacy uninitialized storage", + "[vsag_c_api][ut]") { + std::vector ids(1); + std::vector dists(1); + SearchResult_t result; + result.dists = dists.data(); + result.ids = ids.data(); + result.count = 0; + + REQUIRE(vsag_search_result_enable_statistics(&result).code == VSAG_SUCCESS); + vsag_search_result_destroy_statistics(&result); + REQUIRE(result.other_result == nullptr); +} + +TEST_CASE("vsag_c_api failed searches consume statistics opt-in", "[vsag_c_api][ut]") { + auto index = vsag_index_factory(index_name, index_param); + REQUIRE(index != nullptr); + VsagTestCase test_case; + REQUIRE(vsag_index_build(index, test_case.datas.data(), test_case.ids.data(), dim, num_vectors) + .code == VSAG_SUCCESS); + + std::vector ids(1); + std::vector dists(1); + SearchResult_t result{.dists = dists.data(), .ids = ids.data()}; + auto filter = [](int64_t) { return true; }; + + auto require_consumed = [&](auto&& search) { + REQUIRE(vsag_search_result_enable_statistics(&result).code == VSAG_SUCCESS); + REQUIRE(search().code != VSAG_SUCCESS); + REQUIRE(result.other_result == nullptr); + REQUIRE(vsag_search_result_enable_statistics(&result).code == VSAG_SUCCESS); + vsag_search_result_destroy_statistics(&result); + }; + + require_consumed([&] { + return vsag_index_knn_search(index, test_case.datas.data(), dim, 0, "{}", &result); + }); + require_consumed([&] { + return vsag_index_knn_search_with_filter( + index, test_case.datas.data(), dim, 0, "{}", filter, &result); + }); + require_consumed([&] { + return vsag_index_range_search(index, test_case.datas.data(), dim, 1.0F, 0, "{}", &result); + }); + require_consumed([&] { + return vsag_index_range_search_with_filter( + index, test_case.datas.data(), dim, 1.0F, 0, "{}", filter, &result); + }); + require_consumed([&] { + return vsag_index_knn_search( + index, test_case.datas.data(), dim, 1, "not valid JSON", &result); + }); + + vsag_index_destroy(index); +} + TEST_CASE("vsag_c_api serialize/deserialize invalid path", "[vsag_c_api][ut]") { auto index = vsag_index_factory(index_name, index_param); REQUIRE(index != nullptr); diff --git a/tests/python/test_hgraph.py b/tests/python/test_hgraph.py index 7d00caf998..02f6fa3ed2 100644 --- a/tests/python/test_hgraph.py +++ b/tests/python/test_hgraph.py @@ -71,6 +71,49 @@ def test_hgraph_build(dim, metric, quantization_type, expect_recall, dataset_fac verify_knn_search(index, dataset, search_params, expect_recall=expect_recall) +def test_hgraph_knn_search_with_statistics(dataset_factory): + """Test opt-in statistics results and error propagation.""" + dim = 32 + dataset = dataset_factory(dim=dim, num_vectors=64, metric="ip") + index = create_index(TYPE_NAME, _create_index_params(dim, "ip", "fp32")) + build_index(index, dataset) + query = dataset.query_vectors[:dim] + search_params = json.dumps({"hgraph": {"ef_search": 32}}) + + ids, distances, statistics_json = index.knn_search_with_statistics( + query, 5, search_params + ) + statistics = json.loads(statistics_json) + assert ids.shape == (5,) + assert distances.shape == (5,) + assert statistics["distance_evaluations"] > 0 + + with pytest.raises(RuntimeError, match="knn search failed"): + index.knn_search_with_statistics(query, 5, "not valid JSON") + + +def test_hgraph_range_search_with_statistics(dataset_factory): + """Test opt-in range statistics without changing legacy tuple unpacking.""" + dim = 32 + dataset = dataset_factory(dim=dim, num_vectors=64, metric="ip") + index = create_index(TYPE_NAME, _create_index_params(dim, "ip", "fp32")) + build_index(index, dataset) + query = dataset.query_vectors[:dim] + search_params = json.dumps({"hgraph": {"ef_search": 32}}) + + legacy_ids, legacy_distances = index.range_search(query, 1.0, search_params) + ids, distances, statistics_json = index.range_search_with_statistics( + query, 1.0, search_params + ) + statistics = json.loads(statistics_json) + assert ids.shape == legacy_ids.shape + assert distances.shape == legacy_distances.shape + assert statistics["distance_evaluations"] > 0 + + with pytest.raises(RuntimeError, match="range search failed"): + index.range_search_with_statistics(query, 1.0, "not valid JSON") + + @pytest.mark.parametrize("metric", ["ip"]) @pytest.mark.parametrize("dim", [128, 256, 1024]) @pytest.mark.parametrize("quantization_type,expect_recall", [ diff --git a/tests/python/test_sindi.py b/tests/python/test_sindi.py new file mode 100644 index 0000000000..27bf97ff5a --- /dev/null +++ b/tests/python/test_sindi.py @@ -0,0 +1,71 @@ +# Copyright 2024-present the vsag project +# +# 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. + +"""Pybind statistics tests for the maintained sparse SINDI index.""" + +import json + +import numpy as np +import pyvsag + + +def test_sindi_knn_search_with_statistics(): + """Expose one statistics JSON result per sparse CSR query.""" + index = pyvsag.Index( + "sindi", + json.dumps( + { + "dim": 16, + "dtype": "sparse", + "metric_type": "ip", + "index_param": { + "use_reorder": True, + "doc_prune_ratio": 0.0, + "window_size": 10000, + "term_id_limit": 16, + }, + } + ), + ) + index_pointers = np.array([0, 2, 4, 6], dtype=np.uint32) + indices = np.array([0, 1, 0, 2, 1, 3], dtype=np.uint32) + values = np.array([1.0, 0.5, 0.8, 0.7, 0.9, 0.6], dtype=np.float32) + index.build(index_pointers, indices, values, np.arange(3, dtype=np.int64)) + + query_pointers = np.array([0, 2, 4], dtype=np.uint32) + query_indices = np.array([0, 1, 1, 3], dtype=np.uint32) + query_values = np.array([1.0, 0.5, 0.9, 0.6], dtype=np.float32) + search_parameters = json.dumps( + { + "sindi": { + "n_candidate": 3, + "query_prune_ratio": 0.0, + "term_prune_ratio": 0.0, + } + } + ) + + legacy_ids, legacy_distances = index.knn_search( + query_pointers, query_indices, query_values, 2, search_parameters + ) + ids, distances, statistics_json = index.knn_search_with_statistics( + query_pointers, query_indices, query_values, 2, search_parameters + ) + assert ids.shape == legacy_ids.shape == (2, 2) + assert distances.shape == legacy_distances.shape == (2, 2) + assert len(statistics_json) == 2 + for value in statistics_json: + statistics = json.loads(value) + assert statistics["distance_evaluations"] > 0 + assert statistics["complete"] is True diff --git a/tests/test_brute_force.cpp b/tests/test_brute_force.cpp index 6a12edbfb1..f55fc2b192 100644 --- a/tests/test_brute_force.cpp +++ b/tests/test_brute_force.cpp @@ -222,6 +222,38 @@ const std::vector> BruteForceTestIndex::all_test_c constexpr static const char* search_param_tmp = ""; +TEST_CASE("BruteForce empty search exposes zero statistics", "[ut][bruteforce][statistics]") { + constexpr const char* params = R"({ + "dtype": "float32", + "metric_type": "l2", + "dim": 4, + "index_param": {"base_quantization_type": "fp32"} + })"; + auto index = fixtures::TestIndex::TestFactory("brute_force", params, true); + auto query = vsag::Dataset::Make(); + const float vector[] = {0.0F, 0.0F, 0.0F, 0.0F}; + query->NumElements(1)->Dim(4)->Float32Vectors(vector)->Owner(false); + auto result = index->KnnSearch(query, 1, ""); + REQUIRE(result.has_value()); + auto stats = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(stats["distance_evaluations"].GetUint64() == 0); + REQUIRE(stats["complete"].GetBool()); + + const float vectors[] = { + 0.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 2.0F, 0.0F, 0.0F, 0.0F}; + const int64_t ids[] = {10, 11, 12}; + auto base = vsag::Dataset::Make(); + base->NumElements(3)->Dim(4)->Float32Vectors(vectors)->Ids(ids)->Owner(false); + REQUIRE(index->Build(base).has_value()); + + result = index->KnnSearch(query, 1, R"({"parallelism": 2})"); + REQUIRE(result.has_value()); + stats = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(stats["distance_evaluations"].GetUint64() == 3); + REQUIRE(stats["distance_evaluations_by_phase"]["approximate"].GetUint64() == 3); + REQUIRE(stats["distance_evaluations_by_backend"]["fp32"].GetUint64() == 3); +} + BruteForceResourcePtr BruteForceTestIndex::GetResource(bool sample) { auto resource = std::make_shared(); @@ -1783,6 +1815,11 @@ TEST_CASE("(PR) BruteForce Custom Batch Distance", "[ft][bruteforce][custom_dist REQUIRE(result.has_value()); REQUIRE(max_batch_size == 3); REQUIRE(result.value()->GetDim() == 3); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() == scored_ids.size()); + REQUIRE(statistics["distance_evaluations_by_backend"]["unknown"].GetUint64() == + scored_ids.size()); + REQUIRE_FALSE(statistics["complete"].GetBool()); for (const auto id : scored_ids) { REQUIRE(id % 2 == 0); } @@ -1799,6 +1836,17 @@ TEST_CASE("(PR) BruteForce Custom Batch Distance", "[ft][bruteforce][custom_dist REQUIRE(result.value()->GetIds()[i] == expected_ids[i]); REQUIRE(result.value()->GetDistances()[i] == static_cast(expected_ids[i])); } + + scored_ids.clear(); + request.filter_ = std::make_shared(); + auto rejected_result = index->SearchWithRequest(request); + REQUIRE(rejected_result.has_value()); + REQUIRE(rejected_result.value()->GetDim() == 0); + REQUIRE(scored_ids.empty()); + statistics = vsag::JsonType::Parse(rejected_result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() == 0); + REQUIRE(statistics["distance_evaluations_by_backend"]["unknown"].GetUint64() == 0); + REQUIRE(statistics["complete"].GetBool()); } TEST_CASE("(PR) BruteForce Custom Batch Distance Validation", diff --git a/tests/test_hgraph.cpp b/tests/test_hgraph.cpp index 21948ef7fc..be5deb52ce 100644 --- a/tests/test_hgraph.cpp +++ b/tests/test_hgraph.cpp @@ -1065,6 +1065,27 @@ TEST_CASE("(PR) HGraph SearchWithRequest Reasoning", "[ft][hgraph][pr]") { REQUIRE(result.has_value()); REQUIRE_FALSE(result.value()->GetReasoning().empty()); REQUIRE(result.value()->GetReasoning().find("missed_targets") != std::string::npos); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations_by_phase"]["routing"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations"].GetUint64() == + statistics["distance_evaluations_by_phase"]["routing"].GetUint64() + + statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() + + statistics["distance_evaluations_by_phase"]["rerank"].GetUint64()); + + vsag::IteratorContext* iter_ctx = nullptr; + auto iterator_result = index->KnnSearch( + query, 5, req.params_str_, std::make_shared(), iter_ctx, false); + REQUIRE(iterator_result.has_value()); + REQUIRE(iterator_result.value()->GetDim() == 0); + auto iterator_statistics = vsag::JsonType::Parse(iterator_result.value()->GetStatistics()); + REQUIRE(iterator_statistics["distance_evaluations"].GetUint64() > 0); + REQUIRE(iterator_statistics["distance_evaluations"].GetUint64() == + iterator_statistics["distance_evaluations_by_phase"]["routing"].GetUint64() + + iterator_statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() + + iterator_statistics["distance_evaluations_by_phase"]["rerank"].GetUint64()); + REQUIRE(iterator_statistics["complete"].GetBool()); + delete iter_ctx; req.enable_filter_ = true; req.filter_ = std::make_shared(); @@ -1318,6 +1339,11 @@ TEST_CASE("(PR) HGraph Custom Batch Distance", "[ft][hgraph][custom_distance][pr REQUIRE(result.has_value()); REQUIRE(result.value()->GetDim() > 0); REQUIRE(max_batch_size <= request.distance_batch_size_); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations_by_backend"]["unknown"].GetUint64() == + statistics["distance_evaluations"].GetUint64()); + REQUIRE_FALSE(statistics["complete"].GetBool()); for (int64_t i = 0; i < result.value()->GetDim(); ++i) { REQUIRE(result.value()->GetDistances()[i] == static_cast(result.value()->GetIds()[i])); diff --git a/tests/test_hgraph_rabitq_split.cpp b/tests/test_hgraph_rabitq_split.cpp index f35e14cf58..7418aeca2a 100644 --- a/tests/test_hgraph_rabitq_split.cpp +++ b/tests/test_hgraph_rabitq_split.cpp @@ -221,6 +221,31 @@ TEST_CASE("HGraph RaBitQ Split Homogeneous IO", "[ft][rabitq_split][hgraph]") { TestIndex::TestKnnSearch(index, dataset, kSplitSearchParam, 0.1F, true); } +TEST_CASE("HGraph RaBitQ reorder probes use the rerank statistics phase", + "[ft][rabitq_split][hgraph][statistics]") { + using namespace fixtures; + constexpr int64_t dim = 128; + constexpr uint64_t base_count = 200; + + auto param = + HGraphRaBitQSplitTestIndex::GenerateBuildParam("l2", dim, "memory_io", "", 3, 5, true); + auto index = TestIndex::TestFactory(HGraphRaBitQSplitTestIndex::name, param, true); + auto dataset = HGraphRaBitQSplitTestIndex::pool.GetDatasetAndCreate(dim, base_count, "l2"); + TestIndex::TestBuildIndex(index, dataset, true); + + auto query = get_one_query(dataset->query_, 0); + auto result = index->KnnSearch(query, 10, kSplitSearchParam); + REQUIRE(result.has_value()); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + const auto lower_bound_probes = statistics["reorder_lower_bound_probe_count"].GetUint64(); + const auto reorder_distances = statistics["reorder_distance_count"].GetUint64(); + const auto rerank = statistics["distance_evaluations_by_phase"]["rerank"].GetUint64(); + + REQUIRE(lower_bound_probes > 0); + REQUIRE(reorder_distances > 0); + REQUIRE(rerank == lower_bound_probes + reorder_distances); +} + TEST_CASE("HGraph RaBitQ Split ODescent optimized build", "[ft][rabitq_split][hgraph][odescent]") { using namespace fixtures; constexpr int64_t dim = 128; diff --git a/tests/test_ivf.cpp b/tests/test_ivf.cpp index 7c0125c987..ab511b82b8 100644 --- a/tests/test_ivf.cpp +++ b/tests/test_ivf.cpp @@ -1766,6 +1766,11 @@ TEST_CASE_PERSISTENT_FIXTURE(fixtures::IVFTestIndex, REQUIRE(result.value()->GetDim() == request.topk_); REQUIRE(largest_batch > 0); REQUIRE(largest_batch <= batch_size); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations_by_phase"]["routing"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations_by_backend"]["unknown"].GetUint64() > 0); + REQUIRE_FALSE(statistics["complete"].GetBool()); for (int64_t i = 0; i < result.value()->GetDim(); ++i) { REQUIRE(result.value()->GetDistances()[i] == static_cast(result.value()->GetIds()[i])); @@ -2112,6 +2117,10 @@ TEST_CASE("IVF GraphBucketSearcher Basic", "[ft][ivf][graph]") { auto result = index.value()->KnnSearch(query, 10, search_param); REQUIRE(result.has_value()); REQUIRE(result.value()->GetDim() == 10); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations_by_backend"]["fp32"].GetUint64() >= + statistics["distance_evaluations_by_phase"]["approximate"].GetUint64()); } TEST_CASE("IVF GraphBucketSearcher excludes non-finite threshold results", diff --git a/tests/test_simq.cpp b/tests/test_simq.cpp index f1fdf4cffd..d1427ca157 100644 --- a/tests/test_simq.cpp +++ b/tests/test_simq.cpp @@ -321,6 +321,50 @@ require_simq_search_stats(const vsag::DatasetPtr& result) { REQUIRE(stats.result_count == static_cast(result->GetDim())); } +TEST_CASE("SIMQ: one centroid counts nested HGraph entry point", "[simq][statistics]") { + TempFile tmp; + std::array vector{}; + vector[0] = 1.0F; + MultiVector base_mv{1, vector.data()}; + int64_t id = 7; + auto base = Dataset::Make() + ->NumElements(1) + ->Dim(SIMQ_DIM) + ->Ids(&id) + ->MultiVectors(&base_mv) + ->MultiVectorDim(SIMQ_DIM) + ->Owner(false); + + auto created = Factory::CreateIndex("simq", make_build_param(tmp.path, 1.0F, 4, 2, 1, 1)); + REQUIRE(created.has_value()); + auto index = created.value(); + REQUIRE(index->Build(base).has_value()); + + auto query = Dataset::Make() + ->NumElements(1) + ->Dim(SIMQ_DIM) + ->MultiVectors(&base_mv) + ->MultiVectorDim(SIMQ_DIM) + ->Owner(false); + auto require_statistics = [](const DatasetPtr& result) { + auto statistics = JsonType::Parse(result->GetStatistics()); + REQUIRE(statistics["simq_coarse_dist_cmp"].GetUint64() == 1); + REQUIRE(statistics["distance_evaluations_by_phase"]["routing"].GetUint64() == 1); + REQUIRE(statistics["distance_evaluations_by_phase"]["rerank"].GetUint64() == 1); + REQUIRE(statistics["distance_evaluations"].GetUint64() == 2); + REQUIRE(statistics["distance_evaluations_by_backend"]["fp32"].GetUint64() == 2); + REQUIRE(statistics["complete"].GetBool()); + }; + + auto searched = index->KnnSearch(query, 1, make_search_param(1, 1), FilterPtr{}); + REQUIRE(searched.has_value()); + require_statistics(searched.value()); + + auto ranged = index->RangeSearch(query, 1.0F, make_search_param(1, 1), FilterPtr{}); + REQUIRE(ranged.has_value()); + require_statistics(ranged.value()); +} + // ───────────────────────────────────────────────────────────────────────────── // Test cases // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_sindi.cpp b/tests/test_sindi.cpp index 963a4acde8..aba4ae83b8 100644 --- a/tests/test_sindi.cpp +++ b/tests/test_sindi.cpp @@ -450,6 +450,24 @@ TEST_CASE_PERSISTENT_FIXTURE(fixtures::SINDITestIndex, TestKnnSearch(index, dataset, search_param, 0.99, true); TestRangeSearch(index, dataset, search_param, 0.99, 10, true); TestFilterSearch(index, dataset, search_param, 0.99, true); + + uint32_t unknown_term = 1'000'000; + float value = 1.0F; + vsag::SparseVector sparse_query{ + .len_ = 1, .ids_ = &unknown_term, .vals_ = &value, .token_seq_len_ = 0}; + auto query = vsag::Dataset::Make(); + query->NumElements(1)->Dim(16)->SparseVectors(&sparse_query)->Owner(false); + auto result = index->KnnSearch(query, 1, search_param); + REQUIRE(result.has_value()); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() == 0); + REQUIRE(statistics["complete"].GetBool()); + + auto range_result = index->RangeSearch(query, 1.0F, search_param, 10); + REQUIRE(range_result.has_value()); + statistics = vsag::JsonType::Parse(range_result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() == 0); + REQUIRE(statistics["complete"].GetBool()); } TEST_CASE_PERSISTENT_FIXTURE(fixtures::SINDITestIndex, "SINDI Mark Remove", "[ft][remove][sindi]") { @@ -554,6 +572,11 @@ TEST_CASE_PERSISTENT_FIXTURE(fixtures::SINDITestIndex, REQUIRE(result.has_value()); REQUIRE_FALSE(result.value()->GetReasoning().empty()); REQUIRE(result.value()->GetReasoning().find("expected_analysis") != std::string::npos); + auto statistics = vsag::JsonType::Parse(result.value()->GetStatistics()); + REQUIRE(statistics["distance_evaluations"].GetUint64() > 0); + REQUIRE(statistics["distance_evaluations"].GetUint64() == + statistics["distance_evaluations_by_phase"]["approximate"].GetUint64() + + statistics["distance_evaluations_by_phase"]["rerank"].GetUint64()); } TEST_CASE_PERSISTENT_FIXTURE(fixtures::SINDITestIndex,