fix: keep round_decimal from changing search order (#50440)

## Summary

Keep raw search distances through segment search, iterator output, index
query, and chunk merging so ranking/reduce order is not affected by
`round_decimal`.

Apply `round_decimal` only when producing the final search response,
after reduce/rerank/order-by has already determined result order.
Returned scores keep the requested precision without changing TopK
selection.

issue: #50347

---------

Signed-off-by: xianliang.li <xianliang.li@zilliz.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
foxspy
2026-07-17 11:14:40 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 43264fc57a
commit ad68690249
13 changed files with 252 additions and 52 deletions
@@ -755,15 +755,8 @@ VectorDiskAnnIndex<T>::Query(const DatasetPtr dataset,
float* distances = const_cast<float*>(final->GetDistance());
final->SetIsOwner(true);
auto round_decimal = search_info.round_decimal_;
auto total_num = num_queries * topk;
if (round_decimal != -1) {
const float multiplier = pow(10.0, round_decimal);
for (int i = 0; i < total_num; i++) {
distances[i] = std::round(distances[i] * multiplier) / multiplier;
}
}
search_result.seg_offsets_.resize(total_num);
search_result.distances_.resize(total_num);
search_result.total_nq_ = num_queries;
@@ -20,7 +20,6 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <exception>
@@ -795,15 +794,8 @@ VectorMemIndex<T>::Query(const DatasetPtr dataset,
auto num_queries = final->GetRows();
float* distances = const_cast<float*>(final->GetDistance());
final->SetIsOwner(true);
auto round_decimal = search_info.round_decimal_;
auto total_num = num_queries * topk;
if (round_decimal != -1) {
const float multiplier = pow(10.0, round_decimal);
for (int i = 0; i < total_num; i++) {
distances[i] = std::round(distances[i] * multiplier) / multiplier;
}
}
search_result.seg_offsets_.resize(total_num);
search_result.distances_.resize(total_num);
search_result.total_nq_ = num_queries;
@@ -251,8 +251,7 @@ CachedSearchIterator::NextBatch(const SearchInfo& search_info,
for (size_t query_idx = 0; query_idx < nq_; ++query_idx) {
auto rst = GetBatchedNextResults(query_idx, search_info);
WriteSingleQuerySearchResult(
search_result, query_idx, rst, search_info.round_decimal_);
WriteSingleQuerySearchResult(search_result, query_idx, rst);
}
}
@@ -368,20 +367,11 @@ void
CachedSearchIterator::WriteSingleQuerySearchResult(
SearchResult& search_result,
const size_t idx,
std::vector<DisIdPair>& rst,
const int64_t round_decimal) {
const float multiplier = pow(10.0, round_decimal);
std::vector<DisIdPair>& rst) {
std::transform(rst.begin(),
rst.end(),
search_result.distances_.begin() + idx * batch_size_,
[multiplier, round_decimal](DisIdPair& x) {
if (round_decimal != -1) {
x.first =
std::round(x.first * multiplier) / multiplier;
}
return x.first;
});
[](const DisIdPair& x) { return x.first; });
std::transform(rst.begin(),
rst.end(),
@@ -179,8 +179,7 @@ class CachedSearchIterator {
void
WriteSingleQuerySearchResult(SearchResult& search_result,
const size_t idx,
std::vector<DisIdPair>& rst,
const int64_t round_decimal);
std::vector<DisIdPair>& rst);
void
Init(const SearchInfo& search_info);
@@ -299,7 +299,6 @@ BruteForceSearch(const dataset::SearchDataset& query_ds,
KnowhereStatusString(stat));
}
}
sub_result.round_values();
return sub_result;
}
@@ -11,7 +11,6 @@
#include <folly/ExceptionWrapper.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <memory>
#include <optional>
@@ -60,7 +59,6 @@ SearchOnSealedIndex(const Schema& schema,
milvus::OpContext* op_context,
SearchResult& search_result) {
auto topK = search_info.topk_;
auto round_decimal = search_info.round_decimal_;
auto field_id = search_info.field_id_;
auto& field = schema[field_id];
@@ -138,15 +136,6 @@ SearchOnSealedIndex(const Schema& schema,
if (!use_iterator) {
vec_index->Query(
dataset, search_info, search_bitset, op_context, search_result);
float* distances = search_result.distances_.data();
auto total_num = num_queries * topK;
if (round_decimal != -1) {
const float multiplier = pow(10.0, round_decimal);
for (int i = 0; i < total_num; i++) {
distances[i] =
std::round(distances[i] * multiplier) / multiplier;
}
}
}
FinalizeVectorSearchOffsets(
search_result,
@@ -20,6 +20,7 @@
#include <utility>
#include <vector>
#include "common/Consts.h"
#include "filemanager/InputStream.h"
#include "gtest/gtest.h"
#include "knowhere/comp/index_param.h"
@@ -141,3 +142,29 @@ TEST(Reduce, SubSearchResult) {
TestSubSearchResultMerge<queue_type_ip>(knowhere::metric::IP, 4, 16, 1);
TestSubSearchResultMerge<queue_type_ip>(knowhere::metric::IP, 4, 16, 10);
}
TEST(Reduce, SubSearchResultMergesBeforeRounding) {
SubSearchResult final_result(1, 2, knowhere::metric::L2, 0);
SubSearchResult left(1, 2, knowhere::metric::L2, 0);
left.mutable_offsets() = {1, INVALID_SEG_OFFSET};
left.mutable_distances() = {
0.49f, SubSearchResult::init_value(knowhere::metric::L2)};
SubSearchResult right(1, 2, knowhere::metric::L2, 0);
right.mutable_offsets() = {2, INVALID_SEG_OFFSET};
right.mutable_distances() = {
0.36f, SubSearchResult::init_value(knowhere::metric::L2)};
final_result.merge(left);
final_result.merge(right);
EXPECT_EQ(final_result.get_offsets()[0], 2);
EXPECT_EQ(final_result.get_offsets()[1], 1);
EXPECT_FLOAT_EQ(final_result.get_distances()[0], 0.36f);
EXPECT_FLOAT_EQ(final_result.get_distances()[1], 0.49f);
final_result.round_values();
EXPECT_FLOAT_EQ(final_result.get_distances()[0], 0.0f);
EXPECT_FLOAT_EQ(final_result.get_distances()[1], 0.0f);
}
+1 -1
View File
@@ -267,7 +267,7 @@ TEST(Indexing, BinaryBruteForce) {
sr.seg_offsets_ = std::move(sub_result.mutable_offsets());
sr.distances_ = std::move(sub_result.mutable_distances());
auto json = SearchResultToJson(sr);
auto json = SearchResultToJson(sr, round_decimal);
std::cout << json.dump(2);
#ifdef __linux__
auto ref = nlohmann::json::parse(R"(
+3 -3
View File
@@ -115,7 +115,7 @@ TEST(Query, ExecWithPredicateLoader) {
auto sr = segment->Search(plan.get(), ph_group.get(), timestamp);
query::Json json = SearchResultToJson(*sr);
query::Json json = SearchResultToJson(*sr, 3);
#ifdef __linux__
auto ref = json::parse(R"(
[
@@ -222,7 +222,7 @@ TEST(Query, ExecWithPredicate) {
auto sr = segment->Search(plan.get(), ph_group.get(), timestamp);
query::Json json = SearchResultToJson(*sr);
query::Json json = SearchResultToJson(*sr, 3);
#ifdef __linux__
auto ref = json::parse(R"(
[
@@ -404,7 +404,7 @@ TEST(Query, ExecWithoutPredicate) {
auto sr = segment->Search(plan.get(), ph_group.get(), timestamp);
assert_order(*sr, "l2");
std::vector<std::vector<std::string>> results;
auto json = SearchResultToJson(*sr);
auto json = SearchResultToJson(*sr, 3);
#ifdef __linux__
auto ref = json::parse(R"(
[
+8 -2
View File
@@ -1396,16 +1396,22 @@ SearchResultToVector(const SearchResult& sr) {
}
inline nlohmann::json
SearchResultToJson(const SearchResult& sr) {
SearchResultToJson(const SearchResult& sr, int64_t round_decimal = -1) {
int64_t num_queries = sr.total_nq_;
int64_t topk = sr.unity_topK_;
std::vector<std::vector<std::string>> results;
const float multiplier =
round_decimal >= 0 ? std::pow(10.0, round_decimal) : 1.0f;
for (int q = 0; q < num_queries; ++q) {
std::vector<std::string> result;
for (int k = 0; k < topk; ++k) {
int index = q * topk + k;
auto distance = sr.distances_[index];
if (round_decimal >= 0) {
distance = std::round(distance * multiplier) / multiplier;
}
result.emplace_back(std::to_string(sr.seg_offsets_[index]) + "->" +
std::to_string(sr.distances_[index]));
std::to_string(distance));
}
results.emplace_back(std::move(result));
}
+59 -4
View File
@@ -20,6 +20,7 @@ import (
"bytes"
"context"
"fmt"
"math"
"sort"
"strings"
@@ -809,17 +810,26 @@ func copySearchResultsWithData(src *milvuspb.SearchResults, data *schemapb.Searc
}
type aggregateOperator struct {
aggCtx *search_agg.SearchAggregationContext
collSchema *schemapb.CollectionSchema
aggCtx *search_agg.SearchAggregationContext
collSchema *schemapb.CollectionSchema
roundDecimal int64
}
func newAggregateOperator(t *searchTask, _ map[string]any) (operator, error) {
if t.aggCtx == nil {
return nil, merr.WrapErrServiceInternal("aggregate operator requires non-nil aggCtx")
}
// Aggregation searches bypass endOperator (newSearchPipeline returns early for
// aggCtx), so round the aggregation hit scores here at the terminal step to keep
// round_decimal behavior consistent with non-aggregation searches.
roundDecimal := int64(-1)
if !t.GetIsAdvanced() && len(t.queryInfos) > 0 && t.queryInfos[0] != nil {
roundDecimal = t.queryInfos[0].GetRoundDecimal()
}
return &aggregateOperator{
aggCtx: t.aggCtx,
collSchema: t.schema.CollectionSchema,
aggCtx: t.aggCtx,
collSchema: t.schema.CollectionSchema,
roundDecimal: roundDecimal,
}, nil
}
@@ -857,6 +867,7 @@ func (op *aggregateOperator) run(ctx context.Context, span trace.Span, inputs ..
aggBuckets := make([]*schemapb.AggBucket, 0)
aggTopks := make([]int64, 0, len(nqAggResults))
for _, buckets := range nqAggResults {
roundAggHitScores(buckets, op.roundDecimal)
aggTopks = append(aggTopks, int64(len(buckets)))
aggBuckets = append(aggBuckets, serializeAggBuckets(buckets, fieldIDToName, op.aggCtx.Levels, 0)...)
}
@@ -1773,12 +1784,18 @@ func (op *lambdaOperator) run(ctx context.Context, span trace.Span, inputs ...an
type endOperator struct {
outputFieldNames []string
fieldSchemas []*schemapb.FieldSchema
roundDecimal int64
}
func newEndOperator(t *searchTask, _ map[string]any) (operator, error) {
roundDecimal := int64(-1)
if !t.GetIsAdvanced() && len(t.queryInfos) > 0 && t.queryInfos[0] != nil {
roundDecimal = t.queryInfos[0].GetRoundDecimal()
}
return &endOperator{
outputFieldNames: t.translatedOutputFields,
fieldSchemas: typeutil.GetAllFieldSchemas(t.schema.CollectionSchema),
roundDecimal: roundDecimal,
}, nil
}
@@ -1798,9 +1815,47 @@ func (op *endOperator) run(ctx context.Context, span trace.Span, inputs ...any)
})
allSearchCount := aggregatedAllSearchCount(inputs[1].([]*milvuspb.SearchResults))
result.GetResults().AllSearchCount = allSearchCount
roundSearchScores(result.GetResults(), op.roundDecimal)
return []any{result}, nil
}
func roundScore(score float32, roundDecimal int64) float32 {
if roundDecimal < 0 {
return score
}
multiplier := math.Pow(10.0, float64(roundDecimal))
return float32(math.Floor(float64(score)*multiplier+0.5) / multiplier)
}
func roundSearchScores(result *schemapb.SearchResultData, roundDecimal int64) {
if result == nil || roundDecimal < 0 {
return
}
for i, score := range result.Scores {
result.Scores[i] = roundScore(score, roundDecimal)
}
}
// roundAggHitScores rounds aggregation hit scores in place (recursing into
// sub-aggregation buckets). Applied at the aggregate operator's terminal step
// because aggregation searches bypass endOperator.
func roundAggHitScores(buckets []*search_agg.AggBucketResult, roundDecimal int64) {
if roundDecimal < 0 {
return
}
for _, b := range buckets {
if b == nil {
continue
}
for _, h := range b.Hits {
if h != nil {
h.Score = roundScore(h.Score, roundDecimal)
}
}
roundAggHitScores(b.SubAggBuckets, roundDecimal)
}
}
func newHighlightOperator(t *searchTask, _ map[string]any) (operator, error) {
return t.highlighter.AsSearchPipelineOperator(t)
}
+104
View File
@@ -2483,6 +2483,110 @@ func (s *SearchPipelineSuite) TestFilterFieldOperatorWithStructArrayFields() {
}
}
func (s *SearchPipelineSuite) TestEndOperatorRoundsScores() {
task := &searchTask{
queryInfos: []*planpb.QueryInfo{{RoundDecimal: 0}},
schema: &schemaInfo{
CollectionSchema: &schemapb.CollectionSchema{},
},
}
op, err := newEndOperator(task, nil)
s.NoError(err)
searchResults := &milvuspb.SearchResults{
Results: &schemapb.SearchResultData{
Scores: []float32{0.49, 0.36},
},
}
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
s.NoError(err)
resultData := results[0].(*milvuspb.SearchResults).GetResults()
s.Equal([]float32{0, 0}, resultData.GetScores())
}
func (s *SearchPipelineSuite) TestRoundAggHitScores() {
// Aggregation searches bypass endOperator, so hit scores are rounded at the
// aggregate operator's terminal step. Covers nested sub-aggregation buckets.
buckets := []*search_agg.AggBucketResult{
{
Hits: []*search_agg.HitResult{{Score: 0.49}, {Score: 0.36}},
SubAggBuckets: []*search_agg.AggBucketResult{
{Hits: []*search_agg.HitResult{{Score: 0.51}, nil}},
},
},
nil,
}
roundAggHitScores(buckets, 0)
s.Equal(float32(0), buckets[0].Hits[0].Score)
s.Equal(float32(0), buckets[0].Hits[1].Score)
s.Equal(float32(1), buckets[0].SubAggBuckets[0].Hits[0].Score)
}
func (s *SearchPipelineSuite) TestRoundAggHitScoresDisabled() {
buckets := []*search_agg.AggBucketResult{
{Hits: []*search_agg.HitResult{{Score: 0.49}, {Score: 0.36}}},
}
roundAggHitScores(buckets, -1)
s.Equal(float32(0.49), buckets[0].Hits[0].Score)
s.Equal(float32(0.36), buckets[0].Hits[1].Score)
}
func (s *SearchPipelineSuite) TestEndOperatorKeepsScoresWhenRoundDecimalDisabled() {
task := &searchTask{
queryInfos: []*planpb.QueryInfo{{RoundDecimal: -1}},
schema: &schemaInfo{
CollectionSchema: &schemapb.CollectionSchema{},
},
}
op, err := newEndOperator(task, nil)
s.NoError(err)
searchResults := &milvuspb.SearchResults{
Results: &schemapb.SearchResultData{
Scores: []float32{0.49, 0.36},
},
}
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
s.NoError(err)
resultData := results[0].(*milvuspb.SearchResults).GetResults()
s.Equal([]float32{float32(0.49), float32(0.36)}, resultData.GetScores())
}
func (s *SearchPipelineSuite) TestEndOperatorKeepsAdvancedRerankScores() {
task := &searchTask{
SearchRequest: &internalpb.SearchRequest{IsAdvanced: true},
rankParams: &rankParams{roundDecimal: 0},
schema: &schemaInfo{
CollectionSchema: &schemapb.CollectionSchema{},
},
}
op, err := newEndOperator(task, nil)
s.NoError(err)
searchResults := &milvuspb.SearchResults{
Results: &schemapb.SearchResultData{
Scores: []float32{0.99, 0.88},
},
}
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
s.NoError(err)
resultData := results[0].(*milvuspb.SearchResults).GetResults()
s.Equal([]float32{float32(0.99), float32(0.88)}, resultData.GetScores())
}
func (s *SearchPipelineSuite) TestHybridSearchWithRequeryAndRerankByDataPipe() {
task := getHybridSearchTask("test_collection", [][]string{
{"1", "2"},
@@ -1697,6 +1697,52 @@ func (s *RerankBuilderTestSuite) TestExecuteRerankChain_RoundDecimalNegativeOne_
s.NotContains(chainStr, "round_decimal")
}
func (s *RerankBuilderTestSuite) TestExecuteRerankChain_RoundDecimalAfterOrdering() {
collSchema := s.createCollectionSchemaWithCategory()
searchParams := NewSearchParams(1, 2, 0, 0)
searchMetrics := []string{"L2"}
funcScoreSchema := &schemapb.FunctionScore{
Functions: []*schemapb.FunctionSchema{
{
Type: schemapb.FunctionType_Rerank,
Params: []*commonpb.KeyValuePair{
{Key: "reranker", Value: "weighted"},
{Key: "weights", Value: "[1.0]"},
{Key: "norm_score", Value: "false"},
},
},
},
}
fc, err := BuildRerankChain(collSchema, funcScoreSchema, searchMetrics, searchParams, s.pool)
s.Require().NoError(err)
df := s.createTestDataFrameForRerank(
[]int64{1, 2},
[]float32{0.49, 0.36},
[]string{"A", "B"},
[]int64{2},
)
defer df.Release()
result, err := fc.ExecuteWithContext(context.Background(), df)
s.Require().NoError(err)
defer result.Release()
idCol := result.Column("$id")
s.Require().NotNil(idCol)
idChunk := idCol.Chunk(0).(*array.Int64)
s.Equal(int64(2), idChunk.Value(0))
s.Equal(int64(1), idChunk.Value(1))
scoreCol := result.Column("$score")
s.Require().NotNil(scoreCol)
scoreChunk := scoreCol.Chunk(0).(*array.Float32)
s.Equal(float32(0), scoreChunk.Value(0))
s.Equal(float32(0), scoreChunk.Value(1))
}
func (s *RerankBuilderTestSuite) TestExecuteRerankChain_WithGroupingAndRoundDecimal() {
collSchema := s.createCollectionSchemaWithCategory()
searchParams := NewSearchParamsWithGroupingAndScorer(1, 3, 0, 2, "category", 2, GroupScorerMax)