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 976396b39f [vector] Add MRR hybrid search ranker (#8351)
976396b39f is described below

commit 976396b39f3704f298c8c7c27a77f6978925e10d
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jun 25 20:44:44 2026 +0800

    [vector] Add MRR hybrid search ranker (#8351)
    
    Adds `mrr` as a built-in hybrid search ranker. MRR uses each route
    result order and sums `weight / rank` for matching rows, then returns
    the final top-k rows.
---
 .../global-index/hybrid-search.mdx                 | 57 +++++++++++++++++++++-
 .../paimon/globalindex/HybridSearchRanker.java     | 36 +++++++++++++-
 .../paimon/globalindex/HybridSearchRankerTest.java | 23 +++++++++
 .../pypaimon/table/source/hybrid_search_builder.py | 28 ++++++++---
 .../pypaimon/tests/hybrid_search_ranker_test.py    | 18 ++++++-
 .../plans/logical/PaimonTableValuedFunctions.scala |  3 +-
 .../plans/logical/VectorSearchQueryTest.scala      | 22 +++++++++
 7 files changed, 173 insertions(+), 14 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/hybrid-search.mdx 
b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
index e4923bdec8..bdc44314dc 100644
--- a/docs/docs/multimodal-table/global-index/hybrid-search.mdx
+++ b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
@@ -60,8 +60,8 @@ CALL sys.create_global_index(
 
 For Spark SQL, use the `hybrid_search(table_name, vector_routes, 
full_text_routes, limit[, ranker])`
 table-valued function. The fourth argument is the final number of ranked 
results to return.
-The optional fifth argument selects the ranker. Supported rankers are `rrf` and
-`weighted_score`; the default is `rrf`.
+The optional fifth argument selects the ranker. Supported rankers are `rrf`, 
`weighted_score`,
+and `mrr`; the default is `rrf`.
 
 Rankers combine route scores differently:
 
@@ -69,6 +69,7 @@ Rankers combine route scores differently:
 |---|---|---|
 | `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` | 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. |
+| `mrr` | Emphasizing top-ranked hits from each route | Weighted 
reciprocal-rank fusion sums `weight / rank` for each row returned by a route, 
where rank starts at 1. |
 
 The second argument is an array of vector route configs created by 
`named_struct`:
 
@@ -148,6 +149,24 @@ FROM hybrid_search(
   'weighted_score');
 ```
 
+Use `mrr` when you want weighted reciprocal-rank fusion:
+
+```sql
+SELECT id, __paimon_search_score
+FROM hybrid_search(
+  'my_table',
+  array(
+    named_struct(
+      'field', 'title_embedding',
+      'query_vector', array(1.0f, 0.0f, 0.0f),
+      'limit', 50,
+      'weight', 2.0f,
+      'options', map('ivf.nprobe', '32'))),
+  array(),
+  10,
+  'mrr');
+```
+
 Spark SQL adds `__paimon_search_score` to expose the ranked score.
 
 </TabItem>
@@ -205,6 +224,22 @@ GlobalIndexResult vectorOnlyResult =
                 .executeLocal();
 ```
 
+Use `withRanker("mrr")` for weighted reciprocal-rank fusion:
+
+```java
+GlobalIndexResult mrrResult =
+        table.newHybridSearchBuilder()
+                .addVectorRoute(
+                        "title_embedding",
+                        new float[] {1.0f, 0.0f, 0.0f},
+                        50,
+                        1.0f,
+                        java.util.Collections.singletonMap("ivf.nprobe", "32"))
+                .withLimit(10)
+                .withRanker("mrr")
+                .executeLocal();
+```
+
 For Java, use `Table.newHybridSearchBuilder()` to configure routes, final 
limit, and ranker
 directly. `VectorSearch` and `HybridSearch` are internal pushdown 
representations.
 
@@ -267,6 +302,24 @@ result = (
 )
 ```
 
+Use `with_ranker("mrr")` for weighted reciprocal-rank fusion:
+
+```python
+result = (
+    table.new_hybrid_search_builder()
+    .add_vector_route(
+        "title_embedding",
+        [1.0, 0.0, 0.0],
+        limit=50,
+        weight=1.0,
+        options={"ivf.nprobe": "32"},
+    )
+    .with_limit(10)
+    .with_ranker("mrr")
+    .execute_local()
+)
+```
+
 </TabItem>
 
 </Tabs>
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 675e4b1a38..e3c910aa4b 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
@@ -33,6 +33,7 @@ public class HybridSearchRanker {
 
     public static final String RRF_RANKER = "rrf";
     public static final String WEIGHTED_SCORE_RANKER = "weighted_score";
+    public static final String MRR_RANKER = "mrr";
 
     private static final float RRF_K = 60.0f;
 
@@ -49,8 +50,11 @@ public class HybridSearchRanker {
 
     public static ScoredGlobalIndexResult rank(
             String ranker, List<WeightedResult> results, int limit) {
-        if (WEIGHTED_SCORE_RANKER.equals(normalizeRanker(ranker))) {
+        String normalized = normalizeRanker(ranker);
+        if (WEIGHTED_SCORE_RANKER.equals(normalized)) {
             return weightedScore(results, limit);
+        } else if (MRR_RANKER.equals(normalized)) {
+            return mrr(results, limit);
         }
         return rrf(results, limit);
     }
@@ -60,7 +64,9 @@ public class HybridSearchRanker {
             return RRF_RANKER;
         }
         String normalized = ranker.trim().toLowerCase();
-        if (!RRF_RANKER.equals(normalized) && 
!WEIGHTED_SCORE_RANKER.equals(normalized)) {
+        if (!RRF_RANKER.equals(normalized)
+                && !WEIGHTED_SCORE_RANKER.equals(normalized)
+                && !MRR_RANKER.equals(normalized)) {
             throw new IllegalArgumentException("Unsupported hybrid ranker: " + 
ranker);
         }
         return normalized;
@@ -134,6 +140,32 @@ public class HybridSearchRanker {
         return topK(scores, limit);
     }
 
+    public static ScoredGlobalIndexResult mrr(
+            List<ScoredGlobalIndexResult> results, float[] weights, int limit) 
{
+        List<WeightedResult> weightedResults = new ArrayList<>(results.size());
+        for (int i = 0; i < results.size(); i++) {
+            weightedResults.add(new WeightedResult(results.get(i), 
weightAt(weights, i)));
+        }
+        return mrr(weightedResults, limit);
+    }
+
+    public static ScoredGlobalIndexResult mrr(List<WeightedResult> results, 
int limit) {
+        Map<Long, Float> scores = new HashMap<>();
+        for (WeightedResult weightedResult : results) {
+            ScoredGlobalIndexResult result = weightedResult.result();
+            float weight = weightedResult.weight();
+            List<Long> ranked = rankedRowIds(result);
+            for (int rank = 0; rank < ranked.size(); rank++) {
+                Long rowId = ranked.get(rank);
+                float contribution = weight / (rank + 1.0f);
+                scores.compute(
+                        rowId,
+                        (k, oldScore) -> oldScore == null ? contribution : 
oldScore + contribution);
+            }
+        }
+        return topK(scores, limit);
+    }
+
     private static List<Long> rankedRowIds(ScoredGlobalIndexResult result) {
         List<Long> rowIds = new ArrayList<>();
         for (long rowId : result.results()) {
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 0e3ad8cac5..a1c286f8d4 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
@@ -147,6 +147,29 @@ public class HybridSearchRankerTest {
         assertThat(ranked.scoreGetter().score(2L)).isCloseTo(2.0f, 
within(0.000001f));
     }
 
+    @Test
+    public void testMrrFavorsRowsWithStrongRanksAcrossRoutes() {
+        ScoredGlobalIndexResult first = result(new long[] {1, 2}, new float[] 
{0.9f, 0.8f});
+        ScoredGlobalIndexResult second = result(new long[] {2, 3}, new float[] 
{0.7f, 0.6f});
+
+        assertThat(HybridSearchRanker.normalizeRanker("mrr"))
+                .isEqualTo(HybridSearchRanker.MRR_RANKER);
+
+        ScoredGlobalIndexResult ranked =
+                HybridSearchRanker.rank(
+                        HybridSearchRanker.MRR_RANKER,
+                        Arrays.asList(
+                                new HybridSearchRanker.WeightedResult(first, 
1.0f),
+                                new HybridSearchRanker.WeightedResult(second, 
2.0f)),
+                        2);
+
+        assertThat(ranked.results()).contains(1L, 2L);
+        assertThat(ranked.results()).doesNotContain(3L);
+        assertThat(ranked.scoreGetter().score(2L)).isCloseTo(2.5f, 
within(0.000001f));
+        assertThat(ranked.scoreGetter().score(1L)).isCloseTo(1.0f, 
within(0.000001f));
+        
assertThat(ranked.scoreGetter().score(2L)).isGreaterThan(ranked.scoreGetter().score(1L));
+    }
+
     @Test
     public void testRejectNonFiniteWeights() {
         ScoredGlobalIndexResult result = result(new long[] {1}, new float[] 
{1.0f});
diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py 
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 8edeeac251..b1875c43c5 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -33,6 +33,7 @@ from pypaimon.globalindex.vector_search_result import (
 
 RRF_RANKER = "rrf"
 WEIGHTED_SCORE_RANKER = "weighted_score"
+MRR_RANKER = "mrr"
 _RRF_K = 60.0
 
 
@@ -46,7 +47,7 @@ def _normalize_ranker(ranker: Optional[str]) -> str:
     if ranker is None or not ranker.strip():
         return RRF_RANKER
     normalized = ranker.strip().lower()
-    if normalized not in (RRF_RANKER, WEIGHTED_SCORE_RANKER):
+    if normalized not in (RRF_RANKER, WEIGHTED_SCORE_RANKER, MRR_RANKER):
         raise ValueError("Unsupported hybrid ranker: %s" % ranker)
     return normalized
 
@@ -322,6 +323,8 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
         ]
         if self._ranker == WEIGHTED_SCORE_RANKER:
             return self._weighted_score(non_empty)
+        if self._ranker == MRR_RANKER:
+            return self._mrr(non_empty)
         return self._rrf(non_empty)
 
     def _validate_search(self):
@@ -364,17 +367,19 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
     def _rrf(self, route_results):
         scores = {}
         for route_result in route_results:
-            result = route_result.result
-            score_getter = result.score_getter()
-            row_ids = sorted(
-                result.results(),
-                key=lambda row_id: (
-                    -(score_getter(row_id) or 0.0), row_id))
-            for rank, row_id in enumerate(row_ids):
+            for rank, row_id in 
enumerate(_ranked_row_ids(route_result.result)):
                 contribution = route_result.route.weight / (_RRF_K + rank + 
1.0)
                 scores[row_id] = scores.get(row_id, 0.0) + contribution
         return _top_k(scores, self._limit)
 
+    def _mrr(self, route_results):
+        scores = {}
+        for route_result in route_results:
+            for rank, row_id in 
enumerate(_ranked_row_ids(route_result.result)):
+                contribution = route_result.route.weight / (rank + 1.0)
+                scores[row_id] = scores.get(row_id, 0.0) + contribution
+        return _top_k(scores, self._limit)
+
     def _weighted_score(self, route_results):
         scores = {}
         for route_result in route_results:
@@ -456,6 +461,13 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
         return predicate.new_index(name_to_idx[predicate.field])
 
 
+def _ranked_row_ids(result):
+    score_getter = result.score_getter()
+    return sorted(
+        result.results(),
+        key=lambda row_id: (-(score_getter(row_id) or 0.0), row_id))
+
+
 def _top_k(scores, limit):
     if not scores:
         return ScoredGlobalIndexResult.create_empty()
diff --git a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py 
b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
index 47dc85a428..4647fa44d0 100644
--- a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
+++ b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
@@ -24,7 +24,8 @@ import unittest
 
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
 from pypaimon.table.source.hybrid_search_builder import (
-    HybridSearchBuilderImpl, HybridSearchRoute, HybridSearchRouteResult)
+    HybridSearchBuilderImpl, HybridSearchRoute, HybridSearchRouteResult,
+    MRR_RANKER)
 
 
 def _route_result(weight, id_to_scores):
@@ -82,6 +83,21 @@ class HybridSearchRankerTest(unittest.TestCase):
         # Rank-based fusion respects the 5x vector weight: rowId 1 wins.
         self.assertGreater(getter(1), getter(2))
 
+    def test_mrr_favors_rows_with_strong_ranks_across_routes(self):
+        first = _route_result(1.0, {1: 0.9, 2: 0.8})
+        second = _route_result(2.0, {2: 0.7, 3: 0.6})
+        builder = _builder(2).with_ranker(MRR_RANKER)
+        builder._routes = [first.route, second.route]
+
+        ranked = builder.rank([first, second])
+        getter = ranked.score_getter()
+
+        self.assertEqual({1, 2}, set(ranked.results()))
+        self.assertNotIn(3, set(ranked.results()))
+        self.assertAlmostEqual(getter(2), 2.5, places=6)
+        self.assertAlmostEqual(getter(1), 1.0, places=6)
+        self.assertGreater(getter(2), getter(1))
+
     def test_route_rejects_non_finite_weight(self):
         for weight in (math.nan, math.inf, -math.inf):
             with self.subTest(weight=weight):
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
index ad3675f369..ee3a930386 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
@@ -644,7 +644,8 @@ case class VectorSearchQuery(override val args: 
Seq[Expression])
  *   - vector_routes: route config array with field, query_vector, limit, 
weight, and options fields
  *   - full_text_routes: route config array with query, limit, weight, and 
empty options fields
  *   - limit: the final number of ranked top results to return
- *   - ranker: optional ranker for combining results from multiple routes
+ *   - ranker: optional ranker for combining results from multiple routes: 
rrf, weighted_score, or
+ *     mrr
  */
 case class HybridSearchQuery(override val args: Seq[Expression])
   extends PaimonTableValueFunction(HYBRID_SEARCH) {
diff --git 
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
index 08bc308182..6ec37ac159 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
+++ 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
@@ -101,6 +101,28 @@ class VectorSearchQueryTest extends AnyFunSuite {
     assert(search.routes().get(0).options().isEmpty)
   }
 
+  test("create hybrid search with mrr ranker") {
+    val search = HybridSearchQuery(Seq.empty).createHybridSearch(
+      innerTable,
+      Seq(
+        CreateArray(
+          Seq(
+            CreateNamedStruct(
+              Seq(
+                Literal("vector_column"),
+                Literal("title_vec"),
+                Literal("query_vector"),
+                CreateArray(Seq(Literal(1.0f), Literal(0.0f)))
+              )))),
+        CreateArray(Seq.empty),
+        Literal(7),
+        Literal("mrr")
+      )
+    )
+
+    assert(search.ranker() == "mrr")
+  }
+
   test("create hybrid search with full-text route configs") {
     val search = HybridSearchQuery(Seq.empty).createHybridSearch(
       innerTable,

Reply via email to