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

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


The following commit(s) were added to refs/heads/main by this push:
     new a1bec7bb feat(core): complete native local hybrid search (#1097)
a1bec7bb is described below

commit a1bec7bb697d26c2f7b2989cb54e838fa459786d
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 08:40:50 2026 +0800

    feat(core): complete native local hybrid search (#1097)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  21 ++
 bindings/python/src/context.rs                     |   2 +
 bindings/python/src/hybrid_search.rs               | 206 +++++++++++
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/table.rs                       |   4 +
 .../integrations/datafusion/src/hybrid_search.rs   |   9 +-
 .../integrations/datafusion/tests/read_tables.rs   |  70 +++-
 crates/paimon/src/table/hybrid_search_builder.rs   | 338 +++++++++++++-----
 .../table/hybrid_search_builder/parity_tests.rs    | 393 +++++++++++++++++++++
 9 files changed, 940 insertions(+), 104 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 9279e894..15ca03ce 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -222,6 +222,7 @@ class Table:
     def new_read_builder(self, options: Optional[Dict[str, str]] = None) -> 
ReadBuilder: ...
     def new_stream_scan(self) -> StreamTableScan: ...
     def new_full_text_search_builder(self) -> FullTextSearchBuilder: ...
+    def new_hybrid_search_builder(self) -> HybridSearchBuilder: ...
     def new_vector_search_builder(self) -> "VectorSearchBuilder": ...
     def new_batch_vector_search_builder(self) -> "BatchVectorSearchBuilder": 
...
     def new_global_index_build_builder(self) -> "GlobalIndexBuildBuilder": ...
@@ -615,6 +616,26 @@ class SearchResultReadBuilder:
         """Read user columns and scores in relevance order from the search 
snapshot."""
         ...
 
+class HybridSearchBuilder:
+    def with_limit(self, limit: int) -> HybridSearchBuilder: ...
+    def with_snapshot(self, snapshot_json: Optional[str]) -> 
HybridSearchBuilder:
+        """Reuse resolved Java Snapshot JSON; None pins an empty read view."""
+        ...
+    def with_ranker(self, ranker: Optional[str]) -> HybridSearchBuilder: ...
+    def with_rrf_ranker(self) -> HybridSearchBuilder: ...
+    def with_weighted_score_ranker(self) -> HybridSearchBuilder: ...
+    def with_filter(self, predicate: Dict[str, Any]) -> HybridSearchBuilder: 
...
+    def with_partition_filter(self, predicate: Dict[str, Any]) -> 
HybridSearchBuilder: ...
+    def add_vector_route(self, field_name: str, vector: List[float], limit: 
int,
+                         weight: float = 1.0, options: Optional[Dict[str, 
str]] = None) -> HybridSearchBuilder: ...
+    def add_full_text_route(self, field_name: str, query: str, limit: int,
+                            weight: float = 1.0, options: Optional[Dict[str, 
str]] = None) -> HybridSearchBuilder: ...
+    def execute_local(self) -> HybridSearchResult: ...
+
+class HybridSearchResult:
+    def __len__(self) -> int: ...
+    def row_ids(self) -> Dict[int, float]: ...
+
 class FullTextSearchBuilder:
     def with_query(self, field_name: str, query: str) -> 
FullTextSearchBuilder: ...
     def with_limit(self, limit: int) -> FullTextSearchBuilder: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 4c6637a4..ba8c3d87 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -732,6 +732,8 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::full_text_search::PyFullTextScanPlan>()?;
     this.add_class::<crate::full_text_search::PyFullTextRead>()?;
     this.add_class::<crate::full_text_search::PyFullTextSearchResult>()?;
+    this.add_class::<crate::hybrid_search::PyHybridSearchBuilder>()?;
+    this.add_class::<crate::hybrid_search::PyHybridSearchResult>()?;
     this.add_class::<crate::vector_search::PyVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyBatchVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyVectorScan>()?;
diff --git a/bindings/python/src/hybrid_search.rs 
b/bindings/python/src/hybrid_search.rs
new file mode 100644
index 00000000..749bdd93
--- /dev/null
+++ b/bindings/python/src/hybrid_search.rs
@@ -0,0 +1,206 @@
+// 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.
+
+//! Hybrid configuration forwarding; snapshot, routes and fusion belong to 
core.
+
+use crate::error::to_py_err;
+use crate::predicate::dict_to_table_predicate;
+use paimon::spec::{Predicate, Snapshot};
+use paimon::table::{HybridSearchRanker, HybridSearchRoute, Table};
+use paimon::vector_search::ScoredRowIds;
+use paimon_datafusion::runtime::runtime;
+use pyo3::prelude::*;
+use pyo3::types::PyDict;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+#[pyclass(name = "HybridSearchBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyHybridSearchBuilder {
+    table: Arc<Table>,
+    routes: Vec<HybridSearchRoute>,
+    filters: Vec<Predicate>,
+    limit: Option<usize>,
+    ranker: HybridSearchRanker,
+    pinned_snapshot: Option<Option<Snapshot>>,
+}
+
+impl PyHybridSearchBuilder {
+    pub(crate) fn new(table: Arc<Table>) -> Self {
+        Self {
+            table,
+            routes: Vec::new(),
+            filters: Vec::new(),
+            limit: None,
+            ranker: HybridSearchRanker::Rrf,
+            pinned_snapshot: None,
+        }
+    }
+
+    fn builder(&self) -> paimon::table::HybridSearchBuilder<'_> {
+        let mut builder = self.table.new_hybrid_search_builder();
+        for route in &self.routes {
+            builder.add_route(route.clone());
+        }
+        if let Some(limit) = self.limit {
+            builder.with_limit(limit);
+        }
+        // The stored ranker has already been validated by the core parser.
+        builder
+            .with_ranker(self.ranker.as_str())
+            .expect("validated ranker");
+        for filter in &self.filters {
+            builder.with_filter(filter.clone());
+        }
+        if let Some(snapshot) = &self.pinned_snapshot {
+            builder.with_snapshot(snapshot.as_ref());
+        }
+        builder
+    }
+}
+
+#[pymethods]
+impl PyHybridSearchBuilder {
+    #[pyo3(signature = (field_name, vector, limit, weight=1.0, options=None))]
+    fn add_vector_route(
+        mut slf: PyRefMut<'_, Self>,
+        field_name: String,
+        vector: Vec<f32>,
+        limit: usize,
+        weight: f32,
+        options: Option<HashMap<String, String>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let route = HybridSearchRoute::vector(
+            field_name,
+            vector,
+            limit,
+            weight,
+            options.unwrap_or_default(),
+        )
+        .map_err(to_py_err)?;
+        slf.routes.push(route);
+        Ok(slf)
+    }
+
+    #[pyo3(signature = (field_name, query, limit, weight=1.0, options=None))]
+    fn add_full_text_route(
+        mut slf: PyRefMut<'_, Self>,
+        field_name: String,
+        query: String,
+        limit: usize,
+        weight: f32,
+        options: Option<HashMap<String, String>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let route = HybridSearchRoute::full_text(
+            field_name,
+            query,
+            limit,
+            weight,
+            options.unwrap_or_default(),
+        )
+        .map_err(to_py_err)?;
+        slf.routes.push(route);
+        Ok(slf)
+    }
+
+    fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_, 
Self> {
+        slf.limit = Some(limit);
+        slf
+    }
+
+    #[pyo3(signature = (snapshot_json))]
+    fn with_snapshot<'py>(
+        mut slf: PyRefMut<'py, Self>,
+        snapshot_json: Option<&str>,
+    ) -> PyResult<PyRefMut<'py, Self>> {
+        let snapshot = snapshot_json
+            .map(serde_json::from_str)
+            .transpose()
+            .map_err(|error| {
+                pyo3::exceptions::PyValueError::new_err(format!("Invalid 
snapshot JSON: {error}"))
+            })?;
+        slf.pinned_snapshot = Some(snapshot);
+        Ok(slf)
+    }
+
+    #[pyo3(signature = (ranker))]
+    fn with_ranker<'py>(
+        mut slf: PyRefMut<'py, Self>,
+        ranker: Option<&str>,
+    ) -> PyResult<PyRefMut<'py, Self>> {
+        slf.ranker = 
HybridSearchRanker::parse(ranker.unwrap_or_default()).map_err(to_py_err)?;
+        Ok(slf)
+    }
+
+    fn with_rrf_ranker(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
+        slf.ranker = HybridSearchRanker::Rrf;
+        slf
+    }
+
+    fn with_weighted_score_ranker(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, 
Self> {
+        slf.ranker = HybridSearchRanker::WeightedScore;
+        slf
+    }
+
+    fn with_filter<'py>(
+        mut slf: PyRefMut<'py, Self>,
+        predicate: &Bound<'_, PyDict>,
+    ) -> PyResult<PyRefMut<'py, Self>> {
+        let filter = dict_to_table_predicate(predicate, slf.table.schema(), 
true)?;
+        slf.filters.push(filter);
+        Ok(slf)
+    }
+
+    fn with_partition_filter<'py>(
+        mut slf: PyRefMut<'py, Self>,
+        predicate: &Bound<'_, PyDict>,
+    ) -> PyResult<PyRefMut<'py, Self>> {
+        let filter = dict_to_table_predicate(predicate, slf.table.schema(), 
true)?;
+        slf.builder()
+            .with_partition_filter(filter.clone())
+            .map_err(to_py_err)?;
+        slf.filters.push(filter);
+        Ok(slf)
+    }
+
+    fn execute_local(&self, py: Python<'_>) -> PyResult<PyHybridSearchResult> {
+        let builder = self.builder();
+        py.detach(|| runtime().block_on(builder.execute_scored()))
+            .map(|inner| PyHybridSearchResult { inner })
+            .map_err(to_py_err)
+    }
+}
+
+#[pyclass(name = "HybridSearchResult", module = "pypaimon_rust.datafusion")]
+pub struct PyHybridSearchResult {
+    inner: ScoredRowIds,
+}
+
+#[pymethods]
+impl PyHybridSearchResult {
+    fn __len__(&self) -> usize {
+        self.inner.len()
+    }
+
+    fn row_ids(&self) -> HashMap<u64, f32> {
+        self.inner
+            .row_ids
+            .iter()
+            .copied()
+            .zip(self.inner.scores.iter().copied())
+            .collect()
+    }
+}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index e4a5c027..87a3362c 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -22,6 +22,7 @@ mod blob_uri_reader;
 mod context;
 mod error;
 mod full_text_search;
+mod hybrid_search;
 mod index_build;
 mod merge_into;
 mod oss_cpp;
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index e0daf6ea..2c33c4b0 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -202,6 +202,10 @@ impl PyTable {
         
crate::full_text_search::PyFullTextSearchBuilder::new(Arc::clone(&self.inner))
     }
 
+    fn new_hybrid_search_builder(&self) -> 
crate::hybrid_search::PyHybridSearchBuilder {
+        
crate::hybrid_search::PyHybridSearchBuilder::new(Arc::clone(&self.inner))
+    }
+
     fn new_vector_search_builder(&self) -> 
crate::vector_search::PyVectorSearchBuilder {
         
crate::vector_search::PyVectorSearchBuilder::new(Arc::clone(&self.inner))
     }
diff --git a/crates/integrations/datafusion/src/hybrid_search.rs 
b/crates/integrations/datafusion/src/hybrid_search.rs
index 1912c5f5..76a2889a 100644
--- a/crates/integrations/datafusion/src/hybrid_search.rs
+++ b/crates/integrations/datafusion/src/hybrid_search.rs
@@ -22,7 +22,7 @@
 //! SELECT * FROM hybrid_search(
 //!   'table_name',
 //!   array(named_struct('field', 'embedding', 'query_vector', array(1.0, 
0.0))),
-//!   array(named_struct('column', 'content', 'query', 'paimon')),
+//!   array(named_struct('column', 'content', 'query', 
'{"match":{"query":"paimon"}}')),
 //!   10,
 //!   'rrf')
 //! ```
@@ -53,7 +53,9 @@ use paimon::table::{HybridSearchRanker, HybridSearchRoute, 
Table};
 use crate::error::to_datafusion_error;
 use crate::physical_plan::{SearchScoreExec, SearchScoreOutputColumn};
 use crate::runtime::{await_with_runtime, block_on_with_runtime};
-use crate::table::{datafusion_read_fields, PaimonScanBuilder, 
PaimonTableProvider};
+use crate::table::{
+    datafusion_arrow_schema, datafusion_read_fields, PaimonScanBuilder, 
PaimonTableProvider,
+};
 use crate::table_function_args::{
     extract_int_literal, extract_string_literal, parse_table_identifier,
 };
@@ -248,8 +250,7 @@ impl TableProvider for HybridSearchTableProvider {
         let inner_schema = self.inner.schema();
         let score_index = inner_schema.fields().len();
         let input_read_fields = search_read_fields(table)?;
-        let input_schema = 
paimon::arrow::build_target_arrow_schema(&input_read_fields)
-            .map_err(to_datafusion_error)?;
+        let input_schema = datafusion_arrow_schema(&input_read_fields, true)?;
         let row_id_table_index = input_read_fields
             .iter()
             .position(|field| field.name() == ROW_ID_FIELD_NAME)
diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index f867b07d..caa00fa9 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -2751,7 +2751,7 @@ mod hybrid_search_tests {
     use std::sync::Arc;
 
     #[cfg(feature = "fulltext")]
-    use datafusion::arrow::array::{Array, Int64Array};
+    use datafusion::arrow::array::{Array, Int64Array, StringViewArray};
     use datafusion::arrow::array::{Float32Array, Int32Array};
     use paimon::catalog::Identifier;
     use paimon::table::BranchManager;
@@ -2904,7 +2904,7 @@ mod hybrid_search_tests {
             assert!((score - expected).abs() < 1e-6);
         }
 
-        let empty_batches = ctx
+        let missing_error = ctx
             .sql(
                 "SELECT __paimon_search_score FROM hybrid_search( \
                  'paimon.default.test_java_vindex_vector', \
@@ -2918,10 +2918,29 @@ mod hybrid_search_tests {
                  'rrf')",
             )
             .await
-            .expect("empty hybrid_search score SQL should parse")
+            .expect("invalid-column hybrid_search SQL should parse")
+            .collect()
+            .await
+            .expect_err("an unknown vector column must not silently return 
empty");
+        assert!(missing_error.to_string().contains("does not exist"));
+        super::common::exec(
+            &ctx,
+            "CREATE TABLE paimon.default.hybrid_empty (id INT, embedding 
ARRAY<FLOAT>) \
+             WITH ('row-tracking.enabled' = 'true')",
+        )
+        .await;
+        let empty_batches = ctx
+            .sql(
+                "SELECT __paimon_search_score FROM hybrid_search( \
+             'paimon.default.hybrid_empty', \
+             array(named_struct('field', 'embedding', 'query_vector', 
array(1.0), 'limit', 3)), \
+             array(), 3, 'rrf')",
+            )
+            .await
+            .unwrap()
             .collect()
             .await
-            .expect("empty hybrid_search score query should execute");
+            .unwrap();
         assert_eq!(
             empty_batches
                 .iter()
@@ -2933,19 +2952,22 @@ mod hybrid_search_tests {
 
     #[cfg(feature = "fulltext")]
     #[tokio::test]
-    async fn test_hybrid_search_score_with_row_tracking_only_fulltext() {
+    async fn test_hybrid_search_score_with_fulltext_definition() {
         let (_tmp, ctx) = super::common::setup_sql_context().await;
         super::common::exec(
             &ctx,
             "CREATE TABLE paimon.test_db.hybrid_raw_fulltext (id INT, content 
STRING) \
              WITH ( \
                'row-tracking.enabled' = 'true', \
+               'bucket' = '-1', \
+               'data-evolution.enabled' = 'true', \
+               'global-index.enabled' = 'true', \
                'global-index.search-mode' = 'full')",
         )
         .await;
         super::common::exec(
             &ctx,
-            "INSERT INTO paimon.test_db.hybrid_raw_fulltext VALUES \
+            "INSERT INTO paimon.test_db.hybrid_raw_fulltext (id, content) 
VALUES \
              (1, 'paimon search'), (2, 'other'), (3, 'paimon table')",
         )
         .await;
@@ -2955,11 +2977,26 @@ mod hybrid_search_tests {
              array(), \
              array(named_struct( \
                'column', 'content', \
-               'query', 'paimon', \
+               'query', '{\"match\":{\"query\":\"paimon\"}}', \
                'limit', 10, \
                'weight', 1.0)), \
              10, \
              'rrf')";
+        // Java returns empty without a full-text definition, even in FULL 
mode.
+        let before = ctx
+            .sql(&format!("SELECT id FROM {SEARCH}"))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert!(before.iter().all(|batch| batch.num_rows() == 0));
+        super::common::exec(
+            &ctx,
+            "CALL sys.create_global_index(table => 
'test_db.hybrid_raw_fulltext', \
+             index_column => 'content', index_type => 'full-text')",
+        )
+        .await;
         let explicit_sql = format!("SELECT id, __paimon_search_score FROM 
{SEARCH} ORDER BY id");
         let explicit_batches = ctx
             .sql(&explicit_sql)
@@ -3038,10 +3075,25 @@ mod hybrid_search_tests {
             2
         );
         for batch in &star_batches {
-            assert_eq!(batch.num_columns(), 3);
+            assert_eq!(batch.num_columns(), 4);
             assert!(batch.column_by_name("__paimon_search_score").is_some());
-            assert!(batch.column_by_name("_ROW_ID").is_none());
+            assert!(batch.column_by_name("_ROW_ID").is_some());
         }
+        let mut text = star_batches
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column_by_name("content")
+                    .unwrap()
+                    .as_any()
+                    .downcast_ref::<StringViewArray>()
+                    .unwrap()
+                    .iter()
+                    .map(|value| value.unwrap().to_string())
+            })
+            .collect::<Vec<_>>();
+        text.sort();
+        assert_eq!(text, vec!["paimon search", "paimon table"]);
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/hybrid_search_builder.rs 
b/crates/paimon/src/table/hybrid_search_builder.rs
index f6be569f..e8838214 100644
--- a/crates/paimon/src/table/hybrid_search_builder.rs
+++ b/crates/paimon/src/table/hybrid_search_builder.rs
@@ -22,9 +22,11 @@
 use std::collections::{BTreeMap, HashMap};
 
 use arrow_array::RecordBatch;
-use futures::{stream, TryStreamExt};
+use futures::{stream, StreamExt, TryStreamExt};
 
-use crate::spec::{CoreOptions, SCAN_SNAPSHOT_ID_OPTION};
+#[cfg(all(test, feature = "fulltext"))]
+use crate::spec::SCAN_SNAPSHOT_ID_OPTION;
+use crate::spec::{CoreOptions, Predicate, Snapshot};
 use crate::table::data_file_reader::DataFileReader;
 use crate::table::pk_search_position::PrimaryKeySearchPosition;
 use crate::table::pk_search_ranker::{self, Ranking};
@@ -46,6 +48,10 @@ use 
crate::table::pk_full_text_read::{build_full_text_indexed_splits, PrimaryKey
 use crate::table::pk_full_text_scan::PrimaryKeyFullTextScan;
 
 const RRF_K: f32 = 60.0;
+const MAX_ROUTE_WORKERS: usize = 4;
+
+#[cfg(all(test, feature = "fulltext"))]
+mod parity_tests;
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
 pub enum HybridSearchRanker {
@@ -210,6 +216,8 @@ pub struct HybridSearchBuilder<'a> {
     routes: Vec<HybridSearchRoute>,
     limit: Option<usize>,
     ranker: HybridSearchRanker,
+    filter: Option<Predicate>,
+    pinned_snapshot: Option<Option<Snapshot>>,
 }
 
 impl<'a> HybridSearchBuilder<'a> {
@@ -219,6 +227,8 @@ impl<'a> HybridSearchBuilder<'a> {
             routes: Vec::new(),
             limit: None,
             ranker: HybridSearchRanker::Rrf,
+            filter: None,
+            pinned_snapshot: None,
         }
     }
 
@@ -260,6 +270,26 @@ impl<'a> HybridSearchBuilder<'a> {
         self
     }
 
+    /// Reuse a read view already resolved by the caller, preserving the 
current
+    /// query schema. `None` pins an empty view even if a later commit appears.
+    pub fn with_snapshot(&mut self, snapshot: Option<&Snapshot>) -> &mut Self {
+        self.pinned_snapshot = Some(snapshot.cloned());
+        self
+    }
+
+    /// Apply the same pre-filter to every route before its own Top-K.
+    /// Partition conjuncts are pruned by each route's scan.
+    pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
+        super::vector_search_common::add_filter(&mut self.filter, filter);
+        self
+    }
+
+    /// Add a partition-only predicate, using table field indices like other 
builders.
+    pub fn with_partition_filter(&mut self, filter: Predicate) -> 
crate::Result<&mut Self> {
+        super::partition_filter::validate_partition_filter(self.table, 
&filter)?;
+        Ok(self.with_filter(filter))
+    }
+
     pub fn with_ranker(&mut self, ranker: &str) -> crate::Result<&mut Self> {
         self.ranker = HybridSearchRanker::parse(ranker)?;
         Ok(self)
@@ -287,14 +317,7 @@ impl<'a> HybridSearchBuilder<'a> {
     pub async fn execute_scored(&self) -> crate::Result<ScoredRowIds> {
         let core = CoreOptions::new(self.table.schema().options());
         core.ensure_read_authorized()?;
-        let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
-            message: "Limit must be set via with_limit()".to_string(),
-        })?;
-        if self.routes.is_empty() {
-            return Err(crate::Error::ConfigInvalid {
-                message: "Routes cannot be empty".to_string(),
-            });
-        }
+        let limit = self.validate_search()?;
 
         // A primary-key hybrid fuses PHYSICAL positions, not global row ids, 
so a
         // scored/row-range result is unsupported on it: fail loud and direct 
callers
@@ -313,30 +336,109 @@ impl<'a> HybridSearchBuilder<'a> {
             HybridAddressSpace::Global => {}
         }
 
-        let mut route_results = Vec::with_capacity(self.routes.len());
+        self.validate_global_routes()?;
+        // Resolve once even for an empty table or a retained tag. Re-resolving
+        // selectors independently in the routes can observe different 
versions.
+        let Some(table) = self.resolve_pinned_route_table().await? else {
+            return Ok(ScoredRowIds::empty());
+        };
+        self.execute_global_routes(&table, limit).await
+    }
+
+    fn validate_search(&self) -> crate::Result<usize> {
+        if self.routes.is_empty() {
+            return Err(crate::Error::ConfigInvalid {
+                message: "Routes cannot be empty".into(),
+            });
+        }
+        self.limit
+            .filter(|limit| *limit > 0)
+            .ok_or_else(|| crate::Error::ConfigInvalid {
+                message: "Limit must be positive, set via with_limit()".into(),
+            })
+    }
+
+    fn vector_route_builder<'t>(
+        &self,
+        table: &'t Table,
+        route: &HybridSearchRoute,
+    ) -> super::VectorSearchBuilder<'t> {
+        let mut builder = table.new_vector_search_builder();
+        builder
+            .with_vector_column(&route.field_name)
+            .with_query_vector(route.vector.clone().expect("validated vector 
route"))
+            .with_limit(route.limit)
+            .with_options(route.options.clone());
+        if let Some(filter) = &self.filter {
+            builder.with_filter(filter.clone());
+        }
+        builder
+    }
+
+    fn validate_global_routes(&self) -> crate::Result<()> {
         for route in &self.routes {
-            let result = match route.kind {
+            match route.kind {
                 HybridSearchRouteKind::Vector => {
-                    let mut builder = self.table.new_vector_search_builder();
-                    builder
-                        .with_vector_column(&route.field_name)
-                        
.with_query_vector(route.vector.clone().expect("validated vector route"))
-                        .with_limit(route.limit)
-                        .with_options(route.options.clone());
-                    builder.execute().await?.into_row_ids()?
+                    if !self
+                        .table
+                        .schema()
+                        .fields()
+                        .iter()
+                        .any(|field| field.name() == route.field_name)
+                    {
+                        return Err(crate::Error::ConfigInvalid {
+                            message: format!("Vector column '{}' does not 
exist", route.field_name),
+                        });
+                    }
+                    let builder = self.vector_route_builder(self.table, route);
+                    builder.new_scan()?;
+                    builder.new_read()?;
                 }
                 HybridSearchRouteKind::FullText => {
-                    execute_full_text_route(self.table, route).await?
+                    #[cfg(feature = "fulltext")]
+                    {
+                        let builder =
+                            full_text_route_builder(self.table, route, 
self.filter.as_ref());
+                        builder.new_scan()?;
+                        builder.new_read()?;
+                    }
+                    #[cfg(not(feature = "fulltext"))]
+                    return Err(crate::Error::ConfigInvalid {
+                        message: "Full-text hybrid routes require the fulltext 
feature".into(),
+                    });
                 }
-            };
-            if !result.is_empty() {
-                route_results.push(WeightedRouteResult {
-                    result,
-                    weight: route.weight,
-                });
             }
         }
+        Ok(())
+    }
 
+    async fn execute_global_routes(
+        &self,
+        table: &Table,
+        limit: usize,
+    ) -> crate::Result<ScoredRowIds> {
+        // Bound concurrent route work while retaining input order for float
+        // accumulation and keeping each route's weight and limit attached to 
it.
+        let route_results = stream::iter(self.routes.clone())
+            .map(|route| async move {
+                let result = match route.kind {
+                    HybridSearchRouteKind::Vector => self
+                        .vector_route_builder(table, &route)
+                        .execute()
+                        .await?
+                        .into_row_ids()?,
+                    HybridSearchRouteKind::FullText => {
+                        execute_full_text_route(table, &route, 
self.filter.as_ref()).await?
+                    }
+                };
+                Ok::<_, crate::Error>(WeightedRouteResult {
+                    result,
+                    weight: route.weight,
+                })
+            })
+            .buffered(MAX_ROUTE_WORKERS)
+            .try_collect::<Vec<_>>()
+            .await?;
         Ok(rank_results(self.ranker, &route_results, limit))
     }
 
@@ -351,14 +453,7 @@ impl<'a> HybridSearchBuilder<'a> {
     pub async fn execute_read(&self) -> crate::Result<ArrowRecordBatchStream> {
         let core = CoreOptions::new(self.table.schema().options());
         core.ensure_read_authorized()?;
-        let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
-            message: "Limit must be set via with_limit()".to_string(),
-        })?;
-        if self.routes.is_empty() {
-            return Err(crate::Error::ConfigInvalid {
-                message: "Routes cannot be empty".to_string(),
-            });
-        }
+        let limit = self.validate_search()?;
 
         match self.classify_routes(&core)? {
             HybridAddressSpace::PrimaryKey => {
@@ -418,27 +513,27 @@ impl<'a> HybridSearchBuilder<'a> {
         // fused score. Reject it up front — before any route runs and even 
when the
         // fused result is empty — reusing the primary-key vector read's guard.
         ensure_no_reserved_read_columns(self.table.schema().fields())?;
+        self.validate_primary_key_routes(core)?;
 
         // Snapshot pinning: resolve ONE snapshot for the whole primary-key 
hybrid
         // read and plan every route against it, mirroring Java
         // `HybridSearchBuilderImpl.routeBuilders()` (which resolves the 
snapshot
         // once and injects it into every route builder). Only the "read 
latest"
-        // case is racy — a concurrent commit landing between the two route 
plans
-        // would otherwise pick different snapshots — because every time-travel
-        // selector resolves deterministically to the same snapshot on each 
plan.
-        // So pin latest once via `scan.snapshot-id` and leave the 
already-pinned
-        // (time-travel) paths to resolve their own fixed snapshot.
-        let pinned_table = self.resolve_pinned_route_table().await?;
-        let route_table: &Table = pinned_table.as_ref().unwrap_or(self.table);
+        // case is racy: a concurrent commit landing between the two route 
plans
+        // would otherwise pick different snapshots. Preserve the resolved 
snapshot
+        // itself so retained tags also work after the original snapshot 
expires.
+        let Some(route_table) = self.resolve_pinned_route_table().await? else {
+            return Ok(Box::pin(stream::empty()));
+        };
 
         // Per-route search: positions (converted from candidates) + 
single-file
         // source splits + the snapshot each route's plan pinned.
         let mut routes: Vec<PkRoute> = Vec::with_capacity(self.routes.len());
         for route in &self.routes {
             let pk_route = match route.kind {
-                HybridSearchRouteKind::Vector => 
self.pk_vector_route(route_table, route).await?,
+                HybridSearchRouteKind::Vector => 
self.pk_vector_route(&route_table, route).await?,
                 HybridSearchRouteKind::FullText => {
-                    self.pk_full_text_route(route_table, core, route).await?
+                    self.pk_full_text_route(&route_table, core, route).await?
                 }
             };
             routes.push(pk_route);
@@ -522,42 +617,23 @@ impl<'a> HybridSearchBuilder<'a> {
         Ok(Box::pin(stream::iter(output.into_iter().map(Ok))))
     }
 
-    /// Resolve the ONE snapshot every primary-key route must plan against, as 
an
+    /// Resolve the ONE snapshot every route must plan against, as an
     /// optional pinned table copy. Mirrors Java
     /// `HybridSearchBuilderImpl.routeBuilders()`, which resolves a single 
snapshot
     /// up front and injects it into every route builder.
     ///
-    /// A time-travel selector (`scan.version` / `scan.timestamp-millis` /
-    /// `scan.snapshot-id` / `scan.tag-name`) already resolves 
deterministically to
-    /// the same snapshot on every route plan, so no extra pinning is needed —
-    /// return `None` and let each route resolve it. Only the default "read 
latest"
-    /// path is racy: a concurrent commit landing between two route plans 
would let
-    /// them resolve different snapshots. For that path resolve the latest 
snapshot
-    /// id ONCE and return a table copy pinned to it via `scan.snapshot-id`, 
so both
-    /// routes plan the same version. A table with no snapshot at all also 
returns
-    /// `None` (nothing to pin; every route plans empty).
+    /// Cache the resolved snapshot rather than just its id: retained tags must
+    /// remain readable even when the original snapshot file has expired.
+    /// `None` means the one resolved view is empty.
     async fn resolve_pinned_route_table(&self) -> crate::Result<Option<Table>> 
{
-        let core = CoreOptions::new(self.table.schema().options());
-        // Already targeting a fixed snapshot (resolved travel copy or a 
selector
-        // that resolves deterministically): every route agrees without 
pinning.
-        if self.table.has_resolved_travel_snapshot() || 
core.has_time_travel_selector() {
-            return Ok(None);
+        if let Some(snapshot) = &self.pinned_snapshot {
+            return Ok(snapshot
+                .as_ref()
+                .map(|snapshot| 
self.table.copy_with_pinned_snapshot(snapshot)));
         }
-        // Read-latest: pin the current latest snapshot once so a concurrent 
commit
-        // cannot split the routes across versions.
-        let Some(latest) = self
-            .table
-            .snapshot_manager()
-            .get_latest_snapshot_id()
+        Ok(super::time_travel::resolve_snapshot(self.table)
             .await?
-        else {
-            return Ok(None);
-        };
-        let pinned = self.table.copy_with_options(HashMap::from([(
-            SCAN_SNAPSHOT_ID_OPTION.to_string(),
-            latest.to_string(),
-        )]));
-        Ok(Some(pinned))
+            .map(|snapshot| self.table.copy_with_pinned_snapshot(&snapshot)))
     }
 
     /// Consume the vector search's scored positions, retaining its source 
files
@@ -567,13 +643,7 @@ impl<'a> HybridSearchBuilder<'a> {
         table: &Table,
         route: &HybridSearchRoute,
     ) -> crate::Result<PkRoute> {
-        let vector = route.vector.as_deref().expect("validated vector route");
-        let mut builder = table.new_vector_search_builder();
-        builder
-            .with_vector_column(&route.field_name)
-            .with_query_vector(vector.to_vec())
-            .with_limit(route.limit)
-            .with_options(route.options.clone());
+        let builder = self.vector_route_builder(table, route);
         let result = builder.execute().await?;
         let positions = result
             .positions()?
@@ -593,13 +663,29 @@ impl<'a> HybridSearchBuilder<'a> {
         })
     }
 
+    fn validate_primary_key_routes(&self, core: &CoreOptions<'_>) -> 
crate::Result<()> {
+        for route in &self.routes {
+            match route.kind {
+                HybridSearchRouteKind::Vector => {
+                    let builder = self.vector_route_builder(self.table, route);
+                    builder.new_scan()?;
+                    builder.new_read()?;
+                }
+                HybridSearchRouteKind::FullText => {
+                    self.pk_full_text_field(self.table, core, route)?;
+                }
+            }
+        }
+        Ok(())
+    }
+
     #[cfg(feature = "fulltext")]
-    async fn pk_full_text_route(
+    fn pk_full_text_field(
         &self,
         table: &Table,
         core: &CoreOptions<'_>,
         route: &HybridSearchRoute,
-    ) -> crate::Result<PkRoute> {
+    ) -> crate::Result<i32> {
         // FAST-only: the primary-key full-text read searches 
compaction-visible
         // payloads and rejects FULL/DETAIL loud rather than silently 
degrading,
         // mirroring `full_text_search_builder::execute_read` and Java
@@ -613,10 +699,6 @@ impl<'a> HybridSearchBuilder<'a> {
             });
         }
 
-        let query = route
-            .full_text_query
-            .as_deref()
-            .expect("validated full-text route");
         let field_id = find_field_id_by_name(table.schema().fields(), 
&route.field_name)
             .ok_or_else(|| crate::Error::DataInvalid {
                 message: format!(
@@ -625,7 +707,47 @@ impl<'a> HybridSearchBuilder<'a> {
                 ),
                 source: None,
             })?;
-        let plan = PrimaryKeyFullTextScan::new(table, field_id, None)
+        if let Some(filter) = &self.filter {
+            let mapping = crate::spec::field_idx_to_partition_idx(
+                table.schema().fields(),
+                table.schema().partition_keys(),
+            );
+            if !filter.references_only_mapped_fields(&mapping) {
+                return Err(crate::Error::Unsupported {
+                    message:
+                        "Primary-key full-text search does not support 
non-partition filters yet."
+                            .into(),
+                });
+            }
+        }
+        Ok(field_id)
+    }
+
+    #[cfg(not(feature = "fulltext"))]
+    fn pk_full_text_field(
+        &self,
+        _table: &Table,
+        _core: &CoreOptions<'_>,
+        _route: &HybridSearchRoute,
+    ) -> crate::Result<i32> {
+        Err(crate::Error::ConfigInvalid {
+            message: "primary-key full-text hybrid routes require the fulltext 
feature".into(),
+        })
+    }
+
+    #[cfg(feature = "fulltext")]
+    async fn pk_full_text_route(
+        &self,
+        table: &Table,
+        core: &CoreOptions<'_>,
+        route: &HybridSearchRoute,
+    ) -> crate::Result<PkRoute> {
+        let field_id = self.pk_full_text_field(table, core, route)?;
+        let query = route
+            .full_text_query
+            .as_deref()
+            .expect("validated full-text route");
+        let plan = PrimaryKeyFullTextScan::new(table, field_id, 
self.filter.clone())
             .plan()
             .await?;
         let materialize_reader = DataFileReader::new(
@@ -892,20 +1014,34 @@ fn build_hybrid_indexed_splits(
 }
 
 #[cfg(feature = "fulltext")]
-async fn execute_full_text_route(
-    table: &Table,
+fn full_text_route_builder<'t>(
+    table: &'t Table,
     route: &HybridSearchRoute,
-) -> crate::Result<ScoredRowIds> {
+    filter: Option<&Predicate>,
+) -> super::FullTextSearchBuilder<'t> {
     let mut builder = table.new_full_text_search_builder();
     builder
-        .with_text_column(&route.field_name)
-        .with_query_text(
+        .with_query(
+            &route.field_name,
             route
                 .full_text_query
                 .as_deref()
                 .expect("validated full-text route"),
         )
         .with_limit(route.limit);
+    if let Some(filter) = filter {
+        builder.with_filter(filter.clone());
+    }
+    builder
+}
+
+#[cfg(feature = "fulltext")]
+async fn execute_full_text_route(
+    table: &Table,
+    route: &HybridSearchRoute,
+    filter: Option<&Predicate>,
+) -> crate::Result<ScoredRowIds> {
+    let builder = full_text_route_builder(table, route, filter);
     let result = builder.execute_scored().await?;
     Ok(ScoredRowIds::new(result.row_ids, result.scores))
 }
@@ -914,6 +1050,7 @@ async fn execute_full_text_route(
 async fn execute_full_text_route(
     _table: &Table,
     _route: &HybridSearchRoute,
+    _filter: Option<&Predicate>,
 ) -> crate::Result<ScoredRowIds> {
     Err(crate::Error::ConfigInvalid {
         message: "Full-text hybrid routes require the fulltext 
feature".to_string(),
@@ -1541,6 +1678,18 @@ mod pk_hybrid_tests {
                 "score column must be present"
             );
         }
+        builder.with_snapshot(None);
+        let empty: Vec<RecordBatch> = builder
+            .execute_read()
+            .await
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        assert!(
+            empty.is_empty(),
+            "a pinned empty view must not observe the existing rows"
+        );
     }
 
     #[tokio::test]
@@ -1861,6 +2010,13 @@ mod pk_hybrid_tests {
             format!("{err:?}").contains("FAST"),
             "PK hybrid full-text route under FULL mode must fail loud, got: 
{err:?}"
         );
+        builder.with_snapshot(None);
+        let err = builder
+            .execute_read()
+            .await
+            .err()
+            .expect("empty views must still validate the mode");
+        assert!(err.to_string().contains("FAST"));
     }
 
     // (g) Snapshot pinning: the read-latest path resolves ONE snapshot up 
front and
diff --git a/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs 
b/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs
new file mode 100644
index 00000000..df2f8e25
--- /dev/null
+++ b/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs
@@ -0,0 +1,393 @@
+// 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.
+
+//! Global hybrid execution against real indexes, matching Java route 
semantics.
+
+use super::*;
+use crate::catalog::Identifier;
+use crate::io::FileIOBuilder;
+use crate::spec::{
+    ArrayType, DataType, Datum, FloatType, IntType, PredicateBuilder, Schema, 
TableSchema,
+    VarCharType,
+};
+use crate::table::{TableCommit, TableWrite};
+use arrow_array::types::Float32Type;
+use arrow_array::{ArrayRef, Int32Array, ListArray, StringArray};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use std::sync::Arc;
+
+fn table() -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("text", DataType::VarChar(VarCharType::string_type()))
+        .column(
+            "embedding",
+            DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+        )
+        .column("label", DataType::VarChar(VarCharType::string_type()))
+        .column("pt", DataType::Int(IntType::new()))
+        .partition_keys(["pt"])
+        .options(HashMap::from([
+            ("bucket".into(), "-1".into()),
+            ("row-tracking.enabled".into(), "true".into()),
+            ("data-evolution.enabled".into(), "true".into()),
+            ("global-index.enabled".into(), "true".into()),
+            ("scalar-index.search-mode".into(), "full".into()),
+            ("vector-index.search-mode".into(), "full".into()),
+            ("full-text-index.search-mode".into(), "full".into()),
+        ]))
+        .build()
+        .unwrap();
+    Table::new(
+        FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("default", "hybrid"),
+        format!("memory:/hybrid-parity-{}", uuid::Uuid::new_v4()),
+        TableSchema::new(0, &schema),
+        None,
+    )
+}
+
+async fn append(table: &Table, start: i32) {
+    let columns: Vec<ArrayRef> = vec![
+        Arc::new(Int32Array::from(vec![
+            start,
+            start + 1,
+            start + 2,
+            start + 3,
+        ])),
+        Arc::new(StringArray::from(vec![
+            Some("paimon"),
+            Some("paimon paimon paimon"),
+            Some("paimon lake"),
+            None,
+        ])),
+        Arc::new(ListArray::from_iter_primitive::<Float32Type, _, _>(
+            (start..start + 4).map(|id| Some(vec![Some(id as f32), 
Some(0.0)])),
+        )),
+        Arc::new(StringArray::from(vec![
+            Some("drop"),
+            Some("keep"),
+            Some("keep"),
+            None,
+        ])),
+        Arc::new(Int32Array::from(vec![start / 4; 4])),
+    ];
+    let schema = Arc::new(ArrowSchema::new(vec![
+        Field::new("id", ArrowType::Int32, true),
+        Field::new("text", ArrowType::Utf8, true),
+        Field::new("embedding", columns[2].data_type().clone(), true),
+        Field::new("label", ArrowType::Utf8, true),
+        Field::new("pt", ArrowType::Int32, true),
+    ]));
+    let mut writer = TableWrite::new(table, "hybrid".into()).unwrap();
+    writer
+        .write_arrow_batch(&RecordBatch::try_new(schema, columns).unwrap())
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "hybrid".into())
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+}
+
+async fn index(table: &Table, kind: &str, column: &str) {
+    let mut builder = table.new_global_index_build_builder();
+    builder.with_index_type(kind).with_index_column(column);
+    if kind == "ivf-flat" {
+        builder.with_options(HashMap::from([
+            ("ivf-flat.dimension".into(), "2".into()),
+            ("ivf-flat.nlist".into(), "1".into()),
+            ("ivf-flat.distance.metric".into(), "l2".into()),
+        ]));
+    }
+    builder.execute().await.unwrap();
+}
+
+#[tokio::test]
+async fn full_text_route_uses_java_structured_query() {
+    let table = table();
+    append(&table, 0).await;
+    index(&table, "full-text", "text").await;
+    let mut builder = table.new_hybrid_search_builder();
+    builder
+        .add_full_text_route(
+            "text",
+            r#"{"match":{"query":"paimon lake","operator":"And"}}"#,
+            4,
+            1.0,
+            HashMap::new(),
+        )
+        .unwrap();
+    builder.with_limit(4);
+    assert_eq!(builder.execute_scored().await.unwrap().row_ids, vec![2]);
+}
+
+#[tokio::test]
+async fn full_text_route_rejects_plain_text_as_java_does() {
+    let table = table();
+    append(&table, 0).await;
+    index(&table, "full-text", "text").await;
+    let mut builder = table.new_hybrid_search_builder();
+    builder
+        .add_full_text_route("text", "paimon", 4, 1.0, HashMap::new())
+        .unwrap();
+    builder.with_limit(4);
+    assert!(builder.execute_scored().await.is_err());
+}
+
+fn mixed_query(table: &Table, route_limit: usize) -> HybridSearchBuilder<'_> {
+    let mut builder = table.new_hybrid_search_builder();
+    builder
+        .add_vector_route(
+            "embedding",
+            vec![0.0, 0.0],
+            route_limit,
+            1.0,
+            HashMap::new(),
+        )
+        .unwrap()
+        .add_full_text_route(
+            "text",
+            r#"{"match":{"query":"paimon"}}"#,
+            route_limit,
+            1.0,
+            HashMap::new(),
+        )
+        .unwrap()
+        .with_limit(16);
+    builder
+}
+
+#[tokio::test]
+async fn both_routes_filter_before_top_k_and_accumulate_predicates() {
+    let table = table();
+    append(&table, 0).await;
+    append(&table, 4).await;
+    index(&table, "ivf-flat", "embedding").await;
+    index(&table, "full-text", "text").await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let mut builder = mixed_query(&table, 1);
+    builder
+        .with_filter(pb.equal("label", Datum::String("keep".into())).unwrap())
+        .with_filter(pb.equal("id", Datum::Int(6)).unwrap())
+        .with_partition_filter(pb.equal("pt", Datum::Int(1)).unwrap())
+        .unwrap();
+    let result = builder.execute_scored().await.unwrap();
+    assert_eq!(result.row_ids, vec![6]);
+    assert!((result.scores[0] - 2.0 / 61.0).abs() < 1e-6);
+    builder
+        .with_partition_filter(pb.equal("pt", Datum::Int(0)).unwrap())
+        .unwrap();
+    assert!(builder.execute_scored().await.unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn scalar_coverage_and_refinement_are_shared_by_both_routes() {
+    let table = table();
+    append(&table, 0).await;
+    index(&table, "ivf-flat", "embedding").await;
+    index(&table, "full-text", "text").await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let keep = pb.equal("label", Datum::String("keep".into())).unwrap();
+    for mode in ["fast", "full", "detail"] {
+        let table = table.copy_with_options(HashMap::from([(
+            "scalar-index.search-mode".into(),
+            mode.into(),
+        )]));
+        let mut ids = mixed_query(&table, 4)
+            .with_filter(keep.clone())
+            .execute_scored()
+            .await
+            .unwrap()
+            .row_ids;
+        ids.sort_unstable();
+        // Vector FAST reads indexed ranges from data when no scalar index can
+        // evaluate the filter; full-text FAST excludes those ranges instead.
+        assert_eq!(ids, vec![1, 2], "scalar mode {mode}");
+        let mut text = table.new_full_text_search_builder();
+        text.with_query("text", r#"{"match":{"query":"paimon"}}"#)
+            .with_filter(keep.clone())
+            .with_limit(4);
+        assert_eq!(
+            text.execute_scored().await.unwrap().len(),
+            if mode == "fast" { 0 } else { 2 }
+        );
+    }
+    index(&table, "bitmap", "label").await;
+    let candidate = pb.contains("label", Datum::String("eep".into())).unwrap();
+    for refine in [false, true] {
+        let table = table.copy_with_options(HashMap::from([
+            (
+                "global-index.filter.refine-from-data".into(),
+                refine.to_string(),
+            ),
+            ("scalar-index.search-mode".into(), "fast".into()),
+        ]));
+        let mut ids = mixed_query(&table, 4)
+            .with_filter(candidate.clone())
+            .execute_scored()
+            .await
+            .unwrap()
+            .row_ids;
+        ids.sort_unstable();
+        assert_eq!(ids, if refine { vec![1, 2] } else { vec![] });
+    }
+}
+
+#[tokio::test]
+async fn concurrent_routes_preserve_weights_limits_and_java_fusion() {
+    let table = table();
+    append(&table, 0).await;
+    index(&table, "ivf-flat", "embedding").await;
+    for (ranker, ids, scores) in [
+        (
+            "rrf",
+            vec![3, 2, 0, 1],
+            vec![2.0 / 61.0, 2.0 / 62.0, 1.0 / 61.0, 1.0 / 62.0],
+        ),
+        ("mrr", vec![3, 0, 2, 1], vec![2.0, 1.0, 1.0, 0.5]),
+        ("weighted_score", vec![3, 0, 1, 2], vec![2.0, 1.0, 0.0, 0.0]),
+    ] {
+        let mut builder = table.new_hybrid_search_builder();
+        builder
+            .add_vector_route("embedding", vec![0.0, 0.0], 2, 1.0, 
HashMap::new())
+            .unwrap()
+            .add_vector_route("embedding", vec![3.0, 0.0], 2, 2.0, 
HashMap::new())
+            .unwrap()
+            .with_limit(4)
+            .with_ranker(ranker)
+            .unwrap();
+        let result = builder.execute_scored().await.unwrap();
+        assert_eq!(result.row_ids, ids, "ranker {ranker}");
+        for (actual, expected) in result.scores.iter().zip(scores) {
+            assert!(
+                (actual - expected).abs() < 1e-6,
+                "ranker {ranker}: {actual} != {expected}"
+            );
+        }
+        builder.with_limit(1);
+        assert_eq!(builder.execute_scored().await.unwrap().row_ids, vec![3]);
+    }
+}
+
+#[tokio::test]
+async fn retained_tag_keeps_both_routes_on_the_expired_snapshot() {
+    let table = table();
+    append(&table, 0).await;
+    index(&table, "ivf-flat", "embedding").await;
+    index(&table, "full-text", "text").await;
+    let snapshot = table
+        .snapshot_manager()
+        .get_latest_snapshot()
+        .await
+        .unwrap()
+        .unwrap();
+    table
+        .tag_manager()
+        .create("hybrid-before", &snapshot)
+        .await
+        .unwrap();
+    append(&table, 4).await;
+    table
+        .file_io()
+        .delete_file(&table.snapshot_manager().snapshot_path(snapshot.id()))
+        .await
+        .unwrap();
+    let tagged = table.copy_with_options(HashMap::from([(
+        "scan.tag-name".into(),
+        "hybrid-before".into(),
+    )]));
+    let mut old = mixed_query(&tagged, 16)
+        .execute_scored()
+        .await
+        .unwrap()
+        .row_ids;
+    old.sort_unstable();
+    assert_eq!(old, vec![0, 1, 2, 3]);
+    assert_eq!(
+        mixed_query(&table, 16)
+            .execute_scored()
+            .await
+            .unwrap()
+            .len(),
+        8
+    );
+}
+
+#[tokio::test]
+async fn caller_pinned_empty_and_snapshot_views_do_not_read_latest() {
+    let table = table();
+    append(&table, 0).await;
+    index(&table, "ivf-flat", "embedding").await;
+    index(&table, "full-text", "text").await;
+    let snapshot = table
+        .snapshot_manager()
+        .get_latest_snapshot()
+        .await
+        .unwrap()
+        .unwrap();
+    append(&table, 4).await;
+    assert!(mixed_query(&table, 16)
+        .with_snapshot(None)
+        .execute_scored()
+        .await
+        .unwrap()
+        .is_empty());
+    table
+        .file_io()
+        .delete_file(&table.snapshot_manager().snapshot_path(snapshot.id()))
+        .await
+        .unwrap();
+    let mut old = mixed_query(&table, 16)
+        .with_snapshot(Some(&snapshot))
+        .execute_scored()
+        .await
+        .unwrap()
+        .row_ids;
+    old.sort_unstable();
+    assert_eq!(old, vec![0, 1, 2, 3]);
+}
+
+#[tokio::test]
+async fn empty_tables_still_validate_route_and_partition_configuration() {
+    let table = table();
+    assert!(mixed_query(&table, 4)
+        .execute_scored()
+        .await
+        .unwrap()
+        .is_empty());
+    let pb = PredicateBuilder::new(table.schema().fields());
+    assert!(mixed_query(&table, 4)
+        .with_partition_filter(pb.equal("id", Datum::Int(1)).unwrap())
+        .is_err());
+    let mut invalid = table.new_hybrid_search_builder();
+    invalid
+        .add_full_text_route("text", "plain text", 4, 1.0, HashMap::new())
+        .unwrap()
+        .with_limit(4);
+    // Java does not parse the DSL until a route actually evaluates a corpus.
+    assert!(invalid.execute_scored().await.unwrap().is_empty());
+    append(&table, 0).await;
+    index(&table, "full-text", "text").await;
+    assert!(invalid.execute_scored().await.is_err());
+    let mut missing = table.new_hybrid_search_builder();
+    missing
+        .add_vector_route("missing", vec![0.0, 0.0], 4, 1.0, HashMap::new())
+        .unwrap()
+        .with_limit(4);
+    assert!(missing.execute_scored().await.is_err());
+}

Reply via email to