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 a3863cc6a5 [core][spark][python] Reject full-text hybrid route options 
(#8324)
a3863cc6a5 is described below

commit a3863cc6a5b1acd0902476a77c325157e6480ac9
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jun 23 11:56:52 2026 +0800

    [core][spark][python] Reject full-text hybrid route options (#8324)
    
    Hybrid full-text routes accepted `options` from Spark SQL, Java, and
    Python APIs, but the execution path never consumed them.
    `HybridSearchRoute` stored the map, while `FullTextSearchBuilder` and
    `FullTextSearch` have no options channel, so non-empty full-text route
    options were silently ignored.
    
    This PR rejects non-empty full-text route options fail-fast, keeps empty
    Spark `map()` valid for `named_struct` type consistency, updates the
    docs, and adds regression tests.
---
 .../global-index/hybrid-search.mdx                 |  5 ++--
 .../apache/paimon/predicate/HybridSearchRoute.java |  9 ++++++++
 .../apache/paimon/predicate/FullTextQueryTest.java | 27 ++++++++++++++++++++++
 .../paimon/table/source/HybridSearchBuilder.java   |  2 +-
 .../pypaimon/table/source/hybrid_search_builder.py |  9 ++++++++
 .../pypaimon/tests/vector_search_filter_test.py    | 17 ++++++++++++++
 .../plans/logical/PaimonTableValuedFunctions.scala |  2 +-
 .../plans/logical/VectorSearchQueryTest.scala      | 23 ++++++++++++++++++
 8 files changed, 90 insertions(+), 4 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/hybrid-search.mdx 
b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
index 52068d2fed..e4923bdec8 100644
--- a/docs/docs/multimodal-table/global-index/hybrid-search.mdx
+++ b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
@@ -87,10 +87,11 @@ The third argument is an array of full-text route configs 
created by `named_stru
 | `query` | Yes | N/A | LanceDB-style full-text query JSON for this route. |
 | `limit` | No | Final limit | Top K results to retrieve from this text column 
before ranking. |
 | `weight` | No | `1.0` | Weight for this route when ranking results. |
-| `options` | No | Empty map | Route-specific full-text search options. |
+| `options` | No | Empty map | Reserved for future full-text search options. 
Only an empty map is accepted. |
 
 Within each route array, every `named_struct` should use the same fields 
because
-Spark requires array elements to have the same struct type.
+Spark requires array elements to have the same struct type. Full-text route 
`options` is
+currently a reserved field; pass `map()` when the field is needed for 
struct-type consistency.
 
 Use route `limit` values larger than the final limit when each route should 
contribute enough
 candidates for ranking. For example, with a final limit of `10`, route limits 
such as `50` or `100`
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 beddab745a..57e5369bd4 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
@@ -68,11 +68,19 @@ public class HybridSearchRoute implements Serializable {
 
     public static HybridSearchRoute fullText(
             String queryJson, int limit, float weight, Map<String, String> 
options) {
+        checkFullTextOptions(options);
         FullTextQuery query = FullTextQuery.fromJson(queryJson);
         return new HybridSearchRoute(
                 RouteType.FULL_TEXT, query.columns().get(0), null, query, 
limit, weight, options);
     }
 
+    private static void checkFullTextOptions(Map<String, String> options) {
+        if (options != null && !options.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "Full-text hybrid route options are not supported yet.");
+        }
+    }
+
     private HybridSearchRoute(
             RouteType routeType,
             String fieldName,
@@ -239,6 +247,7 @@ public class HybridSearchRoute implements Serializable {
 
         public HybridSearchRoute build() {
             if (fullTextQuery != null) {
+                checkFullTextOptions(options);
                 return new HybridSearchRoute(
                         RouteType.FULL_TEXT,
                         fullTextQuery.columns().get(0),
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 2cd4e2a491..5fc34ecd34 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
@@ -24,6 +24,7 @@ import java.util.Arrays;
 import java.util.Collections;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link FullTextQuery}. */
 public class FullTextQueryTest {
@@ -172,4 +173,30 @@ public class FullTextQueryTest {
                         
"{\"match_phrase\":{\"column\":\"content\",\"terms\":\"paimon lake\","
                                 + "\"slop\":1}}");
     }
+
+    @Test
+    public void testHybridFullTextRouteRejectsOptions() {
+        assertThatThrownBy(
+                        () ->
+                                HybridSearchRoute.fullText(
+                                        "{\"match\":{\"column\":\"content\","
+                                                + "\"terms\":\"paimon 
lake\"}}",
+                                        10,
+                                        1.0f,
+                                        
Collections.singletonMap("some.option", "x")))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Full-text hybrid route options are not 
supported yet");
+
+        assertThatThrownBy(
+                        () ->
+                                HybridSearchRoute.builder()
+                                        .query(
+                                                
"{\"match\":{\"column\":\"content\","
+                                                        + "\"terms\":\"paimon 
lake\"}}")
+                                        .limit(10)
+                                        .option("some.option", "x")
+                                        .build())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Full-text hybrid route options are not 
supported yet");
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilder.java
index 01304e1315..c74abe0052 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilder.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilder.java
@@ -67,7 +67,7 @@ public interface HybridSearchBuilder extends Serializable {
         return addFullTextRoute(queryJson, limit, weight, null);
     }
 
-    /** Add a full-text-search route. */
+    /** Add a full-text-search route. Non-empty options are not supported yet. 
*/
     default HybridSearchBuilder addFullTextRoute(
             String queryJson, int limit, float weight, Map<String, String> 
options) {
         return addRoute(HybridSearchRoute.fullText(queryJson, limit, weight, 
options));
diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py 
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 868ea1f44b..3415d3bf9f 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -35,6 +35,12 @@ WEIGHTED_SCORE_RANKER = "weighted_score"
 _RRF_K = 60.0
 
 
+def _check_full_text_options(options: Dict[str, str]):
+    if options:
+        raise ValueError(
+            "Full-text hybrid route options are not supported yet.")
+
+
 def _normalize_ranker(ranker: Optional[str]) -> str:
     if ranker is None or not ranker.strip():
         return RRF_RANKER
@@ -74,6 +80,8 @@ class HybridSearchRoute:
         if self.weight <= 0:
             raise ValueError("Weight must be positive, got: %s" % self.weight)
         self.options = dict(self.options or {})
+        if self.route_type == self.FULL_TEXT:
+            _check_full_text_options(self.options)
 
     @classmethod
     def vector_route(
@@ -99,6 +107,7 @@ class HybridSearchRoute:
             limit: int,
             weight: float = 1.0,
             options: Optional[Dict[str, str]] = None) -> 'HybridSearchRoute':
+        _check_full_text_options(options or {})
         query = FullTextQuery.from_json(query_json)
         return cls(
             route_type=cls.FULL_TEXT,
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 0e29e5c1ad..9b1aae3261 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -1968,6 +1968,23 @@ class HybridSearchBuilderTest(unittest.TestCase):
             builder.route_builders()
         self.assertIn("full-text routes", str(ctx.exception))
 
+    def test_hybrid_search_rejects_full_text_route_options(self):
+        from pypaimon.table.source.hybrid_search_builder import (
+            HybridSearchBuilderImpl,
+        )
+
+        id_field = _field(0, "id")
+        content = _field(1, "content", "STRING")
+        table = _StubTable(fields=[id_field, content], entries=[])
+
+        with self.assertRaises(ValueError) as ctx:
+            HybridSearchBuilderImpl(table).add_full_text_route(
+                '{"match":{"column":"content","terms":"paimon search"}}',
+                10,
+                options={"some.option": "x"})
+        self.assertIn("Full-text hybrid route options are not supported yet",
+                      str(ctx.exception))
+
     def test_hybrid_search_partition_filter_prunes_full_text_route(self):
         from pypaimon.table.source.hybrid_search_builder import (
             HybridSearchBuilderImpl,
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 4cafdc0b20..bf3e53bf2f 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
@@ -455,7 +455,7 @@ case class VectorSearchQuery(override val args: 
Seq[Expression])
  * Usage: hybrid_search(table_name, vector_routes, full_text_routes, limit[, 
ranker])
  *   - table_name: the Paimon table to search
  *   - 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 
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
  */
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 4a13fbc56b..82a91fc9ee 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
@@ -133,6 +133,29 @@ class VectorSearchQueryTest extends AnyFunSuite {
     assert(search.routes().get(0).weight() == 1.5f)
   }
 
+  test("reject hybrid full-text route with non-empty options") {
+    val exception = 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("options"),
+                CreateMap(Seq(Literal("some.option"), Literal("x")))
+              ))
+            )),
+          Literal(5)
+        )
+      )
+    }
+
+    assert(exception.getMessage.contains("Full-text hybrid route options are 
not supported yet"))
+  }
+
   test("create full-text search") {
     val search = FullTextSearchQuery(Seq.empty).createFullTextSearch(
       innerTable,

Reply via email to