Files
2026-07-13 12:47:42 +08:00

316 lines
11 KiB
C++

// Copyright 2025-present the zvec 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.
#define _USE_MATH_DEFINES
#include <algorithm>
#include <cmath>
#include <memory>
#include <set>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include <zvec/db/doc.h>
#include <zvec/db/index_params.h>
#include <zvec/db/reranker.h>
#include <zvec/db/type.h>
using namespace zvec;
namespace {
Doc::Ptr MakeDoc(const std::string &id, float score) {
auto doc = std::make_shared<Doc>();
doc->set_pk(id);
doc->set_score(score);
return doc;
}
FieldSchema::Ptr MakeField(const std::string &name, MetricType metric) {
return std::make_shared<FieldSchema>(
name, DataType::VECTOR_FP16, /*dimension=*/4, /*nullable=*/false,
std::make_shared<HnswIndexParams>(metric));
}
} // namespace
// ==================== RRF Tests ====================
TEST(RerankRrfTest, BasicRRF) {
// Two sub-queries, each returning 3 documents with some overlap.
std::vector<DocPtrList> results;
results.push_back(
{MakeDoc("a", 0.9f), MakeDoc("b", 0.8f), MakeDoc("c", 0.7f)});
results.push_back(
{MakeDoc("b", 0.95f), MakeDoc("a", 0.85f), MakeDoc("d", 0.75f)});
auto result =
reranker::rerank(reranker::RrfParams{/*rank_constant=*/60}, results,
/*fields=*/{}, /*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
// "a" appears at rank 0 in sub-query 0 and rank 1 in sub-query 1:
// rrf_score = 1/(60+0+1) + 1/(60+1+1) = 1/61 + 1/62
// "b" appears at rank 1 in sub-query 0 and rank 0 in sub-query 1:
// rrf_score = 1/(60+1+1) + 1/(60+0+1) = 1/62 + 1/61
// So a and b should have equal scores and occupy the top two slots.
ASSERT_GE(out.size(), 3u);
std::set<std::string> top2 = {out[0]->pk(), out[1]->pk()};
EXPECT_EQ(top2, (std::set<std::string>{"a", "b"}));
EXPECT_NEAR(out[0]->score(), out[1]->score(), 1e-10);
}
TEST(RerankRrfTest, Topn) {
std::vector<DocPtrList> results;
results.push_back(
{MakeDoc("a", 0.9f), MakeDoc("b", 0.8f), MakeDoc("c", 0.7f)});
auto result =
reranker::rerank(reranker::RrfParams{/*rank_constant=*/60}, results,
/*fields=*/{}, /*topn=*/2);
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value().size(), 2u);
}
TEST(RerankRrfTest, SingleField) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.9f), MakeDoc("b", 0.8f)});
auto result =
reranker::rerank(reranker::RrfParams{/*rank_constant=*/60}, results,
/*fields=*/{}, /*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_EQ(out.size(), 2u);
// With single sub-query, RRF score for rank 0 > rank 1.
EXPECT_GT(out[0]->score(), out[1]->score());
}
TEST(RerankRrfTest, EmptyResults) {
std::vector<DocPtrList> results;
auto result =
reranker::rerank(reranker::RrfParams{/*rank_constant=*/60}, results,
/*fields=*/{}, /*topn=*/10);
ASSERT_TRUE(result.has_value());
EXPECT_TRUE(result.value().empty());
}
TEST(RerankRrfTest, DefaultParams) {
// RrfParams (and therefore RerankParams) defaults to rank_constant = 60.
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.9f), MakeDoc("b", 0.8f)});
auto result =
reranker::rerank(reranker::RerankParams{}, results, /*fields=*/{},
/*topn=*/10);
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value().size(), 2u);
}
// ==================== Weighted Tests ====================
TEST(RerankWeightedTest, BasicWeighted) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f), MakeDoc("b", 0.3f)});
results.push_back({MakeDoc("a", 0.8f), MakeDoc("c", 0.6f)});
std::vector<FieldSchema::Ptr> fields = {MakeField("vec1", MetricType::L2),
MakeField("vec2", MetricType::L2)};
auto result =
reranker::rerank(reranker::WeightedParams{{0.7, 0.3}}, results, fields,
/*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_GE(out.size(), 2u);
// "a" appears in both sub-queries, should have highest combined score.
EXPECT_EQ(out[0]->pk(), "a");
}
TEST(RerankWeightedTest, MixedMetrics) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f)});
results.push_back({MakeDoc("a", 0.4f)});
std::vector<FieldSchema::Ptr> fields = {
MakeField("vec1", MetricType::L2), MakeField("vec2", MetricType::COSINE)};
auto result =
reranker::rerank(reranker::WeightedParams{{0.5, 0.5}}, results, fields,
/*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0]->pk(), "a");
// L2 normalize(0.5) = 1 - 2*atan(0.5)/pi
// COSINE normalize(0.4) = 1 - 0.4/2 = 0.8
// weighted = l2_norm * 0.5 + cos_norm * 0.5
double l2_norm = 1.0 - 2.0 * std::atan(0.5) / M_PI;
double cos_norm = 1.0 - 0.4 / 2.0;
double expected = l2_norm * 0.5 + cos_norm * 0.5;
EXPECT_NEAR(out[0]->score(), expected, 1e-5);
}
TEST(RerankWeightedTest, WeightsCountMismatch) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f)});
results.push_back({MakeDoc("b", 0.3f)});
std::vector<FieldSchema::Ptr> fields = {MakeField("vec1", MetricType::L2),
MakeField("vec2", MetricType::L2)};
// Only one weight provided for two sub-queries.
auto result =
reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields,
/*topn=*/10);
ASSERT_FALSE(result.has_value());
}
TEST(RerankWeightedTest, FieldsCountMismatch) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f)});
results.push_back({MakeDoc("b", 0.3f)});
std::vector<FieldSchema::Ptr> fields = {MakeField("vec1", MetricType::L2)};
auto result =
reranker::rerank(reranker::WeightedParams{{0.5, 0.5}}, results, fields,
/*topn=*/10);
ASSERT_FALSE(result.has_value());
}
TEST(RerankWeightedTest, NullFieldError) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f)});
std::vector<FieldSchema::Ptr> fields = {nullptr};
auto result =
reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields,
/*topn=*/10);
ASSERT_FALSE(result.has_value());
}
TEST(RerankWeightedTest, NormalizeL2) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.0f), MakeDoc("b", 1.0f)});
std::vector<FieldSchema::Ptr> fields = {MakeField("vec1", MetricType::L2)};
auto result =
reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields,
/*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_EQ(out.size(), 2u);
// L2 normalize(0.0) = 1.0, normalize(1.0) in (0, 1)
EXPECT_NEAR(out[0]->score(), 1.0, 1e-10);
EXPECT_EQ(out[0]->pk(), "a");
EXPECT_GT(out[1]->score(), 0.0);
EXPECT_LT(out[1]->score(), 1.0);
}
TEST(RerankWeightedTest, NormalizeIP) {
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.0f), MakeDoc("b", 1.0f)});
std::vector<FieldSchema::Ptr> fields = {MakeField("vec1", MetricType::IP)};
auto result =
reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields,
/*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_EQ(out.size(), 2u);
// IP normalize(1.0) > 0.5 > normalize(0.0) = 0.5
EXPECT_EQ(out[0]->pk(), "b");
EXPECT_GT(out[0]->score(), 0.5);
EXPECT_NEAR(out[1]->score(), 0.5, 1e-10);
}
TEST(RerankWeightedTest, NormalizeCosine) {
std::vector<DocPtrList> results;
results.push_back(
{MakeDoc("a", 0.0f), MakeDoc("b", 1.0f), MakeDoc("c", 2.0f)});
std::vector<FieldSchema::Ptr> fields = {
MakeField("vec1", MetricType::COSINE)};
auto result =
reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields,
/*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_EQ(out.size(), 3u);
// COSINE normalize(0.0) = 1.0, normalize(1.0) = 0.5, normalize(2.0) = 0.0
EXPECT_NEAR(out[0]->score(), 1.0, 1e-10);
EXPECT_NEAR(out[1]->score(), 0.5, 1e-10);
EXPECT_NEAR(out[2]->score(), 0.0, 1e-10);
}
TEST(RerankWeightedTest, Topn) {
std::vector<DocPtrList> results;
results.push_back(
{MakeDoc("a", 0.1f), MakeDoc("b", 0.2f), MakeDoc("c", 0.3f)});
std::vector<FieldSchema::Ptr> fields = {MakeField("vec1", MetricType::L2)};
auto result =
reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields,
/*topn=*/2);
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value().size(), 2u);
}
// ==================== Callback Tests ====================
TEST(RerankCallbackTest, BasicCallback) {
// Simple callback that returns docs sorted by score descending, limited to
// topn.
reranker::CallbackParams::Callback cb =
[](const std::vector<DocPtrList> &results,
const std::vector<FieldSchema::Ptr> & /*fields*/,
int topn) -> DocPtrList {
DocPtrList all_docs;
for (const auto &docs : results) {
for (const auto &doc : docs) {
all_docs.push_back(doc);
}
}
std::sort(all_docs.begin(), all_docs.end(),
[](const Doc::Ptr &a, const Doc::Ptr &b) {
return a->score() > b->score();
});
if (static_cast<int>(all_docs.size()) > topn) {
all_docs.resize(topn);
}
return all_docs;
};
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f), MakeDoc("b", 0.9f)});
results.push_back({MakeDoc("c", 0.7f)});
auto result =
reranker::rerank(reranker::CallbackParams{cb}, results, /*fields=*/{},
/*topn=*/10);
ASSERT_TRUE(result.has_value());
auto &out = result.value();
ASSERT_EQ(out.size(), 3u);
// Should be sorted by score descending.
EXPECT_EQ(out[0]->pk(), "b");
EXPECT_EQ(out[1]->pk(), "c");
EXPECT_EQ(out[2]->pk(), "a");
}
TEST(RerankCallbackTest, EmptyCallbackError) {
reranker::CallbackParams params; // callback is empty
std::vector<DocPtrList> results;
results.push_back({MakeDoc("a", 0.5f)});
auto result = reranker::rerank(params, results, /*fields=*/{}, /*topn=*/10);
ASSERT_FALSE(result.has_value());
}