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 67d88bf5 [Core] Add snapshot-pinned full-text search with scalar 
filters (#1095)
67d88bf5 is described below

commit 67d88bf5c78b25d23b5cbe4099a1aa3857d24ffd
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 20:59:36 2026 +0800

    [Core] Add snapshot-pinned full-text search with scalar filters (#1095)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  23 ++
 bindings/python/src/context.rs                     |   5 +
 bindings/python/src/full_text_search.rs            | 192 +++++++++
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/table.rs                       |   4 +
 crates/paimon/src/table/de_vector_read.rs          |   3 +-
 crates/paimon/src/table/de_vector_scan.rs          |  66 +--
 crates/paimon/src/table/de_vector_scan/tests.rs    |   1 +
 .../paimon/src/table/full_text_search_builder.rs   | 239 ++++++-----
 .../src/table/full_text_search_builder/filter.rs   | 206 ++++++++++
 .../table/full_text_search_builder/parity_tests.rs | 449 +++++++++++++++++++++
 .../src/table/full_text_search_builder/read.rs     |  89 ++++
 .../src/table/full_text_search_builder/scan.rs     | 183 +++++++++
 .../paimon/src/table/global_index_search_filter.rs |  77 ++++
 crates/paimon/src/table/mod.rs                     |   5 +-
 docs/src/sql.md                                    |  34 ++
 16 files changed, 1416 insertions(+), 161 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index e38d5e5e..9279e894 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -221,6 +221,7 @@ class Table:
     def schema(self) -> TableSchema: ...
     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_vector_search_builder(self) -> "VectorSearchBuilder": ...
     def new_batch_vector_search_builder(self) -> "BatchVectorSearchBuilder": 
...
     def new_global_index_build_builder(self) -> "GlobalIndexBuildBuilder": ...
@@ -613,3 +614,25 @@ class SearchResultReadBuilder:
     def read(self) -> List[pyarrow.RecordBatch]:
         """Read user columns and scores in relevance order from the search 
snapshot."""
         ...
+
+class FullTextSearchBuilder:
+    def with_query(self, field_name: str, query: str) -> 
FullTextSearchBuilder: ...
+    def with_limit(self, limit: int) -> FullTextSearchBuilder: ...
+    def with_filter(self, predicate: Dict[str, Any]) -> FullTextSearchBuilder: 
...
+    def with_partition_filter(self, predicate: Dict[str, Any]) -> 
FullTextSearchBuilder: ...
+    def new_full_text_scan(self) -> FullTextScan: ...
+    def new_full_text_read(self) -> FullTextRead: ...
+    def execute_local(self) -> FullTextSearchResult: ...
+
+class FullTextScan:
+    def scan(self) -> FullTextScanPlan: ...
+
+class FullTextScanPlan:
+    def snapshot_id(self) -> Optional[int]: ...
+
+class FullTextRead:
+    def read_plan(self, plan: FullTextScanPlan) -> FullTextSearchResult: ...
+
+class FullTextSearchResult:
+    def __len__(self) -> int: ...
+    def row_ids(self) -> Dict[int, float]: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index f0baad1b..4c6637a4 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -727,6 +727,11 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<PaimonCatalog>()?;
     this.add_class::<crate::table::PyTable>()?;
     this.add_class::<crate::index_build::PyGlobalIndexBuildBuilder>()?;
+    this.add_class::<crate::full_text_search::PyFullTextSearchBuilder>()?;
+    this.add_class::<crate::full_text_search::PyFullTextScan>()?;
+    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::vector_search::PyVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyBatchVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyVectorScan>()?;
diff --git a/bindings/python/src/full_text_search.rs 
b/bindings/python/src/full_text_search.rs
new file mode 100644
index 00000000..b73c0fb6
--- /dev/null
+++ b/bindings/python/src/full_text_search.rs
@@ -0,0 +1,192 @@
+// 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.
+
+//! Thin bindings over core full-text Scan -> Plan -> Read.
+
+use crate::error::to_py_err;
+use crate::predicate::dict_to_table_predicate;
+use paimon::spec::Predicate;
+use paimon::table::{FullTextRead, FullTextScan, FullTextScanPlan, Table};
+use paimon_datafusion::runtime::runtime;
+use pyo3::prelude::*;
+use pyo3::types::PyDict;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+#[derive(Default)]
+struct Config {
+    query: Option<(String, String)>,
+    limit: Option<usize>,
+    filters: Vec<Predicate>,
+}
+
+impl Config {
+    fn builder<'a>(
+        &self,
+        table: &'a Table,
+    ) -> paimon::Result<paimon::table::FullTextSearchBuilder<'a>> {
+        let mut builder = table.new_full_text_search_builder();
+        if let Some((column, query)) = &self.query {
+            builder.with_query(column, query);
+        }
+        if let Some(limit) = self.limit {
+            builder.with_limit(limit);
+        }
+        for filter in &self.filters {
+            builder.with_filter(filter.clone());
+        }
+        Ok(builder)
+    }
+}
+
+#[pyclass(name = "FullTextSearchBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyFullTextSearchBuilder {
+    table: Arc<Table>,
+    config: Config,
+}
+
+impl PyFullTextSearchBuilder {
+    pub(crate) fn new(table: Arc<Table>) -> Self {
+        Self {
+            table,
+            config: Config::default(),
+        }
+    }
+}
+
+#[pymethods]
+impl PyFullTextSearchBuilder {
+    fn with_query(
+        mut slf: PyRefMut<'_, Self>,
+        field_name: String,
+        query: String,
+    ) -> PyRefMut<'_, Self> {
+        slf.config.query = Some((field_name, query));
+        slf
+    }
+    fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_, 
Self> {
+        slf.config.limit = Some(limit);
+        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.config.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.config
+            .builder(&slf.table)
+            .map_err(to_py_err)?
+            .with_partition_filter(filter.clone())
+            .map_err(to_py_err)?;
+        slf.config.filters.push(filter);
+        Ok(slf)
+    }
+    fn new_full_text_scan(&self) -> PyResult<PyFullTextScan> {
+        Ok(PyFullTextScan {
+            inner: self
+                .config
+                .builder(&self.table)
+                .map_err(to_py_err)?
+                .new_scan()
+                .map_err(to_py_err)?,
+        })
+    }
+    fn new_full_text_read(&self) -> PyResult<PyFullTextRead> {
+        Ok(PyFullTextRead {
+            inner: self
+                .config
+                .builder(&self.table)
+                .map_err(to_py_err)?
+                .new_read()
+                .map_err(to_py_err)?,
+        })
+    }
+    fn execute_local(&self, py: Python<'_>) -> 
PyResult<PyFullTextSearchResult> {
+        let builder = self.config.builder(&self.table).map_err(to_py_err)?;
+        py.detach(|| runtime().block_on(builder.execute_scored()))
+            .map(|inner| PyFullTextSearchResult { inner })
+            .map_err(to_py_err)
+    }
+}
+
+#[pyclass(name = "FullTextScan", module = "pypaimon_rust.datafusion")]
+pub struct PyFullTextScan {
+    inner: FullTextScan,
+}
+#[pymethods]
+impl PyFullTextScan {
+    fn scan(&self, py: Python<'_>) -> PyResult<PyFullTextScanPlan> {
+        py.detach(|| runtime().block_on(self.inner.scan()))
+            .map(|inner| PyFullTextScanPlan { inner })
+            .map_err(to_py_err)
+    }
+}
+
+#[pyclass(name = "FullTextScanPlan", module = "pypaimon_rust.datafusion")]
+pub struct PyFullTextScanPlan {
+    inner: FullTextScanPlan,
+}
+#[pymethods]
+impl PyFullTextScanPlan {
+    fn snapshot_id(&self) -> Option<i64> {
+        self.inner.snapshot_id()
+    }
+}
+
+#[pyclass(name = "FullTextRead", module = "pypaimon_rust.datafusion")]
+pub struct PyFullTextRead {
+    inner: FullTextRead,
+}
+#[pymethods]
+impl PyFullTextRead {
+    fn read_plan(
+        &self,
+        py: Python<'_>,
+        plan: &PyFullTextScanPlan,
+    ) -> PyResult<PyFullTextSearchResult> {
+        py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
+            .map(|inner| PyFullTextSearchResult { inner })
+            .map_err(to_py_err)
+    }
+}
+
+#[pyclass(name = "FullTextSearchResult", module = "pypaimon_rust.datafusion")]
+pub struct PyFullTextSearchResult {
+    inner: paimon::full_text::SearchResult,
+}
+#[pymethods]
+impl PyFullTextSearchResult {
+    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 2478c02d..e4a5c027 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -21,6 +21,7 @@ mod blob;
 mod blob_uri_reader;
 mod context;
 mod error;
+mod full_text_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 363c4a07..e0daf6ea 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -198,6 +198,10 @@ impl PyTable {
         }
     }
 
+    fn new_full_text_search_builder(&self) -> 
crate::full_text_search::PyFullTextSearchBuilder {
+        
crate::full_text_search::PyFullTextSearchBuilder::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/paimon/src/table/de_vector_read.rs 
b/crates/paimon/src/table/de_vector_read.rs
index 665953bc..ae72aa1c 100644
--- a/crates/paimon/src/table/de_vector_read.rs
+++ b/crates/paimon/src/table/de_vector_read.rs
@@ -24,11 +24,12 @@ use crate::spec::{
     row_id_data_field, CoreOptions, DataField, DataType, FileKind, 
GlobalIndexSearchMode,
     IndexFileMeta, IndexManifestEntry, Predicate, ROW_ID_FIELD_NAME,
 };
-use crate::table::de_vector_scan::{matching_row_ids_for_filter, 
DeVectorScanPlan};
+use crate::table::de_vector_scan::DeVectorScanPlan;
 use crate::table::global_index_scanner::{
     deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
     unindexed_ranges_for_global_index_entries, GlobalIndexScanner, 
RowRangeIndex,
 };
+use crate::table::global_index_search_filter::matching_row_ids_for_filter;
 use crate::table::global_index_types::normalize_queryable_global_index_type;
 use crate::table::index_file_path::IndexFileLocation;
 use crate::table::pk_vector_position_read::SEARCH_SCORE_COLUMN;
diff --git a/crates/paimon/src/table/de_vector_scan.rs 
b/crates/paimon/src/table/de_vector_scan.rs
index ac154ef2..883dd077 100644
--- a/crates/paimon/src/table/de_vector_scan.rs
+++ b/crates/paimon/src/table/de_vector_scan.rs
@@ -17,14 +17,13 @@
 
 //! Plans global-index vector searches against one snapshot and resolves 
scalar pre-filters.
 
-use crate::spec::{CoreOptions, IndexManifest, IndexManifestEntry, Predicate, 
ROW_ID_FIELD_NAME};
+use crate::spec::{CoreOptions, IndexManifest, IndexManifestEntry, Predicate};
 use crate::table::bucket_filter::split_partition_and_data_predicates;
+use crate::table::global_index_search_filter::matching_row_ids_for_filter;
 use crate::table::partition_filter::PartitionFilter;
 use crate::table::vector_scan::Scan;
-use crate::table::{RowRange, Table};
+use crate::table::Table;
 use crate::vindex::vector_search_timing_enabled;
-use arrow_array::{Array, Int64Array};
-use futures::TryStreamExt;
 use roaring::RoaringTreemap;
 use std::sync::Arc;
 use std::time::{Duration, Instant};
@@ -54,65 +53,6 @@ fn same_vector_search_table(left: &Table, right: &Table) -> 
bool {
         && left.branch() == right.branch()
 }
 
-pub(super) async fn matching_row_ids_for_filter(
-    table: &Table,
-    filter: &Predicate,
-    ranges: Option<Vec<RowRange>>,
-) -> crate::Result<RoaringTreemap> {
-    let mut read_builder = table.new_read_builder();
-    read_builder
-        .with_projection(&[ROW_ID_FIELD_NAME])?
-        .with_filter(filter.clone());
-    if let Some(ranges) = ranges {
-        read_builder.with_row_ranges(ranges);
-    }
-    let plan = read_builder.new_scan().plan().await?;
-    let read = read_builder.new_read()?;
-    let mut stream = read.to_arrow(plan.splits())?;
-    let mut row_ids = RoaringTreemap::new();
-    while let Some(batch) = stream.try_next().await? {
-        let index =
-            batch
-                .schema()
-                .index_of(ROW_ID_FIELD_NAME)
-                .map_err(|_| crate::Error::DataInvalid {
-                    message: format!(
-                        "scalar vector pre-filter read is missing 
{ROW_ID_FIELD_NAME}"
-                    ),
-                    source: None,
-                })?;
-        let values = batch
-            .column(index)
-            .as_any()
-            .downcast_ref::<Int64Array>()
-            .ok_or_else(|| crate::Error::DataInvalid {
-                message: format!(
-                    "scalar vector pre-filter {ROW_ID_FIELD_NAME} column is 
not Int64"
-                ),
-                source: None,
-            })?;
-        for row in 0..values.len() {
-            if values.is_null(row) {
-                return Err(crate::Error::DataInvalid {
-                    message: format!(
-                        "scalar vector pre-filter produced a null 
{ROW_ID_FIELD_NAME}"
-                    ),
-                    source: None,
-                });
-            }
-            let row_id = values.value(row);
-            let row_id = u64::try_from(row_id).map_err(|_| 
crate::Error::DataInvalid {
-                message: format!(
-                    "scalar vector pre-filter produced a negative 
{ROW_ID_FIELD_NAME}: {row_id}"
-                ),
-                source: None,
-            })?;
-            row_ids.insert(row_id);
-        }
-    }
-    Ok(row_ids)
-}
-
 impl Table {
     /// Resolve a scalar predicate once and pin all later vector-search/read
     /// stages to the same snapshot.
diff --git a/crates/paimon/src/table/de_vector_scan/tests.rs 
b/crates/paimon/src/table/de_vector_scan/tests.rs
index 310a442f..5033ed9b 100644
--- a/crates/paimon/src/table/de_vector_scan/tests.rs
+++ b/crates/paimon/src/table/de_vector_scan/tests.rs
@@ -27,6 +27,7 @@ use crate::vector_search::SearchResult;
 use arrow_array::builder::{Float32Builder, ListBuilder};
 use arrow_array::{ArrayRef, Float32Array, Int32Array, RecordBatch};
 use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as 
ArrowSchema};
+use futures::TryStreamExt;
 use roaring::RoaringTreemap;
 use std::collections::HashMap;
 use std::sync::Arc;
diff --git a/crates/paimon/src/table/full_text_search_builder.rs 
b/crates/paimon/src/table/full_text_search_builder.rs
index 1c567162..8255768c 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -23,13 +23,13 @@ use crate::full_text::{FullTextSearch, SearchResult};
 use crate::io::FileIO;
 use crate::spec::{
     CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta, 
IndexManifest,
-    IndexManifestEntry, ROW_ID_FIELD_NAME,
+    IndexManifestEntry, Predicate, ROW_ID_FIELD_NAME,
 };
 use crate::table::data_file_reader::DataFileReader;
 use crate::table::full_text_index_adapter::{search_full_text_file, 
search_full_text_index};
 use crate::table::global_index_scanner::{
-    deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
-    unindexed_ranges_for_global_index_entries, RowRangeIndex,
+    deleted_row_ranges_for_data_evolution_dvs, 
unindexed_ranges_for_global_index_entries,
+    RowRangeIndex,
 };
 use crate::table::index_file_path::IndexFileLocation;
 use crate::table::pk_full_text_read::PrimaryKeyFullTextRead;
@@ -46,7 +46,11 @@ use serde_json::json;
 use std::collections::{HashMap, HashSet};
 
 const FULL_TEXT_INDEX_TYPE: &str = "full-text";
-const FULL_TEXT_INDEX_SEARCH_CONCURRENCY: usize = 8;
+mod filter;
+mod read;
+mod scan;
+pub use read::FullTextRead;
+pub use scan::{FullTextScan, FullTextScanPlan};
 
 /// Builder for executing full-text search on a Paimon table.
 ///
@@ -65,8 +69,10 @@ pub struct FullTextSearchBuilder<'a> {
     table: &'a Table,
     text_column: Option<String>,
     query_text: Option<String>,
+    query_is_dsl: bool,
     limit: Option<usize>,
     include_row_ids: Option<RoaringTreemap>,
+    filter: Option<Predicate>,
 }
 
 impl<'a> FullTextSearchBuilder<'a> {
@@ -75,8 +81,10 @@ impl<'a> FullTextSearchBuilder<'a> {
             table,
             text_column: None,
             query_text: None,
+            query_is_dsl: false,
             limit: None,
             include_row_ids: None,
+            filter: None,
         }
     }
 
@@ -89,6 +97,7 @@ impl<'a> FullTextSearchBuilder<'a> {
     /// Set the query text to search for.
     pub fn with_query_text(&mut self, query: &str) -> &mut Self {
         self.query_text = Some(query.to_string());
+        self.query_is_dsl = false;
         self
     }
 
@@ -115,77 +124,35 @@ impl<'a> FullTextSearchBuilder<'a> {
     }
 
     pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
-        // Fail closed: returns data-derived row ranges outside 
`TableScan`/`TableRead`.
-        let core = CoreOptions::new(self.table.schema().options());
-        core.ensure_read_authorized()?;
-        let text_column =
-            self.text_column
-                .as_deref()
-                .ok_or_else(|| crate::Error::ConfigInvalid {
-                    message: "Text column must be set via 
with_text_column()".to_string(),
-                })?;
-        let query_text = self
-            .query_text
-            .as_deref()
-            .ok_or_else(|| crate::Error::ConfigInvalid {
-                message: "Query text must be set via 
with_query_text()".to_string(),
-            })?;
-        let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
-            message: "Limit must be set via with_limit()".to_string(),
-        })?;
-
-        // Primary-key full-text search does not produce global row-ids: it 
maps
-        // hits to physical `(data file, row position)` pairs. A 
scored/row-range
-        // search is therefore unsupported on the PK path — callers must use
-        // `execute_read`. Fail loud rather than fall through to the append/DE
-        // global-index path (which would search the wrong index and could 
return
-        // an empty or wrong result). Mirrors the vector builder.
-        if resolves_to_pk_full_text_path(&core, text_column) {
-            return Err(crate::Error::DataInvalid {
-                message: "primary-key full-text search does not produce global 
row ids; use the \
-                          materialized read (execute_read) instead"
-                    .to_string(),
-                source: None,
-            });
-        }
+        let read = self.new_read()?;
+        read.read(self.new_scan()?.scan().await?).await
+    }
 
-        let mut search = FullTextSearch::new(
-            normalize_query_text(query_text, text_column)?,
-            limit,
-            text_column.to_string(),
-        )?;
-        if let Some(include_row_ids) = &self.include_row_ids {
-            search = search.with_include_row_ids(include_row_ids.clone());
-        }
+    /// Configure the queried field and query, matching Java withQuery.
+    pub fn with_query(&mut self, field_name: &str, query: &str) -> &mut Self {
+        self.with_text_column(field_name);
+        self.query_text = Some(query.to_string());
+        self.query_is_dsl = true;
+        self
+    }
 
-        let snapshot_manager = self.table.snapshot_manager();
+    /// Exclude rows before Top-K; partition conjuncts prune the manifest.
+    pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
+        super::vector_search_common::add_filter(&mut self.filter, filter);
+        self
+    }
 
-        let snapshot = match snapshot_manager.get_latest_snapshot().await? {
-            Some(s) => s,
-            None => return Ok(SearchResult::empty()),
-        };
+    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))
+    }
 
-        let index_entries = match snapshot.index_manifest() {
-            Some(index_manifest_name) => {
-                let manifest_path = 
snapshot_manager.manifest_path(index_manifest_name);
-                IndexManifest::read(self.table.file_io(), 
&manifest_path).await?
-            }
-            None => Vec::new(),
-        };
+    pub fn new_scan(&self) -> crate::Result<FullTextScan> {
+        FullTextScan::new(self)
+    }
 
-        evaluate_full_text_search(
-            FullTextSearchEvaluation {
-                table: Some(self.table),
-                file_io: self.table.file_io(),
-                table_path: self.table.location(),
-                table_options: self.table.schema().options(),
-                schema_fields: self.table.schema().fields(),
-                next_row_id: snapshot.next_row_id(),
-            },
-            &index_entries,
-            &search,
-        )
-        .await
+    pub fn new_read(&self) -> crate::Result<FullTextRead> {
+        FullTextRead::new(self)
     }
 
     /// Run the full-text search and materialize the matching rows as Arrow 
batches,
@@ -256,7 +223,20 @@ impl<'a> FullTextSearchBuilder<'a> {
                 source: None,
             })?;
 
-        let plan = PrimaryKeyFullTextScan::new(self.table, field_id, None)
+        if let Some(filter) = &self.filter {
+            let (_, data) = 
super::bucket_filter::split_partition_and_data_predicates(
+                filter.clone(),
+                self.table.schema().fields(),
+                self.table.schema().partition_keys(),
+            );
+            if !data.is_empty() {
+                return Err(crate::Error::Unsupported {
+                    message: "Primary-key full-text search does not support 
non-partition filters"
+                        .into(),
+                });
+            }
+        }
+        let plan = PrimaryKeyFullTextScan::new(self.table, field_id, 
self.filter.clone())
             .plan()
             .await?;
         if plan.splits.is_empty() {
@@ -309,6 +289,8 @@ struct FullTextSearchEvaluation<'a> {
     table_options: &'a HashMap<String, String>,
     schema_fields: &'a [DataField],
     next_row_id: Option<i64>,
+    partition_filter: Option<&'a Predicate>,
+    row_filter: Option<&'a Predicate>,
 }
 
 async fn evaluate_full_text_search(
@@ -339,7 +321,7 @@ async fn evaluate_full_text_search(
         .collect();
     let has_fulltext_entries = !fulltext_entries.is_empty();
 
-    if !has_fulltext_entries && search_mode == GlobalIndexSearchMode::Fast {
+    if !has_fulltext_entries {
         return Ok(SearchResult::empty());
     }
 
@@ -356,11 +338,24 @@ async fn evaluate_full_text_search(
         None
     };
 
+    let mut indexed_search = search.clone();
+    if let Some(filter) = evaluation.row_filter {
+        let matched =
+            filter::matching_indexed_rows(&evaluation, index_entries, 
&fulltext_entries, filter)
+                .await?;
+        indexed_search.include_row_ids = Some(filter::intersect_include(
+            search.include_row_ids.as_ref(),
+            matched,
+        ));
+    }
     let mut merged = SearchResult::empty();
     if !fulltext_entries.is_empty() {
-        let plans =
-            plan_full_text_index_searches(fulltext_entries, 
search.include_row_ids.as_ref())?;
-        let results = futures::stream::iter(plans)
+        let plans = plan_full_text_index_searches(
+            fulltext_entries,
+            indexed_search.include_row_ids.as_ref(),
+        )?;
+        let results = plans
+            .into_iter()
             .map(|plan| {
                 let entry = plan.entry;
                 let global_meta = 
entry.index_file.global_index_meta.as_ref().unwrap();
@@ -373,22 +368,22 @@ async fn evaluate_full_text_search(
                 let local_filter = plan.local_filter;
                 let row_range_start = global_meta.row_range_start;
                 let row_range_end = global_meta.row_range_end;
-                let limit = search_limit_with_deleted_rows(
-                    search.limit,
-                    row_range_start,
-                    row_range_end,
-                    deleted_row_index.as_ref(),
-                );
-                async move {
+                // Compound query leaves must see every candidate in the shard
+                // before Boolean/boost composition and the final global Top-K.
+                let limit = candidate_limit(row_range_start, row_range_end)?;
+                Ok::<_, crate::Error>(async move {
                     let input = evaluation.file_io.new_input(&path)?;
                     let reader = Box::new(input.reader().await?);
                     let result =
                         search_full_text_index(reader, file_name, query_text, 
limit, local_filter)
                             .await?;
                     Ok::<_, 
crate::Error>(search_result_from_core(result)?.offset(row_range_start))
-                }
+                })
             })
-            .buffered(FULL_TEXT_INDEX_SEARCH_CONCURRENCY)
+            .collect::<crate::Result<Vec<_>>>()?
+            .into_iter();
+        let results = futures::stream::iter(results)
+            .buffered(core_options.global_index_thread_num()?)
             .try_collect::<Vec<_>>()
             .await?;
         for r in &results {
@@ -402,7 +397,7 @@ async fn evaluate_full_text_search(
                 message: "Full-text raw search in detail mode requires table 
context".to_string(),
                 source: None,
             })?;
-            detail_data_ranges_for_table(table).await?
+            detail_data_ranges_for_table(table, 
evaluation.partition_filter).await?
         } else {
             Vec::new()
         };
@@ -420,9 +415,23 @@ async fn evaluate_full_text_search(
                 message: "Full-text raw search requires table 
context".to_string(),
                 source: None,
             })?;
-            let raw_result =
-                read_raw_full_text_search(table, search, &raw_ranges, 
evaluation.table_options)
-                    .await?;
+            let mut raw_search = search.clone();
+            if let Some(filter) = evaluation.row_filter {
+                let matched =
+                    filter::matching_rows(&evaluation, filter, 
raw_ranges.clone()).await?;
+                raw_search.include_row_ids = Some(filter::intersect_include(
+                    search.include_row_ids.as_ref(),
+                    matched,
+                ));
+            }
+            let raw_result = read_raw_full_text_search_filtered(
+                table,
+                &raw_search,
+                &raw_ranges,
+                evaluation.table_options,
+                evaluation.partition_filter,
+            )
+            .await?;
             merged = merged.without_row_ranges(&raw_ranges)?;
             merged = merged.or(&raw_result);
         }
@@ -517,13 +526,15 @@ fn is_full_text_index_file(index_file: &IndexFileMeta) -> 
bool {
     index_file.index_type == FULL_TEXT_INDEX_TYPE
 }
 
-async fn detail_data_ranges_for_table(table: &Table) -> 
crate::Result<Vec<RowRange>> {
-    let plan = table
-        .new_read_builder()
-        .new_scan()
-        .with_scan_all_files()
-        .plan()
-        .await?;
+async fn detail_data_ranges_for_table(
+    table: &Table,
+    partition_filter: Option<&Predicate>,
+) -> crate::Result<Vec<RowRange>> {
+    let mut builder = table.new_read_builder();
+    if let Some(filter) = partition_filter {
+        builder.with_filter(filter.clone());
+    }
+    let plan = builder.new_scan().with_scan_all_files().plan().await?;
     let mut ranges = Vec::new();
     for split in plan.splits() {
         for file in split.data_files() {
@@ -535,11 +546,22 @@ async fn detail_data_ranges_for_table(table: &Table) -> 
crate::Result<Vec<RowRan
     Ok(merge_row_ranges(ranges))
 }
 
+#[cfg(test)]
 async fn read_raw_full_text_search(
     table: &Table,
     search: &FullTextSearch,
     raw_ranges: &[RowRange],
     table_options: &HashMap<String, String>,
+) -> crate::Result<SearchResult> {
+    read_raw_full_text_search_filtered(table, search, raw_ranges, 
table_options, None).await
+}
+
+async fn read_raw_full_text_search_filtered(
+    table: &Table,
+    search: &FullTextSearch,
+    raw_ranges: &[RowRange],
+    table_options: &HashMap<String, String>,
+    partition_filter: Option<&Predicate>,
 ) -> crate::Result<SearchResult> {
     if raw_ranges.is_empty() {
         return Ok(SearchResult::empty());
@@ -555,6 +577,9 @@ async fn read_raw_full_text_search(
     read_builder
         .with_projection(&[search.field_name.as_str(), ROW_ID_FIELD_NAME])?
         .with_row_ranges(raw_ranges);
+    if let Some(filter) = partition_filter {
+        read_builder.with_filter(filter.clone());
+    }
     let plan = read_builder.new_scan().plan().await?;
     if plan.splits().is_empty() {
         return Ok(SearchResult::empty());
@@ -622,7 +647,7 @@ async fn read_raw_full_text_search(
         index_file,
         "raw-full-text.index".to_string(),
         search.query_text.clone(),
-        search.limit,
+        candidate_limit(row_range_start, row_range_end)?,
         local_filter,
     )
     .await?;
@@ -636,6 +661,19 @@ fn raw_index_task_error(error: tokio::task::JoinError) -> 
crate::Error {
     }
 }
 
+fn candidate_limit(from: i64, to: i64) -> crate::Result<usize> {
+    if from < 0 || to < from {
+        return Err(crate::Error::DataInvalid {
+            message: format!("Invalid full-text row range [{from}, {to}]"),
+            source: None,
+        });
+    }
+    usize::try_from(to as u64 - from as u64 + 1).map_err(|_| 
crate::Error::DataInvalid {
+        message: "Full-text row range exceeds the native candidate 
limit".into(),
+        source: None,
+    })
+}
+
 fn raw_full_text_batch_documents(
     batch: &RecordBatch,
     search: &FullTextSearch,
@@ -889,6 +927,8 @@ mod tests {
                 table_options: &options,
                 schema_fields: &fields,
                 next_row_id: Some(10),
+                partition_filter: None,
+                row_filter: None,
             },
             &[],
             &search,
@@ -1042,6 +1082,8 @@ mod tests {
                 table_options: &HashMap::new(),
                 schema_fields: &fields,
                 next_row_id: Some(102),
+                partition_filter: None,
+                row_filter: None,
             },
             &[entry],
             &search,
@@ -1126,6 +1168,8 @@ mod tests {
                 table_options: &HashMap::new(),
                 schema_fields: &fields,
                 next_row_id: Some(102),
+                partition_filter: None,
+                row_filter: None,
             },
             &[entry, missing_empty_filter_entry],
             &search,
@@ -1519,3 +1563,6 @@ mod tests {
         );
     }
 }
+
+#[cfg(test)]
+mod parity_tests;
diff --git a/crates/paimon/src/table/full_text_search_builder/filter.rs 
b/crates/paimon/src/table/full_text_search_builder/filter.rs
new file mode 100644
index 00000000..37246cca
--- /dev/null
+++ b/crates/paimon/src/table/full_text_search_builder/filter.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.
+
+//! Java full-text scalar coverage and exact candidate refinement.
+
+use super::*;
+use crate::table::global_index_scanner::GlobalIndexScanner;
+use crate::table::global_index_search_filter::matching_row_ids_for_filter;
+use crate::table::global_index_types::normalize_queryable_global_index_type;
+
+pub(super) fn intersect_include(
+    include: Option<&RoaringTreemap>,
+    mut rows: RoaringTreemap,
+) -> RoaringTreemap {
+    if let Some(include) = include {
+        rows &= include;
+    }
+    rows
+}
+
+fn row_ids(ranges: &[RowRange]) -> crate::Result<RoaringTreemap> {
+    let mut ids = RoaringTreemap::new();
+    for range in ranges {
+        let from = u64::try_from(range.from()).map_err(|_| 
crate::Error::DataInvalid {
+            message: "Negative full-text candidate row ID".into(),
+            source: None,
+        })?;
+        let to = u64::try_from(range.to()).map_err(|_| 
crate::Error::DataInvalid {
+            message: "Negative full-text candidate row ID".into(),
+            source: None,
+        })?;
+        ids.insert_range(from..=to);
+    }
+    Ok(ids)
+}
+
+fn ranges(ids: &RoaringTreemap) -> crate::Result<Vec<RowRange>> {
+    let mut ranges: Vec<RowRange> = Vec::new();
+    for id in ids.iter() {
+        let id = i64::try_from(id).map_err(|_| crate::Error::DataInvalid {
+            message: "Full-text candidate row ID exceeds i64".into(),
+            source: None,
+        })?;
+        match ranges.last_mut() {
+            Some(last) if last.to().checked_add(1) == Some(id) => {
+                *last = RowRange::new(last.from(), id);
+            }
+            _ => ranges.push(RowRange::new(id, id)),
+        }
+    }
+    Ok(ranges)
+}
+
+fn field_ids(predicate: &Predicate, fields: &[DataField], ids: &mut 
HashSet<i32>) {
+    match predicate {
+        Predicate::Leaf { column, .. } => {
+            if let Some(id) = find_field_id_by_name(fields, column) {
+                ids.insert(id);
+            }
+        }
+        Predicate::And(children) | Predicate::Or(children) => {
+            for child in children {
+                field_ids(child, fields, ids);
+            }
+        }
+        Predicate::Not(child) => field_ids(child, fields, ids),
+        Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+    }
+}
+
+pub(super) async fn matching_rows(
+    evaluation: &FullTextSearchEvaluation<'_>,
+    filter: &Predicate,
+    ranges: Vec<RowRange>,
+) -> crate::Result<RoaringTreemap> {
+    if ranges.is_empty() {
+        return Ok(RoaringTreemap::new());
+    }
+    let table = evaluation.table.ok_or_else(|| crate::Error::DataInvalid {
+        message: "Full-text row filtering requires table context".into(),
+        source: None,
+    })?;
+    // The caller has already selected which ranges Java permits reading. A
+    // candidate refinement must not lose rows to scalar FAST pruning again.
+    let table = table.copy_with_options(HashMap::from([(
+        "scalar-index.search-mode".into(),
+        "full".into(),
+    )]));
+    let filter = match evaluation.partition_filter {
+        Some(partition) => Predicate::and(vec![partition.clone(), 
filter.clone()]),
+        None => filter.clone(),
+    };
+    matching_row_ids_for_filter(&table, &filter, Some(ranges)).await
+}
+
+pub(super) async fn matching_indexed_rows(
+    evaluation: &FullTextSearchEvaluation<'_>,
+    entries: &[IndexManifestEntry],
+    full_text_entries: &[&IndexManifestEntry],
+    filter: &Predicate,
+) -> crate::Result<RoaringTreemap> {
+    let core = CoreOptions::new(evaluation.table_options);
+    let mode = core.scalar_index_search_mode()?;
+    let covered_ranges = full_text_entries
+        .iter()
+        .map(|entry| {
+            let meta = entry.index_file.global_index_meta.as_ref().unwrap();
+            candidate_limit(meta.row_range_start, meta.row_range_end)?;
+            Ok(RowRange::new(meta.row_range_start, meta.row_range_end))
+        })
+        .collect::<crate::Result<Vec<_>>>()?;
+    let covered = row_ids(&covered_ranges)?;
+    let scalar_entries = entries
+        .iter()
+        .filter(|entry| {
+            
normalize_queryable_global_index_type(&entry.index_file.index_type).is_some()
+        })
+        .cloned()
+        .collect::<Vec<_>>();
+    let mut fields = HashSet::new();
+    field_ids(filter, evaluation.schema_fields, &mut fields);
+    let detail = if mode == GlobalIndexSearchMode::Detail {
+        let table = evaluation.table.ok_or_else(|| crate::Error::DataInvalid {
+            message: "Full-text DETAIL filtering requires table 
context".into(),
+            source: None,
+        })?;
+        detail_data_ranges_for_table(table, evaluation.partition_filter).await?
+    } else {
+        Vec::new()
+    };
+    let unindexed_ranges = unindexed_ranges_for_global_index_entries(
+        &scalar_entries,
+        &fields,
+        mode,
+        evaluation.next_row_id,
+        &detail,
+        |file| 
normalize_queryable_global_index_type(&file.index_type).is_some(),
+    );
+    let mut unindexed = row_ids(&unindexed_ranges)? & &covered;
+    let decided = &covered - &unindexed;
+    let mut matched = RoaringTreemap::new();
+    if !decided.is_empty() {
+        // Java createForScalarFilters excludes multi-field definitions: search
+        // pre-filters evaluate individual fields rather than composite keys.
+        let scalar_entries = scalar_entries
+            .into_iter()
+            .filter(|entry| {
+                entry
+                    .index_file
+                    .global_index_meta
+                    .as_ref()
+                    .is_some_and(|meta| 
meta.extra_field_ids.as_ref().is_none_or(Vec::is_empty))
+            })
+            .collect::<Vec<_>>();
+        let scanner = GlobalIndexScanner::create_with_fm_options(
+            evaluation.file_io,
+            evaluation.table_path,
+            core.global_index_thread_num()?,
+            core.btree_index_fallback_scan_max_size()?,
+            core.btree_index_data_block_cache_size()?,
+            core.bitmap_index_fallback_scan_max_size()?,
+            &scalar_entries,
+            evaluation.schema_fields,
+            if scalar_entries
+                .iter()
+                .any(|entry| 
entry.index_file.index_type.eq_ignore_ascii_case("fm"))
+            {
+                
crate::fm_index::FMOptions::from_options(evaluation.table_options)?.read
+            } else {
+                crate::fm_index::FMReadOptions::default()
+            },
+        )?;
+        let answer = match scanner {
+            Some(scanner) => 
scanner.matching_ranges_with_exactness(filter).await?,
+            None => None,
+        };
+        match answer {
+            Some((answer, exact)) => {
+                let candidates = row_ids(&answer)? & &decided;
+                if exact {
+                    matched |= candidates;
+                } else if core.global_index_filter_refine_from_data() {
+                    matched |= matching_rows(evaluation, filter, 
ranges(&candidates)?).await?;
+                }
+            }
+            None if mode != GlobalIndexSearchMode::Fast => unindexed |= 
decided,
+            None => {}
+        }
+    }
+    matched |= matching_rows(evaluation, filter, ranges(&unindexed)?).await?;
+    Ok(matched)
+}
diff --git a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs 
b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
new file mode 100644
index 00000000..fde501e5
--- /dev/null
+++ b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
@@ -0,0 +1,449 @@
+// 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.
+
+//! Real index/read coverage for the public full-text plan contract.
+use super::*;
+use crate::catalog::Identifier;
+use crate::io::FileIOBuilder;
+use crate::spec::{DataType, Datum, IntType, PredicateBuilder, Schema, 
TableSchema, VarCharType};
+use crate::table::{TableCommit, TableWrite};
+use arrow_array::{ArrayRef, Int32Array};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use std::sync::Arc;
+
+fn new_schema(partitioned: bool) -> Schema {
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("text", DataType::VarChar(VarCharType::string_type()))
+        .column("label", DataType::VarChar(VarCharType::string_type()))
+        .column("pt", DataType::Int(IntType::new()))
+        .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()),
+            ("global-index.row-count-per-shard".into(), "4".into()),
+            ("scalar-index.search-mode".into(), "full".into()),
+            ("full-text-index.search-mode".into(), "full".into()),
+        ]));
+    if partitioned {
+        schema = schema.partition_keys(["pt"]);
+    }
+    schema.build().unwrap()
+}
+
+fn new_table(partitioned: bool) -> Table {
+    let schema = new_schema(partitioned);
+    Table::new(
+        FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("default", "text"),
+        format!("memory:/text-parity-{}", uuid::Uuid::new_v4()),
+        TableSchema::new(0, &schema),
+        None,
+    )
+}
+
+async fn append(table: &Table, start: i32, count: i32) {
+    let schema = Arc::new(ArrowSchema::new(vec![
+        Field::new("id", ArrowType::Int32, true),
+        Field::new("text", ArrowType::Utf8, true),
+        Field::new("label", ArrowType::Utf8, true),
+        Field::new("pt", ArrowType::Int32, true),
+    ]));
+    let columns: Vec<ArrayRef> = vec![
+        Arc::new(Int32Array::from((start..start + count).collect::<Vec<_>>())),
+        Arc::new(StringArray::from(
+            (start..start + count)
+                .map(|id| if id == 3 { None } else { Some("paimon lake") })
+                .collect::<Vec<_>>(),
+        )),
+        Arc::new(StringArray::from(
+            (start..start + count)
+                .map(|id| if id % 2 == 1 { "keep" } else { "drop" })
+                .collect::<Vec<_>>(),
+        )),
+        Arc::new(Int32Array::from(
+            (start..start + count).map(|id| id / 4).collect::<Vec<_>>(),
+        )),
+    ];
+    let mut write = TableWrite::new(table, "data".into()).unwrap();
+    write
+        .write_arrow_batch(&RecordBatch::try_new(schema, columns).unwrap())
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "data".into())
+        .commit(write.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+}
+
+async fn index(table: &Table, kind: &str, column: &str) {
+    table
+        .new_global_index_build_builder()
+        .with_index_type(kind)
+        .with_index_column(column)
+        .execute()
+        .await
+        .unwrap();
+}
+
+fn query(table: &Table) -> FullTextSearchBuilder<'_> {
+    let mut builder = table.new_full_text_search_builder();
+    builder
+        .with_query("text", r#"{"match":{"query":"paimon"}}"#)
+        .with_limit(64);
+    builder
+}
+
+fn ids(result: SearchResult) -> Vec<u64> {
+    let mut ids = result.row_ids;
+    ids.sort_unstable();
+    ids
+}
+
+#[tokio::test]
+async fn scalar_modes_and_refinement_follow_java_before_top_k() {
+    let table = new_table(false);
+    append(&table, 0, 8).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, expected) in [
+        ("fast", vec![]),
+        ("full", vec![1, 5, 7]),
+        ("detail", vec![1, 5, 7]),
+    ] {
+        let table = table.copy_with_options(HashMap::from([(
+            "scalar-index.search-mode".into(),
+            mode.into(),
+        )]));
+        assert_eq!(
+            ids(query(&table)
+                .with_filter(keep.clone())
+                .execute_scored()
+                .await
+                .unwrap()),
+            expected
+        );
+    }
+    index(&table, "btree", "label").await;
+    let fast = table.copy_with_options(HashMap::from([(
+        "scalar-index.search-mode".into(),
+        "fast".into(),
+    )]));
+    assert_eq!(
+        ids(query(&fast)
+            .with_filter(keep)
+            .execute_scored()
+            .await
+            .unwrap()),
+        vec![1, 5, 7]
+    );
+    let candidate = pb.contains("label", Datum::String("eep".into())).unwrap();
+    assert_eq!(
+        ids(query(&fast)
+            .with_filter(candidate.clone())
+            .execute_scored()
+            .await
+            .unwrap()),
+        vec![1, 5, 7]
+    );
+    let no_refine = fast.copy_with_options(HashMap::from([(
+        "global-index.filter.refine-from-data".into(),
+        "false".into(),
+    )]));
+    assert!(query(&no_refine)
+        .with_filter(candidate)
+        .execute_scored()
+        .await
+        .unwrap()
+        .is_empty());
+    let high = pb.greater_or_equal("id", Datum::Int(5)).unwrap();
+    let mut search = query(&table);
+    search.with_limit(1).with_filter(high);
+    assert_eq!(ids(search.execute_scored().await.unwrap()), vec![5]);
+}
+
+async fn user_ids(table: &Table, result: SearchResult) -> Vec<i32> {
+    let mut builder = table.new_read_builder();
+    builder.with_projection(&["id", ROW_ID_FIELD_NAME]).unwrap();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut mapping = HashMap::new();
+    for batch in batches {
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let row_ids = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        for row in 0..batch.num_rows() {
+            mapping.insert(row_ids.value(row) as u64, ids.value(row));
+        }
+    }
+    let mut values = result
+        .row_ids
+        .iter()
+        .map(|id| mapping[id])
+        .collect::<Vec<_>>();
+    values.sort_unstable();
+    values
+}
+
+#[tokio::test]
+async fn partition_filters_apply_to_indexed_and_raw_corpora() {
+    let table = new_table(true);
+    append(&table, 0, 8).await;
+    index(&table, "full-text", "text").await;
+    append(&table, 8, 4).await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let filter = pb.greater_or_equal("pt", Datum::Int(1)).unwrap();
+    for mode in ["full", "detail"] {
+        let table = table.copy_with_options(HashMap::from([(
+            "full-text-index.search-mode".into(),
+            mode.into(),
+        )]));
+        assert_eq!(
+            user_ids(
+                &table,
+                query(&table)
+                    .with_partition_filter(filter.clone())
+                    .unwrap()
+                    .execute_scored()
+                    .await
+                    .unwrap()
+            )
+            .await,
+            (4..12).collect::<Vec<_>>()
+        );
+        let filter = Predicate::and(vec![
+            filter.clone(),
+            pb.equal("label", Datum::String("keep".into())).unwrap(),
+        ]);
+        assert_eq!(
+            user_ids(
+                &table,
+                query(&table)
+                    .with_filter(filter)
+                    .execute_scored()
+                    .await
+                    .unwrap()
+            )
+            .await,
+            vec![5, 7, 9, 11]
+        );
+    }
+}
+
+#[tokio::test]
+async fn scan_read_pin_snapshot_and_validate_plan_origin() {
+    let table = new_table(false);
+    append(&table, 0, 8).await;
+    index(&table, "full-text", "text").await;
+    let builder = query(&table);
+    let scan = builder.new_scan().unwrap();
+    let read = builder.new_read().unwrap();
+    let plan = scan.scan().await.unwrap();
+    assert_eq!(plan.snapshot_id(), Some(2));
+    append(&table, 8, 4).await;
+    assert_eq!(
+        ids(read.read(plan.clone()).await.unwrap()),
+        vec![0, 1, 2, 4, 5, 6, 7]
+    );
+    assert_eq!(
+        ids(query(&table).execute_scored().await.unwrap()),
+        vec![0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11]
+    );
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let mut other = query(&table);
+    other.with_filter(pb.equal("id", Datum::Int(1)).unwrap());
+    assert!(other
+        .new_read()
+        .unwrap()
+        .read(plan.clone())
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("different table, column or filter"));
+    let foreign = new_table(false);
+    assert!(query(&foreign)
+        .new_read()
+        .unwrap()
+        .read(plan)
+        .await
+        .is_err());
+}
+
+#[tokio::test]
+async fn empty_plans_stay_empty_after_append_and_validate_query_first() {
+    let table = new_table(false);
+    let builder = query(&table);
+    let plan = builder.new_scan().unwrap().scan().await.unwrap();
+    assert_eq!(plan.snapshot_id(), None);
+    append(&table, 0, 4).await;
+    index(&table, "full-text", "text").await;
+    assert!(builder
+        .new_read()
+        .unwrap()
+        .read(plan)
+        .await
+        .unwrap()
+        .is_empty());
+    let empty = new_table(false);
+    assert!(query(&empty)
+        .with_limit(0)
+        .execute_scored()
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("positive"));
+    assert!(query(&empty)
+        .with_query("missing", r#"{"match":{"query":"paimon"}}"#)
+        .new_scan()
+        .is_err());
+    assert!(query(&empty)
+        .with_query("id", r#"{"match":{"query":"paimon"}}"#)
+        .new_scan()
+        .is_err());
+}
+
+#[tokio::test]
+async fn raw_filter_keeps_bm25_statistics_of_the_whole_corpus() {
+    let table = new_table(false);
+    append(&table, 0, 4).await;
+    index(&table, "full-text", "text").await;
+    append(&table, 4, 8).await;
+    let unfiltered = query(&table).execute_scored().await.unwrap();
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let filtered = query(&table)
+        .with_filter(pb.equal("label", Datum::String("keep".into())).unwrap())
+        .execute_scored()
+        .await
+        .unwrap();
+    assert_eq!(ids(filtered.clone()), vec![1, 5, 7, 9, 11]);
+    let scores: HashMap<_, _> = unfiltered
+        .row_ids
+        .into_iter()
+        .zip(unfiltered.scores)
+        .collect();
+    for (id, score) in filtered.row_ids.into_iter().zip(filtered.scores) {
+        assert_eq!(score, scores[&id]);
+    }
+}
+
+#[tokio::test]
+async fn snapshot_pinning_preserves_schema_only_renames() {
+    use crate::catalog::{Catalog, FileSystemCatalog};
+    use crate::common::Options;
+    use crate::spec::SchemaChange;
+
+    let catalog = FileSystemCatalog::new(Options::from_map(HashMap::from([(
+        "warehouse".into(),
+        format!("memory:/text-schema-rename-{}", uuid::Uuid::new_v4()),
+    )])))
+    .unwrap();
+    catalog
+        .create_database("default", false, HashMap::new())
+        .await
+        .unwrap();
+    let identifier = Identifier::new("default", "text");
+    catalog
+        .create_table(&identifier, new_schema(false), false)
+        .await
+        .unwrap();
+    let table = catalog.get_table(&identifier).await.unwrap();
+    append(&table, 0, 4).await;
+    index(&table, "full-text", "text").await;
+    append(&table, 4, 4).await;
+    catalog
+        .alter_table(
+            &identifier,
+            vec![SchemaChange::rename_column("text".into(), "body".into())],
+            false,
+        )
+        .await
+        .unwrap();
+    let table = catalog.get_table(&identifier).await.unwrap();
+    for mode in ["full", "detail"] {
+        let table = table.copy_with_options(HashMap::from([(
+            "full-text-index.search-mode".into(),
+            mode.into(),
+        )]));
+        let mut builder = table.new_full_text_search_builder();
+        builder
+            .with_query("body", r#"{"match":{"query":"paimon"}}"#)
+            .with_limit(64);
+        assert_eq!(
+            ids(builder.execute_scored().await.unwrap()),
+            vec![0, 1, 2, 4, 5, 6, 7]
+        );
+        let plan = builder.new_scan().unwrap().scan().await.unwrap();
+        assert_eq!(plan.table.schema().id(), table.schema().id());
+        assert_eq!(
+            ids(builder.new_read().unwrap().read(plan).await.unwrap()),
+            vec![0, 1, 2, 4, 5, 6, 7]
+        );
+    }
+}
+
+#[tokio::test]
+async fn scalar_search_pre_filters_exclude_composite_indexes() {
+    let table = new_table(false);
+    append(&table, 0, 8).await;
+    index(&table, "full-text", "text").await;
+    table
+        .new_global_index_build_builder()
+        .with_index_type("btree")
+        .with_index_columns(&["label", "id"])
+        .execute()
+        .await
+        .unwrap();
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let predicate = Predicate::and(vec![
+        pb.equal("label", Datum::String("keep".into())).unwrap(),
+        pb.greater_than("id", Datum::Int(0)).unwrap(),
+    ]);
+    for (mode, expected) in [
+        ("fast", vec![]),
+        ("full", vec![1, 5, 7]),
+        ("detail", vec![1, 5, 7]),
+    ] {
+        let table = table.copy_with_options(HashMap::from([(
+            "scalar-index.search-mode".into(),
+            mode.into(),
+        )]));
+        assert_eq!(
+            ids(query(&table)
+                .with_filter(predicate.clone())
+                .execute_scored()
+                .await
+                .unwrap()),
+            expected
+        );
+    }
+}
diff --git a/crates/paimon/src/table/full_text_search_builder/read.rs 
b/crates/paimon/src/table/full_text_search_builder/read.rs
new file mode 100644
index 00000000..3a8b2169
--- /dev/null
+++ b/crates/paimon/src/table/full_text_search_builder/read.rs
@@ -0,0 +1,89 @@
+// 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.
+
+//! Executes full-text queries over a snapshot-scoped plan.
+
+use super::scan::PlanContext;
+use super::*;
+
+/// An owned reader can execute queries over plans from the same table, column
+/// and filter. Changing a query or limit does not require another scan.
+pub struct FullTextRead {
+    table: Table,
+    context: PlanContext,
+    search: FullTextSearch,
+}
+
+impl FullTextRead {
+    pub(super) fn new(builder: &FullTextSearchBuilder<'_>) -> 
crate::Result<Self> {
+        
CoreOptions::new(builder.table.schema().options()).ensure_read_authorized()?;
+        let limit = builder.limit.filter(|limit| *limit > 0).ok_or_else(|| {
+            crate::Error::ConfigInvalid {
+                message: "Limit must be positive, set via with_limit()".into(),
+            }
+        })?;
+        let context = PlanContext::new(builder)?;
+        let query = builder
+            .query_text
+            .as_deref()
+            .ok_or_else(|| crate::Error::ConfigInvalid {
+                message: "Query text must be set via with_query() or 
with_query_text()".into(),
+            })?;
+        let query = if builder.query_is_dsl {
+            query.to_string()
+        } else {
+            normalize_query_text(query, &context.column)?
+        };
+        let search = FullTextSearch::new(query, limit, 
context.column.clone())?;
+        Ok(Self {
+            table: builder.table.clone(),
+            context,
+            search,
+        })
+    }
+
+    pub async fn read(&self, plan: FullTextScanPlan) -> 
crate::Result<SearchResult> {
+        
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        
CoreOptions::new(plan.table.schema().options()).ensure_read_authorized()?;
+        if self.context != plan.context {
+            return Err(crate::Error::DataInvalid {
+                message: "Full-text plan belongs to a different table, column 
or filter".into(),
+                source: None,
+            });
+        }
+        if plan.snapshot_id.is_none() {
+            return Ok(SearchResult::empty());
+        }
+        let mut search = self.search.clone();
+        search.include_row_ids = self.context.include_row_ids.clone();
+        evaluate_full_text_search(
+            FullTextSearchEvaluation {
+                table: Some(&plan.table),
+                file_io: plan.table.file_io(),
+                table_path: plan.table.location(),
+                table_options: self.table.schema().options(),
+                schema_fields: self.table.schema().fields(),
+                next_row_id: plan.next_row_id,
+                partition_filter: plan.partition_filter.as_ref(),
+                row_filter: plan.row_filter.as_ref(),
+            },
+            &plan.entries,
+            &search,
+        )
+        .await
+    }
+}
diff --git a/crates/paimon/src/table/full_text_search_builder/scan.rs 
b/crates/paimon/src/table/full_text_search_builder/scan.rs
new file mode 100644
index 00000000..8e87a3cd
--- /dev/null
+++ b/crates/paimon/src/table/full_text_search_builder/scan.rs
@@ -0,0 +1,183 @@
+// 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.
+
+//! Snapshot-pinned, query-independent full-text planning.
+
+use super::*;
+use crate::spec::Predicate;
+use crate::table::bucket_filter::split_partition_and_data_predicates;
+use crate::table::partition_filter::PartitionFilter;
+
+#[derive(Clone, PartialEq)]
+pub(super) struct PlanContext {
+    location: String,
+    branch: String,
+    pub(super) column: String,
+    pub(super) filter: Option<Predicate>,
+    pub(super) include_row_ids: Option<RoaringTreemap>,
+}
+
+impl PlanContext {
+    pub(super) fn new(builder: &FullTextSearchBuilder<'_>) -> 
crate::Result<Self> {
+        
CoreOptions::new(builder.table.schema().options()).ensure_read_authorized()?;
+        let column = builder
+            .text_column
+            .as_deref()
+            .ok_or_else(|| crate::Error::ConfigInvalid {
+                message: "Text column must be set via with_query() or 
with_text_column()".into(),
+            })?;
+        let field = builder
+            .table
+            .schema()
+            .fields()
+            .iter()
+            .find(|field| field.name() == column)
+            .ok_or_else(|| crate::Error::ConfigInvalid {
+                message: format!("Text column '{column}' does not exist"),
+            })?;
+        if !matches!(
+            field.data_type(),
+            crate::spec::DataType::Char(_) | crate::spec::DataType::VarChar(_)
+        ) {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!("Full-text column '{column}' must be a 
character string"),
+            });
+        }
+        if 
resolves_to_pk_full_text_path(&builder.table.schema().core_options(), column) {
+            return Err(crate::Error::DataInvalid { message: "primary-key 
full-text search does not produce global row ids; use the materialized read 
(execute_read) instead".into(), source: None });
+        }
+        Ok(Self {
+            location: builder.table.location().trim_end_matches('/').into(),
+            branch: builder.table.branch().into(),
+            column: column.into(),
+            filter: builder.filter.clone(),
+            include_row_ids: builder.include_row_ids.clone(),
+        })
+    }
+}
+
+/// One selected snapshot and its partition-pruned index entries. A plan 
remains
+/// usable after its scan is dropped, and does not re-resolve the latest 
snapshot.
+#[derive(Clone)]
+pub struct FullTextScanPlan {
+    pub(super) context: PlanContext,
+    pub(super) table: Table,
+    pub(super) entries: Vec<IndexManifestEntry>,
+    pub(super) next_row_id: Option<i64>,
+    pub(super) snapshot_id: Option<i64>,
+    pub(super) partition_filter: Option<Predicate>,
+    pub(super) row_filter: Option<Predicate>,
+}
+
+impl FullTextScanPlan {
+    pub fn snapshot_id(&self) -> Option<i64> {
+        self.snapshot_id
+    }
+}
+
+/// Full-text planning follows Java FullTextScan: select one snapshot and prune
+/// index manifest entries by partition. Queries and limits belong to the 
reader.
+pub struct FullTextScan {
+    table: Table,
+    context: PlanContext,
+}
+
+impl FullTextScan {
+    pub(super) fn new(builder: &FullTextSearchBuilder<'_>) -> 
crate::Result<Self> {
+        Ok(Self {
+            table: builder.table.clone(),
+            context: PlanContext::new(builder)?,
+        })
+    }
+
+    pub async fn scan(&self) -> crate::Result<FullTextScanPlan> {
+        
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        let mut plan = FullTextScanPlan {
+            context: self.context.clone(),
+            table: self.table.clone(),
+            entries: Vec::new(),
+            next_row_id: None,
+            snapshot_id: None,
+            partition_filter: None,
+            row_filter: None,
+        };
+        let mut partition_pruner = None;
+        if let Some(filter) = &self.context.filter {
+            let (partition, data) = split_partition_and_data_predicates(
+                filter.clone(),
+                self.table.schema().fields(),
+                self.table.schema().partition_keys(),
+            );
+            if let Some(partition) = partition {
+                let fields = self.table.schema().partition_fields();
+                partition_pruner =
+                    Some(PartitionFilter::from_predicate(partition.clone(), 
&fields));
+                let mapping = self
+                    .table
+                    .schema()
+                    .partition_keys()
+                    .iter()
+                    .map(|key| {
+                        self.table
+                            .schema()
+                            .fields()
+                            .iter()
+                            .position(|field| field.name() == key)
+                    })
+                    .collect::<Vec<_>>();
+                plan.partition_filter =
+                    Some(partition.remap_field_index(&mapping).ok_or_else(|| {
+                        crate::Error::DataInvalid {
+                            message:
+                                "Cannot bind full-text partition predicate to 
the table schema"
+                                    .into(),
+                            source: None,
+                        }
+                    })?);
+            }
+            plan.row_filter = (!data.is_empty()).then(|| Predicate::and(data));
+        }
+        if plan.row_filter.is_some() && 
!self.table.schema().core_options().data_evolution_enabled()
+        {
+            return Err(crate::Error::Unsupported {
+                message: "Full-text row filters require a data-evolution 
table".into(),
+            });
+        }
+        let Some(snapshot) = 
crate::table::time_travel::resolve_snapshot(&self.table).await? else {
+            return Ok(plan);
+        };
+        plan.snapshot_id = Some(snapshot.id());
+        plan.next_row_id = snapshot.next_row_id();
+        // Pin the data without changing the schema already bound by the query.
+        // A schema-only rename does not create a new data snapshot.
+        plan.table = self.table.copy_with_pinned_snapshot(&snapshot);
+        if let Some(name) = snapshot.index_manifest() {
+            let path = self.table.snapshot_manager().manifest_path(name);
+            plan.entries = IndexManifest::read(self.table.file_io(), 
&path).await?;
+        }
+        if let Some(partition) = partition_pruner {
+            let mut entries = Vec::new();
+            for entry in plan.entries {
+                if partition.matches_entry(&entry.partition)? {
+                    entries.push(entry);
+                }
+            }
+            plan.entries = entries;
+        }
+        Ok(plan)
+    }
+}
diff --git a/crates/paimon/src/table/global_index_search_filter.rs 
b/crates/paimon/src/table/global_index_search_filter.rs
new file mode 100644
index 00000000..423bd867
--- /dev/null
+++ b/crates/paimon/src/table/global_index_search_filter.rs
@@ -0,0 +1,77 @@
+// 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.
+
+//! Exact row-ID filtering shared by data-evolution index searches.
+
+use crate::spec::{Predicate, ROW_ID_FIELD_NAME};
+use crate::table::{RowRange, Table};
+use arrow_array::{Array, Int64Array};
+use futures::TryStreamExt;
+use roaring::RoaringTreemap;
+
+pub(super) async fn matching_row_ids_for_filter(
+    table: &Table,
+    filter: &Predicate,
+    ranges: Option<Vec<RowRange>>,
+) -> crate::Result<RoaringTreemap> {
+    let mut read_builder = table.new_read_builder();
+    read_builder
+        .with_projection(&[ROW_ID_FIELD_NAME])?
+        .with_filter(filter.clone());
+    if let Some(ranges) = ranges {
+        read_builder.with_row_ranges(ranges);
+    }
+    let plan = read_builder.new_scan().plan().await?;
+    let read = read_builder.new_read()?;
+    let mut stream = read.to_arrow(plan.splits())?;
+    let mut row_ids = RoaringTreemap::new();
+    while let Some(batch) = stream.try_next().await? {
+        let index =
+            batch
+                .schema()
+                .index_of(ROW_ID_FIELD_NAME)
+                .map_err(|_| crate::Error::DataInvalid {
+                    message: format!("global index row filter read is missing 
{ROW_ID_FIELD_NAME}"),
+                    source: None,
+                })?;
+        let values = batch
+            .column(index)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: format!("global index row filter {ROW_ID_FIELD_NAME} 
column is not Int64"),
+                source: None,
+            })?;
+        for row in 0..values.len() {
+            if values.is_null(row) {
+                return Err(crate::Error::DataInvalid {
+                    message: format!("global index row filter produced a null 
{ROW_ID_FIELD_NAME}"),
+                    source: None,
+                });
+            }
+            let row_id = values.value(row);
+            let row_id = u64::try_from(row_id).map_err(|_| 
crate::Error::DataInvalid {
+                message: format!(
+                    "global index row filter produced a negative 
{ROW_ID_FIELD_NAME}: {row_id}"
+                ),
+                source: None,
+            })?;
+            row_ids.insert(row_id);
+        }
+    }
+    Ok(row_ids)
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 3877bd6f..11a902ed 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -78,6 +78,7 @@ mod global_index_build_builder;
 pub(crate) mod global_index_build_common;
 mod global_index_drop_builder;
 pub(crate) mod global_index_scanner;
+mod global_index_search_filter;
 mod global_index_types;
 mod hybrid_search_builder;
 mod incremental_scan;
@@ -189,7 +190,9 @@ pub use format_partition::{
 pub use format_partition_stats::FormatTablePartitionStatsCollector;
 pub use format_partition_truncate::FormatTableTruncator;
 #[cfg(feature = "fulltext")]
-pub use full_text_search_builder::FullTextSearchBuilder;
+pub use full_text_search_builder::{
+    FullTextRead, FullTextScan, FullTextScanPlan, FullTextSearchBuilder,
+};
 use futures::stream::BoxStream;
 pub use global_index_build_builder::GlobalIndexBuildBuilder;
 pub use global_index_drop_builder::GlobalIndexDropBuilder;
diff --git a/docs/src/sql.md b/docs/src/sql.md
index e017bedb..120b8a33 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -2129,6 +2129,40 @@ The function searches across all Tantivy full-text index 
files for the target co
 
 Build the index files with `CALL sys.create_global_index(..., index_type => 
'full-text')` (see [create_global_index](#create_global_index)), or with Java 
Paimon; both write the same format.
 
+### Rust full-text builder
+
+The core builder accepts the structured full-text query DSL used by Java and
+PyPaimon. Scalar and partition predicates are applied before the final Top-K.
+
+```rust,ignore
+let predicate = PredicateBuilder::new(table.schema().fields())
+    .equal("category", Datum::String("news".into()))?;
+let mut builder = table.new_full_text_search_builder();
+builder
+    .with_query("content", r#"{"match":{"query":"paimon"}}"#)
+    .with_filter(predicate)
+    .with_limit(10);
+let plan = builder.new_scan()?.scan().await?;
+let result = builder.new_read()?.read(plan).await?;
+```
+
+`FullTextScanPlan` owns a pinned snapshot and can outlive its scan. Readers
+for the same table, branch, column and filter can reuse the plan with different
+queries or limits. Pinning preserves the query's bound schema, including
+schema-only column renames. `execute_scored()` performs both stages directly.
+
+For Data Evolution, full-text and scalar `fast`, `full`, and `detail` modes
+follow Java's coverage rules. Inexact scalar candidates are read back only
+when `global-index.filter.refine-from-data=true`. Composite scalar indexes
+are excluded from search pre-filters, as in Java. Filtering preserves the
+unfiltered corpus for BM25 scores; Boolean and boosted queries collect leaf
+candidates before applying the final limit. Index shard searches use
+`global-index.thread-num` workers.
+
+The existing `with_text_column()` / `with_query_text()` API continues to
+accept plain text for the SQL table function. Full-text row filters require
+Data Evolution; primary-key materialized reads use `execute_read()`.
+
 
 ## Time Travel
 

Reply via email to