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 1b6f6cbe9a [core][spark][python] Reject non-finite hybrid route
weights (#8327)
1b6f6cbe9a is described below
commit 1b6f6cbe9acccd591dc0ff4a471378d3f48933a1
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jun 23 19:26:48 2026 +0800
[core][spark][python] Reject non-finite hybrid route weights (#8327)
Hybrid search route weights were only checked with `weight <= 0`. `NaN`
bypassed that check, while `Infinity` was accepted and could dominate
RRF or weighted-score fusion.
This PR requires route weights to be finite and positive across Java,
Spark TVF parsing, and Python. It also validates direct
`HybridSearchRanker` weight inputs through `WeightedResult`.
---
.../paimon/globalindex/HybridSearchRanker.java | 10 ++++-
.../apache/paimon/predicate/HybridSearchRoute.java | 5 ++-
.../paimon/globalindex/HybridSearchRankerTest.java | 31 ++++++++++++++
.../apache/paimon/predicate/FullTextQueryTest.java | 37 +++++++++++++++++
.../pypaimon/table/source/hybrid_search_builder.py | 6 ++-
.../pypaimon/tests/hybrid_search_ranker_test.py | 16 ++++++++
.../plans/logical/PaimonTableValuedFunctions.scala | 7 +++-
.../plans/logical/VectorSearchQueryTest.scala | 47 ++++++++++++++++++++++
8 files changed, 152 insertions(+), 7 deletions(-)
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 eb2f1d45ac..1b6504c0f9 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
@@ -182,6 +182,14 @@ public class HybridSearchRanker {
return weights[index];
}
+ private static float checkWeight(float weight) {
+ if (!Float.isFinite(weight) || weight <= 0) {
+ throw new IllegalArgumentException(
+ "Weight must be finite and positive, got: " + weight);
+ }
+ return weight;
+ }
+
/** Weighted result from one search route. */
public static class WeightedResult implements Serializable {
@@ -192,7 +200,7 @@ public class HybridSearchRanker {
public WeightedResult(ScoredGlobalIndexResult result, float weight) {
this.result = result;
- this.weight = weight;
+ this.weight = checkWeight(weight);
}
public ScoredGlobalIndexResult result() {
diff --git
a/paimon-common/src/main/java/org/apache/paimon/predicate/HybridSearchRoute.java
b/paimon-common/src/main/java/org/apache/paimon/predicate/HybridSearchRoute.java
index 57e5369bd4..b0746635bf 100644
---
a/paimon-common/src/main/java/org/apache/paimon/predicate/HybridSearchRoute.java
+++
b/paimon-common/src/main/java/org/apache/paimon/predicate/HybridSearchRoute.java
@@ -104,8 +104,9 @@ public class HybridSearchRoute implements Serializable {
if (limit <= 0) {
throw new IllegalArgumentException("Limit must be positive, got: "
+ limit);
}
- if (weight <= 0) {
- throw new IllegalArgumentException("Weight must be positive, got:
" + weight);
+ if (!Float.isFinite(weight) || weight <= 0) {
+ throw new IllegalArgumentException(
+ "Weight must be finite and positive, got: " + weight);
}
this.routeType = routeType;
this.fieldName = fieldName;
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 f927d0cf8a..0d4b2c0824 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
@@ -29,6 +29,7 @@ import java.util.Iterator;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.within;
/** Tests for {@link HybridSearchRanker}. */
@@ -126,6 +127,36 @@ public class HybridSearchRankerTest {
assertThat(ranked.scoreGetter().score(3L)).isCloseTo(2.0f,
within(0.000001f));
}
+ @Test
+ public void testRejectNonFiniteWeights() {
+ ScoredGlobalIndexResult result = result(new long[] {1}, new float[]
{1.0f});
+
+ assertThatThrownBy(
+ () ->
+ HybridSearchRanker.rrf(
+ Collections.singletonList(result),
+ new float[] {Float.NaN},
+ 1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Weight must be finite and positive");
+
+ assertThatThrownBy(
+ () ->
+ HybridSearchRanker.weightedScore(
+ Collections.singletonList(result),
+ new float[] {Float.POSITIVE_INFINITY},
+ 1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Weight must be finite and positive");
+
+ assertThatThrownBy(
+ () ->
+ new HybridSearchRanker.WeightedResult(
+ result, Float.NEGATIVE_INFINITY))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Weight must be finite and positive");
+ }
+
private ScoredGlobalIndexResult result(long[] rowIds, float[] scores) {
RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
return result(rowIds, scores, bitmap);
diff --git
a/paimon-common/src/test/java/org/apache/paimon/predicate/FullTextQueryTest.java
b/paimon-common/src/test/java/org/apache/paimon/predicate/FullTextQueryTest.java
index 5fc34ecd34..ff8ad91b76 100644
---
a/paimon-common/src/test/java/org/apache/paimon/predicate/FullTextQueryTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/predicate/FullTextQueryTest.java
@@ -199,4 +199,41 @@ public class FullTextQueryTest {
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Full-text hybrid route options are not
supported yet");
}
+
+ @Test
+ public void testHybridRouteRejectsNonFiniteWeight() {
+ assertThatThrownBy(
+ () ->
+ HybridSearchRoute.vector(
+ "embedding",
+ new float[] {1.0f},
+ 10,
+ Float.NaN,
+ Collections.emptyMap()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Weight must be finite and positive");
+
+ assertThatThrownBy(
+ () ->
+ HybridSearchRoute.vector(
+ "embedding",
+ new float[] {1.0f},
+ 10,
+ Float.POSITIVE_INFINITY,
+ Collections.emptyMap()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Weight must be finite and positive");
+
+ assertThatThrownBy(
+ () ->
+ HybridSearchRoute.builder()
+ .query(
+
"{\"match\":{\"column\":\"content\","
+ + "\"terms\":\"paimon
lake\"}}")
+ .limit(10)
+ .weight(Float.NEGATIVE_INFINITY)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Weight must be finite and positive");
+ }
}
diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 3415d3bf9f..8edeeac251 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -18,6 +18,7 @@
"""Builder to build hybrid search."""
import heapq
+import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@@ -77,8 +78,9 @@ class HybridSearchRoute:
"Query cannot be None for full-text route")
if self.limit <= 0:
raise ValueError("Limit must be positive, got: %s" % self.limit)
- if self.weight <= 0:
- raise ValueError("Weight must be positive, got: %s" % self.weight)
+ if not math.isfinite(self.weight) or self.weight <= 0:
+ raise ValueError(
+ "Weight must be finite and positive, got: %s" % self.weight)
self.options = dict(self.options or {})
if self.route_type == self.FULL_TEXT:
_check_full_text_options(self.options)
diff --git a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
index aa4936dec2..47dc85a428 100644
--- a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
+++ b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
@@ -19,6 +19,7 @@
HybridSearchRankerTest so the weighted_score ranker stays consistent across
languages (per-route min-max normalization before weighting)."""
+import math
import unittest
from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
@@ -81,6 +82,21 @@ class HybridSearchRankerTest(unittest.TestCase):
# Rank-based fusion respects the 5x vector weight: rowId 1 wins.
self.assertGreater(getter(1), getter(2))
+ def test_route_rejects_non_finite_weight(self):
+ for weight in (math.nan, math.inf, -math.inf):
+ with self.subTest(weight=weight):
+ with self.assertRaisesRegex(
+ ValueError, "Weight must be finite and positive"):
+ HybridSearchRoute.vector_route(
+ "f", [1.0], 10, weight=weight)
+
+ with self.assertRaisesRegex(
+ ValueError, "Weight must be finite and positive"):
+ HybridSearchRoute.full_text_route(
+ '{"match":{"column":"content","terms":"paimon lake"}}',
+ 10,
+ weight=math.inf)
+
if __name__ == "__main__":
unittest.main()
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 bf3e53bf2f..7d41d7f1b5 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
@@ -626,8 +626,11 @@ case class HybridSearchQuery(override val args:
Seq[Expression])
case u: UTF8String => u.toString.toFloat
case other => throw new RuntimeException(s"Invalid $name type:
${other.getClass.getName}")
}
- if (parsed <= 0) {
- throw new IllegalArgumentException(s"$name must be positive, but got:
$parsed")
+ if (!java.lang.Float.isFinite(parsed) || parsed <= 0) {
+ if (name == "weight") {
+ throw new IllegalArgumentException(s"Weight must be finite and
positive, got: $parsed")
+ }
+ throw new IllegalArgumentException(s"$name must be finite and positive,
but got: $parsed")
}
parsed
}
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 82a91fc9ee..08bc308182 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
@@ -156,6 +156,53 @@ class VectorSearchQueryTest extends AnyFunSuite {
assert(exception.getMessage.contains("Full-text hybrid route options are
not supported yet"))
}
+ test("reject hybrid route with non-finite weight") {
+ val vectorException = intercept[IllegalArgumentException] {
+ 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))),
+ Literal("weight"),
+ Literal(Float.NaN)
+ ))
+ )),
+ CreateArray(Seq.empty),
+ Literal(5)
+ )
+ )
+ }
+
+ assert(vectorException.getMessage.contains("Weight must be finite and
positive"))
+
+ val fullTextException = intercept[IllegalArgumentException] {
+ HybridSearchQuery(Seq.empty).createHybridSearch(
+ innerTable,
+ Seq(
+ CreateArray(Seq.empty),
+ CreateArray(
+ Seq(
+ CreateNamedStruct(
+ Seq(
+ Literal("query"),
+ Literal("""{"match":{"column":"content","terms":"paimon
lake"}}"""),
+ Literal("weight"),
+ Literal(Float.PositiveInfinity)
+ ))
+ )),
+ Literal(5)
+ )
+ )
+ }
+
+ assert(fullTextException.getMessage.contains("Weight must be finite and
positive"))
+ }
+
test("create full-text search") {
val search = FullTextSearchQuery(Seq.empty).createFullTextSearch(
innerTable,