// 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 #include #include #include #include #include #include #include #include #include #include using namespace zvec; namespace { Doc::Ptr MakeDoc(const std::string &id, float score) { auto doc = std::make_shared(); doc->set_pk(id); doc->set_score(score); return doc; } FieldSchema::Ptr MakeField(const std::string &name, MetricType metric) { return std::make_shared( name, DataType::VECTOR_FP16, /*dimension=*/4, /*nullable=*/false, std::make_shared(metric)); } } // namespace // ==================== RRF Tests ==================== TEST(RerankRrfTest, BasicRRF) { // Two sub-queries, each returning 3 documents with some overlap. std::vector 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 top2 = {out[0]->pk(), out[1]->pk()}; EXPECT_EQ(top2, (std::set{"a", "b"})); EXPECT_NEAR(out[0]->score(), out[1]->score(), 1e-10); } TEST(RerankRrfTest, Topn) { std::vector 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 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 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 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 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 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 results; results.push_back({MakeDoc("a", 0.5f)}); results.push_back({MakeDoc("a", 0.4f)}); std::vector 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 results; results.push_back({MakeDoc("a", 0.5f)}); results.push_back({MakeDoc("b", 0.3f)}); std::vector 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 results; results.push_back({MakeDoc("a", 0.5f)}); results.push_back({MakeDoc("b", 0.3f)}); std::vector 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 results; results.push_back({MakeDoc("a", 0.5f)}); std::vector fields = {nullptr}; auto result = reranker::rerank(reranker::WeightedParams{{1.0}}, results, fields, /*topn=*/10); ASSERT_FALSE(result.has_value()); } TEST(RerankWeightedTest, NormalizeL2) { std::vector results; results.push_back({MakeDoc("a", 0.0f), MakeDoc("b", 1.0f)}); std::vector 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 results; results.push_back({MakeDoc("a", 0.0f), MakeDoc("b", 1.0f)}); std::vector 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 results; results.push_back( {MakeDoc("a", 0.0f), MakeDoc("b", 1.0f), MakeDoc("c", 2.0f)}); std::vector 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 results; results.push_back( {MakeDoc("a", 0.1f), MakeDoc("b", 0.2f), MakeDoc("c", 0.3f)}); std::vector 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 &results, const std::vector & /*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(all_docs.size()) > topn) { all_docs.resize(topn); } return all_docs; }; std::vector 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 results; results.push_back({MakeDoc("a", 0.5f)}); auto result = reranker::rerank(params, results, /*fields=*/{}, /*topn=*/10); ASSERT_FALSE(result.has_value()); }