This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new c63da64049 [global-index] Normalize hybrid weighted score fusion 
(#8294)
c63da64049 is described below

commit c63da640497de96f1b3c51f7fd91b2acf11fa290
Author: QuakeWang <[email protected]>
AuthorDate: Sat Jun 20 16:04:39 2026 +0800

    [global-index] Normalize hybrid weighted score fusion (#8294)
    
    `weighted_score` previously fused hybrid search routes by directly
    summing `weight * raw_score`.
    
    This is incorrect for heterogeneous routes because vector similarity
    scores and full-text/BM25 scores use different scales. A route with
    larger numeric scores can dominate the final ranking, making route
    weights ineffective.
---
 .../global-index/hybrid-search.mdx                 |  2 +-
 .../paimon/globalindex/HybridSearchRanker.java     | 19 ++++-
 .../paimon/globalindex/HybridSearchRankerTest.java | 54 +++++++++++++-
 .../pypaimon/table/source/hybrid_search_builder.py | 25 ++++++-
 .../pypaimon/tests/hybrid_search_ranker_test.py    | 86 ++++++++++++++++++++++
 .../apache/paimon/spark/sql/HybridSearchTest.scala | 69 +++++++++++++++++
 6 files changed, 248 insertions(+), 7 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/hybrid-search.mdx 
b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
index bde17d02b6..7c3f8395e3 100644
--- a/docs/docs/multimodal-table/global-index/hybrid-search.mdx
+++ b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
@@ -68,7 +68,7 @@ Rankers combine route scores differently:
 | Ranker | Best For | Description |
 |---|---|---|
 | `rrf` | Combining routes with different score scales | Reciprocal rank 
fusion uses each route's rank order, so vector and full-text scores do not need 
to be normalized. |
-| `weighted_score` | Combining routes with comparable score scales | Adds 
weighted scores from each route. Tune route weights carefully when score 
distributions differ. |
+| `weighted_score` | Weighting routes by normalized score, not just rank | 
Min-max normalizes each route's scores to `[0, 1]`, then sums them weighted by 
route `weight`, so weights (not raw score magnitude) control each route's 
influence. The exposed `__paimon_search_score` is therefore a per-query 
relative value in `[0, sum of weights]`, not a raw similarity or BM25 score. |
 
 The second argument is an array of vector route configs created by 
`named_struct`:
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/HybridSearchRanker.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/HybridSearchRanker.java
index 34c8a09c20..eb2f1d45ac 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/HybridSearchRanker.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/HybridSearchRanker.java
@@ -105,8 +105,25 @@ public class HybridSearchRanker {
             ScoredGlobalIndexResult result = weightedResult.result();
             float weight = weightedResult.weight();
             ScoreGetter scoreGetter = result.scoreGetter();
+
+            // Route score scales are heterogeneous (e.g. bounded vector 
similarity vs unbounded
+            // BM25), so raw scores are not comparable across routes. Min-max 
normalize each route
+            // into [0, 1] before weighting, so that weights -- not a route's 
numeric magnitude --
+            // decide its influence on the fused score.
+            float min = Float.POSITIVE_INFINITY;
+            float max = Float.NEGATIVE_INFINITY;
+            for (long rowId : result.results()) {
+                float score = scoreGetter.score(rowId);
+                min = Math.min(min, score);
+                max = Math.max(max, score);
+            }
+            float range = max - min;
+
             for (long rowId : result.results()) {
-                float contribution = weight * scoreGetter.score(rowId);
+                // No spread within the route (single hit or all ties) carries 
no relative signal,
+                // so every hit maps to 1.0 rather than being zeroed out.
+                float normalized = range > 0.0f ? (scoreGetter.score(rowId) - 
min) / range : 1.0f;
+                float contribution = weight * normalized;
                 scores.compute(
                         rowId,
                         (k, oldScore) -> oldScore == null ? contribution : 
oldScore + contribution);
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/HybridSearchRankerTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/HybridSearchRankerTest.java
index 02b835134c..f927d0cf8a 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/HybridSearchRankerTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/HybridSearchRankerTest.java
@@ -71,7 +71,59 @@ public class HybridSearchRankerTest {
 
         assertThat(ranked.results().getIntCardinality()).isEqualTo(1);
         assertThat(ranked.results()).contains(1L);
-        assertThat(ranked.scoreGetter().score(1L)).isCloseTo(0.9f, 
within(0.000001f));
+        // Within the route {0.3, 0.2} min-max maps 0.3 -> 1.0, so score = 
weight (3.0) * 1.0.
+        assertThat(ranked.scoreGetter().score(1L)).isCloseTo(3.0f, 
within(0.000001f));
+    }
+
+    @Test
+    public void testWeightedScoreNormalizesHeterogeneousRouteScales() {
+        // Vector-like route: rowId 1 strongly matches, rowId 2 barely matches 
(bounded ~[0, 1]).
+        ScoredGlobalIndexResult vectorRoute = result(new long[] {1, 2}, new 
float[] {0.95f, 0.10f});
+        // BM25-like route: rowId 2 strongly matches, rowId 1 weakly 
(unbounded, larger magnitude).
+        ScoredGlobalIndexResult textRoute = result(new long[] {1, 2}, new 
float[] {2.0f, 25.0f});
+
+        // Vector route weighted 5x. Without normalization the BM25 magnitude 
would dominate; with
+        // normalization each route maps its best hit to 1.0 and worst to 0.0, 
so weights decide.
+        ScoredGlobalIndexResult ranked =
+                HybridSearchRanker.weightedScore(
+                        Arrays.asList(vectorRoute, textRoute), new float[] 
{5.0f, 1.0f}, 2);
+
+        // rowId 1: 5 * 1.0 (vector best) + 1 * 0.0 (text worst) = 5.0
+        // rowId 2: 5 * 0.0 (vector worst) + 1 * 1.0 (text best) = 1.0
+        assertThat(ranked.scoreGetter().score(1L)).isCloseTo(5.0f, 
within(0.000001f));
+        assertThat(ranked.scoreGetter().score(2L)).isCloseTo(1.0f, 
within(0.000001f));
+        
assertThat(ranked.scoreGetter().score(1L)).isGreaterThan(ranked.scoreGetter().score(2L));
+    }
+
+    @Test
+    public void testWeightedScoreSingleHitRouteMapsToFullWeight() {
+        // A route with a single hit has no spread (min == max); it must not 
produce NaN/Infinity
+        // and must keep contributing rather than being zeroed out.
+        ScoredGlobalIndexResult singleHit = result(new long[] {7}, new float[] 
{42.0f});
+
+        ScoredGlobalIndexResult ranked =
+                HybridSearchRanker.weightedScore(
+                        Collections.singletonList(
+                                new 
HybridSearchRanker.WeightedResult(singleHit, 2.0f)),
+                        1);
+
+        assertThat(ranked.scoreGetter().score(7L)).isCloseTo(2.0f, 
within(0.000001f));
+    }
+
+    @Test
+    public void testWeightedScoreAllTiedRouteMapsEachHitToFullWeight() {
+        // All hits share the same score (no relative signal); each maps to 
1.0 -> weight.
+        ScoredGlobalIndexResult tied = result(new long[] {1, 2, 3}, new 
float[] {5.0f, 5.0f, 5.0f});
+
+        ScoredGlobalIndexResult ranked =
+                HybridSearchRanker.weightedScore(
+                        Collections.singletonList(
+                                new HybridSearchRanker.WeightedResult(tied, 
2.0f)),
+                        3);
+
+        assertThat(ranked.scoreGetter().score(1L)).isCloseTo(2.0f, 
within(0.000001f));
+        assertThat(ranked.scoreGetter().score(2L)).isCloseTo(2.0f, 
within(0.000001f));
+        assertThat(ranked.scoreGetter().score(3L)).isCloseTo(2.0f, 
within(0.000001f));
     }
 
     private ScoredGlobalIndexResult result(long[] rowIds, float[] scores) {
diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py 
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 39a93a0167..bb5a32cced 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -376,11 +376,28 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
         scores = {}
         for route_result in route_results:
             result = route_result.result
+            weight = route_result.route.weight
             score_getter = result.score_getter()
-            for row_id in result.results():
-                contribution = route_result.route.weight * (
-                    score_getter(row_id) or 0.0)
-                scores[row_id] = scores.get(row_id, 0.0) + contribution
+
+            # Route score scales are heterogeneous (e.g. bounded vector 
similarity
+            # vs unbounded BM25), so raw scores are not comparable across 
routes.
+            # Min-max normalize each route into [0, 1] before weighting, so 
that
+            # weights -- not a route's numeric magnitude -- decide its 
influence on
+            # the fused score. This mirrors the Java HybridSearchRanker.
+            route_scores = {
+                row_id: (score_getter(row_id) or 0.0)
+                for row_id in result.results()
+            }
+            if not route_scores:
+                continue
+            min_score = min(route_scores.values())
+            score_range = max(route_scores.values()) - min_score
+            for row_id, raw in route_scores.items():
+                # No spread within the route (single hit or all ties) carries 
no
+                # relative signal, so every hit maps to 1.0 rather than zeroed 
out.
+                normalized = (
+                    (raw - min_score) / score_range if score_range > 0.0 else 
1.0)
+                scores[row_id] = scores.get(row_id, 0.0) + weight * normalized
         return _top_k(scores, self._limit)
 
     def _split_partition_filter(self, predicate):
diff --git a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py 
b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
new file mode 100644
index 0000000000..aa4936dec2
--- /dev/null
+++ b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+"""Tests for HybridSearchBuilderImpl ranking, mirroring the Java
+HybridSearchRankerTest so the weighted_score ranker stays consistent across
+languages (per-route min-max normalization before weighting)."""
+
+import unittest
+
+from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
+from pypaimon.table.source.hybrid_search_builder import (
+    HybridSearchBuilderImpl, HybridSearchRoute, HybridSearchRouteResult)
+
+
+def _route_result(weight, id_to_scores):
+    route = HybridSearchRoute.vector_route("f", [1.0], 10, weight=weight)
+    return HybridSearchRouteResult(route, 
DictBasedScoredIndexResult(id_to_scores))
+
+
+def _builder(limit):
+    builder = HybridSearchBuilderImpl(table=None)
+    builder._limit = limit
+    return builder
+
+
+class HybridSearchRankerTest(unittest.TestCase):
+
+    def test_weighted_score_normalizes_heterogeneous_route_scales(self):
+        # Vector-like route (bounded ~[0, 1]) and BM25-like route (unbounded, 
larger).
+        vector_route = _route_result(5.0, {1: 0.95, 2: 0.10})
+        text_route = _route_result(1.0, {1: 2.0, 2: 25.0})
+
+        ranked = _builder(2)._weighted_score([vector_route, text_route])
+        getter = ranked.score_getter()
+
+        # rowId 1: 5 * 1.0 (vector best) + 1 * 0.0 (text worst) = 5.0
+        # rowId 2: 5 * 0.0 (vector worst) + 1 * 1.0 (text best) = 1.0
+        self.assertAlmostEqual(getter(1), 5.0, places=6)
+        self.assertAlmostEqual(getter(2), 1.0, places=6)
+        self.assertGreater(getter(1), getter(2))
+
+    def test_weighted_score_single_hit_route_maps_to_full_weight(self):
+        # min == max: must not divide by zero and must keep contributing.
+        single_hit = _route_result(2.0, {7: 42.0})
+
+        ranked = _builder(1)._weighted_score([single_hit])
+
+        self.assertAlmostEqual(ranked.score_getter()(7), 2.0, places=6)
+
+    def test_weighted_score_all_tied_route_maps_each_hit_to_full_weight(self):
+        tied = _route_result(2.0, {1: 5.0, 2: 5.0, 3: 5.0})
+
+        ranked = _builder(3)._weighted_score([tied])
+        getter = ranked.score_getter()
+
+        self.assertAlmostEqual(getter(1), 2.0, places=6)
+        self.assertAlmostEqual(getter(2), 2.0, places=6)
+        self.assertAlmostEqual(getter(3), 2.0, places=6)
+
+    def test_rrf_unaffected_and_respects_weight_on_same_input(self):
+        vector_route = _route_result(5.0, {1: 0.95, 2: 0.10})
+        text_route = _route_result(1.0, {1: 2.0, 2: 25.0})
+
+        ranked = _builder(2)._rrf([vector_route, text_route])
+        getter = ranked.score_getter()
+
+        # Rank-based fusion respects the 5x vector weight: rowId 1 wins.
+        self.assertGreater(getter(1), getter(2))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/HybridSearchTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/HybridSearchTest.scala
index 7bfbfcfa18..bfc39cad3a 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/HybridSearchTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/HybridSearchTest.scala
@@ -84,6 +84,75 @@ class HybridSearchTest extends PaimonSparkTestBase {
     }
   }
 
+  test("weighted_score exposes per-route min-max normalized fused scores") {
+    withTable("T") {
+      spark.sql("""
+                  |CREATE TABLE T (id INT, vec_a ARRAY<FLOAT>, vec_b 
ARRAY<FLOAT>)
+                  |TBLPROPERTIES (
+                  |  'bucket' = '-1',
+                  |  'global-index.row-count-per-shard' = '10000',
+                  |  'row-tracking.enabled' = 'true',
+                  |  'data-evolution.enabled' = 'true',
+                  |  'test.vector.dimension' = '2',
+                  |  'test.vector.required-option.key' = 'ivf.nprobe',
+                  |  'test.vector.required-option.value' = '16')
+                  |""".stripMargin)
+
+      // Both columns hold identical vectors, so each route ranks id0 best and 
id2 worst.
+      // Scores vs query [1, 0] are strictly ordered id0 > id1 > id2 under 
every supported
+      // metric, so min-max maps id0 -> 1.0 and id2 -> 0.0 in BOTH routes 
regardless of metric.
+      // id2's raw score is > 0, so a fused 0.0 can only come from 
normalization, not a raw sum.
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (0, array(1.0f, 0.0f), array(1.0f, 0.0f)),
+                  |  (1, array(0.8f, 0.6f), array(0.8f, 0.6f)),
+                  |  (2, array(0.6f, 0.8f), array(0.6f, 0.8f))
+                  |""".stripMargin)
+
+      spark
+        .sql(s"CALL sys.create_global_index(table => 'test.T', index_column => 
'vec_a', " +
+          s"index_type => '${TestVectorGlobalIndexerFactory.IDENTIFIER}')")
+        .collect()
+      spark
+        .sql(s"CALL sys.create_global_index(table => 'test.T', index_column => 
'vec_b', " +
+          s"index_type => '${TestVectorGlobalIndexerFactory.IDENTIFIER}')")
+        .collect()
+
+      val scores = spark
+        .sql("""
+               |SELECT id, __paimon_search_score
+               |FROM hybrid_search(
+               |  'T',
+               |  array(
+               |    named_struct(
+               |      'field', 'vec_a',
+               |      'query_vector', array(1.0f, 0.0f),
+               |      'limit', 3,
+               |      'weight', 2.0f,
+               |      'options', map('ivf.nprobe', '16')),
+               |    named_struct(
+               |      'field', 'vec_b',
+               |      'query_vector', array(1.0f, 0.0f),
+               |      'limit', 3,
+               |      'weight', 1.0f,
+               |      'options', map('ivf.nprobe', '16'))),
+               |  array(),
+               |  3,
+               |  'weighted_score')
+               |""".stripMargin)
+        .collect()
+        .map(row => row.getInt(0) -> row.getFloat(1))
+        .toMap
+
+      // id0 is the top hit in both routes -> 1.0 each -> 2.0 * 1.0 + 1.0 * 
1.0 = sum(weights).
+      assert(math.abs(scores(0) - 3.0f) < 1e-5)
+      // id2 is the worst hit in both routes -> min-max maps it to 0.0 each -> 
fused 0.0.
+      // Without normalization this would be 2.0 * raw_a + 1.0 * raw_b > 0.
+      assert(math.abs(scores(2) - 0.0f) < 1e-5)
+      assert(scores(0) > scores(2))
+    }
+  }
+
   test("hybrid search ranks vector and full-text routes") {
     withTable("T") {
       spark.sql("""

Reply via email to