Skip to content

fix: preserve rewritten FunctionChain scores in reduction and fusion - #55232

Open
junjiejiangjjj wants to merge 1 commit into
milvus-io:masterfrom
junjiejiangjjj:55212-patch
Open

junjiejiangjjj wants to merge 1 commit into
milvus-io:masterfrom
junjiejiangjjj:55212-patch

Conversation

@junjiejiangjjj

Copy link
Copy Markdown
Contributor

Skip metric-based distance restoration when L0/L1 chains write .
Preserve rewritten scores during HybridSearch fusion and treat them as
larger-is-better, while retaining existing behavior for raw metric scores.

Add Go regression tests and Python e2e cases covering L2 score rewriting,
group-by, pagination, and normalized and unnormalized hybrid fusion.

issue: #55212

@sre-ci-robot

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
To complete the pull request process, please assign zhuwenxing after the PR has been reviewed.
You can assign the PR to them by writing /assign @zhuwenxing in a comment when ready.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@sre-ci-robot sre-ci-robot added the size/XL Denotes a PR that changes 500-999 lines. label Nov 6, 2026
@sre-ci-robot
sre-ci-robot requested review from czs007 and sunby November 6, 2026 08:17
@mergify mergify Bot added dco-passed DCO check passed. kind/bug Issues or changes related a bug labels Nov 6, 2026
@sre-ci-robot

Copy link
Copy Markdown
Contributor

[ci-v2-notice]
Notice: New ci-v2 system is enabled for this PR.

To rerun ci-v2 checks, comment with:

  • /ci-rerun-code-check // for ci-v2/code-check
  • /ci-rerun-code-check-macos // for Code Checker MacOS (GitHub Actions)
  • /ci-rerun-build // for ci-v2/build
  • /ci-rerun-build-all // for ci-v2/build-all (multi-arch builds)
  • /ci-rerun-buildenv // for ci-v2/build-env (build milvus-env builder images; update .env after the new tag is ready)
  • /ci-rerun-ut-integration // for ci-v2/ut-integration, will rerun ci-v2/build
  • /ci-rerun-ut-go // for ci-v2/ut-go, will rerun ci-v2/build
  • /ci-rerun-ut-cpp // for ci-v2/ut-cpp
  • /ci-rerun-ut // for all ci-v2/ut-integration, ci-v2/ut-go, ci-v2/ut-cpp, will rerun ci-v2/build
  • /ci-rerun-e2e-default // for ci-v2/e2e-default
  • /ci-rerun-e2e-pulsar-wp // for ci-v2/e2e-pulsar-wp (GCP branch-equivalent Pulsar + Woodpecker Python E2E)
  • /ci-rerun-e2e-amd // for ci-v2/e2e-amd (e2e pool dispatcher)
  • /ci-rerun-e2e-dist-wp // for ci-v2/e2e-dist-wp (Tencent distributed woodpecker-service boundary)
  • /ci-rerun-build-ut-cov // for ci-v2/build-ut-cov (build + unit tests in one pipeline)
  • /ci-rerun-build-ut-cov-tcus // force TC-US build-ut-cov; respects Portal limit, never falls back to AWS
  • /ci-rerun-gosdk // for ci-v2/go-sdk (Go SDK E2E tests, ARM)
  • /ci-rerun-gosdk-std // for ci-v2/go-sdk-std (Go SDK E2E, standalone)
  • /ci-rerun-gosdk-dist-wp // for ci-v2/go-sdk-dist-wp (Go SDK E2E, distributed + Woodpecker service)

If you have any questions or requests, please contact @zhikunyao.

@sre-ci-robot

Copy link
Copy Markdown
Contributor

❌ CI Loop Results 9b2203a

Stage Result Duration Tests
✅ Build SUCCESS 9.4min -
❌ Code-Check FAILURE 4.6min -
✅ UT-Integration SUCCESS 42.1min -
❌ UT-GO FAILURE 34.4min -
✅ UT-CPP-Cov SUCCESS 27.2min 23007 total, 23007 passed, 0 failed

Total: 62min | Pipeline | Artifacts

Overall Coverage: 79.0%
Diff Coverage: Go 97.0% (65 hit, 2 miss, 67 measurable lines, 25 unmeasured)
Diff Coverage HTML: view changed lines
Total Patch Coverage: 97.0% (65/67 measurable lines, 25 unmeasured)

Failed Test Logs:

  Skip metric-based distance restoration when L0/L1 chains write .
  Preserve rewritten scores during HybridSearch fusion and treat them as
  larger-is-better, while retaining existing behavior for raw metric scores.

  Add Go regression tests and Python e2e cases covering L2 score rewriting,
  group-by, pagination, and normalized and unnormalized hybrid fusion.

  issue: milvus-io#55212

Signed-off-by: junjie.jiang <junjie.jiang@zilliz.com>

@liliu-z liliu-z left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The direction looks right to me. Once an L0/L1 chain writes $score, the value is no longer a distance, so Proxy shouldn't negate it back and Merge shouldn't treat it as one.

My main concern: the reduce ops' metrics output has three consumers (rerank, element_best_collapse, agg), and only rerank learns about rewritten scores. On the other two paths, results that used to have the right order and the wrong sign now come back in the wrong order. Details inline.

offset = v
}
return &searchReduceOperator{
scoreRewritten: chain.QueryNodeChainsWriteScore(t.request.GetFunctionChains()),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

search_aggregation still takes the score direction from the metric. L0 is allowed with it (only L1 is rejected), and aggOp builds SearchAggregationComputer from metrics[0], so for L2, top_hits sorts _score ascending. With an L0 chain writing $score, the scores that reach it are now larger-is-better, so top_hits returns the worst hits first and size keeps the wrong ones. Before this change the order was right and only the sign was off.

return nil, err
}
return &hybridSearchReduceOperator{
scoresRewritten: t.subSearchScoresRewritten(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same problem in hybrid search. element_best_collapse runs on this output before rerank and picks each row's best element with metric.PositivelyRelated(metrics[i]). Take a hybrid search that mixes an element-level struct sub-search on L2 (whose L0/L1 chain writes $score) with a row-level one: collapse now keeps the lowest business score per row.

Instead of re-deriving the flag in rerankOperator and threading ScoresRewritten through SearchParams / SearchRuntimeInfo / MergeOption, could the reduce ops emit the effective direction together with metrics? Then all three consumers read the same thing.

if normalize {
for i, m := range metricTypes {
normFuncs[i] = getNormalizeFunc(m)
if !isScoreRewritten(rewritten, i) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This also changes norm_score=true for IP/COSINE/BM25 sub-searches that rewrite $score, which #55212 didn't affect. These scores used to be mapped into [0,1]. Now they keep their raw scale, so a business score like 6*ts swamps the normalized vector scores, even though the user explicitly asked for normalization.

Would it be more consistent to treat a rewritten input as a generic larger-is-better score and apply the IP-style 0.5 + atan(x)/π? That still fixes the L2 sign problem, and norm_score keeps meaning "all inputs in [0,1]".


Proxy derives whether each sub-search wrote `$score` from that sub-search's
L0/L1 outputs and passes the state to Merge through runtime build context.
Rewritten scores are larger-is-better business values: Merge preserves their

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One case worth spelling out here: for L2, L0/L1 read $score as the negated distance (segcore flips it in segment_c.cpp). So a chain that adjusts the score rather than replacing it, e.g. $score * 1.2, used to return 1.2d and now returns -1.2d. If that's the intended contract, could we document it? The API doc (20260624) only says $score starts from the search score/distance, which reads like a positive L2 distance.

}

func reduceResults(ctx context.Context, toReduceResults []*internalpb.SearchResults, nq, topK, offset int64, metricType string, pkType schemapb.DataType, queryInfo *planpb.QueryInfo, isAdvance bool, isSearchAggregation bool, collectionID int64, partitionIDs []int64) (*milvuspb.SearchResults, error) {
func shouldRestoreDistance(metricType string, scoreRewritten []bool) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: scoreRewritten ...bool is passed as an optional arg through four functions, and only [0] is ever read. A plain bool parameter, or a field on reduce.ResultInfo next to the metric type, would be easier to follow.

@sre-ci-robot

Copy link
Copy Markdown
Contributor

✅ CI Loop Results 5d306b7

Stage Result Duration Tests
✅ Build SUCCESS 10.0min -
✅ Code-Check SUCCESS 5.1min -
✅ UT-Integration SUCCESS 37.3min -
✅ UT-GO SUCCESS 34.2min -
✅ UT-CPP-Cov SUCCESS 27.0min 23007 total, 23007 passed, 0 failed

Total: 59min | Pipeline | Artifacts

Overall Coverage: 79.0%
Diff Coverage: Go 97.0% (65 hit, 2 miss, 67 measurable lines, 25 unmeasured)
Diff Coverage HTML: view changed lines
Total Patch Coverage: 97.0% (65/67 measurable lines, 25 unmeasured)

@mergify mergify Bot added the ci-passed label Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/test ci-passed dco-passed DCO check passed. kind/bug Issues or changes related a bug sig/testing size/XL Denotes a PR that changes 500-999 lines.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants