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 eb1493d9 Support primary-key full-text results through scan and read 
(#1132)
eb1493d9 is described below

commit eb1493d92ba2f57d75362be5168d16ced3873c53
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 17:47:53 2026 +0800

    Support primary-key full-text results through scan and read (#1132)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |   8 +-
 bindings/python/src/context.rs                     |   5 +-
 bindings/python/src/full_text_search.rs            |  33 +----
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/search_result.rs               | 121 +++++++++++++++++++
 bindings/python/src/vector_search.rs               | 102 +---------------
 .../paimon/src/table/full_text_search_builder.rs   | 130 ++++++--------------
 .../table/full_text_search_builder/parity_tests.rs |  99 ++++++++++++++-
 .../src/table/full_text_search_builder/read.rs     |  73 ++++++++++-
 .../src/table/full_text_search_builder/scan.rs     |  26 +++-
 crates/paimon/src/table/hybrid_search_builder.rs   |   2 +-
 crates/paimon/src/table/mod.rs                     |   3 +-
 crates/paimon/src/table/pk_full_text_read.rs       | 134 +++++++--------------
 crates/paimon/src/table/pk_full_text_scan.rs       |   2 +
 .../{vector_search_result.rs => search_result.rs}  |  59 +++++++--
 .../tests.rs                                       |   0
 crates/paimon/src/vector_search.rs                 |   2 +-
 17 files changed, 460 insertions(+), 340 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 7b605d68..1edff10e 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -806,7 +806,7 @@ class 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: ...
+    def execute_local(self) -> SearchResult: ...
 
 class FullTextScan:
     def scan(self) -> FullTextScanPlan: ...
@@ -815,11 +815,7 @@ 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]: ...
+    def read_plan(self, plan: FullTextScanPlan) -> SearchResult: ...
 
 class ShardTableUpdator:
     def arrow_reader(self) -> RecordBatchReader:
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 37277513..1c29b456 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -801,7 +801,6 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     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::hybrid_search::PyHybridSearchBuilder>()?;
     this.add_class::<crate::hybrid_search::PyHybridSearchResult>()?;
     this.add_class::<crate::vector_search::PyVectorSearchBuilder>()?;
@@ -810,8 +809,8 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::vector_search::PyVectorScanPlan>()?;
     this.add_class::<crate::vector_search::PyVectorRead>()?;
     this.add_class::<crate::vector_search::PyBatchVectorRead>()?;
-    this.add_class::<crate::vector_search::PySearchResult>()?;
-    this.add_class::<crate::vector_search::PySearchResultReadBuilder>()?;
+    this.add_class::<crate::search_result::PySearchResult>()?;
+    this.add_class::<crate::search_result::PySearchResultReadBuilder>()?;
     this.add_class::<crate::read::PyReadBuilder>()?;
     this.add_class::<crate::read::PyTableScan>()?;
     this.add_class::<crate::read::PyStreamTableScan>()?;
diff --git a/bindings/python/src/full_text_search.rs 
b/bindings/python/src/full_text_search.rs
index b73c0fb6..ca364d1a 100644
--- a/bindings/python/src/full_text_search.rs
+++ b/bindings/python/src/full_text_search.rs
@@ -19,12 +19,12 @@
 
 use crate::error::to_py_err;
 use crate::predicate::dict_to_table_predicate;
+use crate::search_result::PySearchResult;
 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)]
@@ -123,10 +123,10 @@ impl PyFullTextSearchBuilder {
                 .map_err(to_py_err)?,
         })
     }
-    fn execute_local(&self, py: Python<'_>) -> 
PyResult<PyFullTextSearchResult> {
+    fn execute_local(&self, py: Python<'_>) -> PyResult<PySearchResult> {
         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(PySearchResult::new)
             .map_err(to_py_err)
     }
 }
@@ -161,32 +161,9 @@ pub struct PyFullTextRead {
 }
 #[pymethods]
 impl PyFullTextRead {
-    fn read_plan(
-        &self,
-        py: Python<'_>,
-        plan: &PyFullTextScanPlan,
-    ) -> PyResult<PyFullTextSearchResult> {
+    fn read_plan(&self, py: Python<'_>, plan: &PyFullTextScanPlan) -> 
PyResult<PySearchResult> {
         py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
-            .map(|inner| PyFullTextSearchResult { inner })
+            .map(PySearchResult::new)
             .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 a15c7600..c55aa5bd 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -31,6 +31,7 @@ mod oss_cpp;
 mod predicate;
 mod read;
 mod schema;
+mod search_result;
 mod shard_update;
 mod table;
 mod udf;
diff --git a/bindings/python/src/search_result.rs 
b/bindings/python/src/search_result.rs
new file mode 100644
index 00000000..2e889e30
--- /dev/null
+++ b/bindings/python/src/search_result.rs
@@ -0,0 +1,121 @@
+// 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.
+
+use crate::error::to_py_err;
+use arrow::pyarrow::ToPyArrow;
+use futures::TryStreamExt;
+use paimon::table::SearchResult;
+use paimon_datafusion::runtime::runtime;
+use pyo3::prelude::*;
+use pyo3::types::PyBytes;
+use std::collections::HashMap;
+
+#[pyclass(name = "SearchResult", module = "pypaimon_rust.datafusion")]
+pub struct PySearchResult {
+    inner: SearchResult,
+}
+
+impl PySearchResult {
+    pub(crate) fn new(inner: SearchResult) -> Self {
+        Self { inner }
+    }
+}
+
+type PySearchPosition = (Py<PyBytes>, i32, String, i64, f32);
+
+#[pymethods]
+impl PySearchResult {
+    fn snapshot_id(&self) -> Option<i64> {
+        self.inner.snapshot_id()
+    }
+    fn new_read_builder(&self) -> PySearchResultReadBuilder {
+        PySearchResultReadBuilder {
+            result: self.inner.clone(),
+            projection: None,
+        }
+    }
+    fn __len__(&self) -> usize {
+        self.inner.len()
+    }
+    fn row_ids(&self) -> PyResult<HashMap<u64, f32>> {
+        let hits = self.inner.row_ids().map_err(to_py_err)?;
+        Ok(hits
+            .row_ids
+            .iter()
+            .copied()
+            .zip(hits.scores.iter().copied())
+            .collect())
+    }
+    fn positions(&self, py: Python<'_>) -> PyResult<Vec<PySearchPosition>> {
+        Ok(self
+            .inner
+            .positions()
+            .map_err(to_py_err)?
+            .iter()
+            .map(|position| {
+                (
+                    PyBytes::new(py, 
&position.partition.to_serialized_bytes()).unbind(),
+                    position.bucket,
+                    position.data_file_name.clone(),
+                    position.row_position,
+                    position.score,
+                )
+            })
+            .collect())
+    }
+    fn splits(&self, py: Python<'_>) -> PyResult<Vec<Py<PyBytes>>> {
+        Ok(self
+            .inner
+            .serialize_primary_key_splits()
+            .map_err(to_py_err)?
+            .iter()
+            .map(|bytes| PyBytes::new(py, bytes).unbind())
+            .collect())
+    }
+}
+
+#[pyclass(name = "SearchResultReadBuilder", module = 
"pypaimon_rust.datafusion")]
+pub struct PySearchResultReadBuilder {
+    result: SearchResult,
+    projection: Option<Vec<String>>,
+}
+
+#[pymethods]
+impl PySearchResultReadBuilder {
+    fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) -> 
PyRefMut<'_, Self> {
+        slf.projection = Some(columns);
+        slf
+    }
+    fn read(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
+        let batches = py
+            .detach(|| {
+                runtime().block_on(async {
+                    let mut builder = self.result.new_read_builder();
+                    if let Some(projection) = &self.projection {
+                        let columns: Vec<&str> = 
projection.iter().map(String::as_str).collect();
+                        builder.with_projection(&columns);
+                    }
+                    builder.read().await?.try_collect::<Vec<_>>().await
+                })
+            })
+            .map_err(to_py_err)?;
+        batches
+            .iter()
+            .map(|batch| batch.to_pyarrow(py).map(Bound::unbind))
+            .collect()
+    }
+}
diff --git a/bindings/python/src/vector_search.rs 
b/bindings/python/src/vector_search.rs
index 49fe05b6..540c579d 100644
--- a/bindings/python/src/vector_search.rs
+++ b/bindings/python/src/vector_search.rs
@@ -18,17 +18,15 @@
 use std::collections::HashMap;
 use std::sync::Arc;
 
-use arrow::pyarrow::ToPyArrow;
-use futures::TryStreamExt;
 use paimon::spec::Predicate;
 use paimon::table::{BatchVectorRead, Table, VectorRead, VectorScan, 
VectorScanPlan};
-use paimon::vector_search::SearchResult;
 use paimon_datafusion::runtime::runtime;
 use pyo3::prelude::*;
-use pyo3::types::{PyBytes, PyDict};
+use pyo3::types::PyDict;
 
 use crate::error::to_py_err;
 use crate::predicate::dict_to_table_predicate;
+use crate::search_result::PySearchResult;
 
 #[derive(Clone, Default)]
 struct SearchConfig {
@@ -332,99 +330,3 @@ impl PyBatchVectorRead {
             .map_err(to_py_err)
     }
 }
-
-#[pyclass(name = "SearchResult", module = "pypaimon_rust.datafusion")]
-pub struct PySearchResult {
-    inner: SearchResult,
-}
-
-impl PySearchResult {
-    fn new(inner: SearchResult) -> Self {
-        Self { inner }
-    }
-}
-
-type PySearchPosition = (Py<PyBytes>, i32, String, i64, f32);
-
-#[pymethods]
-impl PySearchResult {
-    fn snapshot_id(&self) -> Option<i64> {
-        self.inner.snapshot_id()
-    }
-    fn new_read_builder(&self) -> PySearchResultReadBuilder {
-        PySearchResultReadBuilder {
-            result: self.inner.clone(),
-            projection: None,
-        }
-    }
-    fn __len__(&self) -> usize {
-        self.inner.len()
-    }
-    fn row_ids(&self) -> PyResult<HashMap<u64, f32>> {
-        let hits = self.inner.row_ids().map_err(to_py_err)?;
-        Ok(hits
-            .row_ids
-            .iter()
-            .copied()
-            .zip(hits.scores.iter().copied())
-            .collect())
-    }
-    fn positions(&self, py: Python<'_>) -> PyResult<Vec<PySearchPosition>> {
-        Ok(self
-            .inner
-            .positions()
-            .map_err(to_py_err)?
-            .iter()
-            .map(|position| {
-                (
-                    PyBytes::new(py, 
&position.partition.to_serialized_bytes()).unbind(),
-                    position.bucket,
-                    position.data_file_name.clone(),
-                    position.row_position,
-                    position.score,
-                )
-            })
-            .collect())
-    }
-    fn splits(&self, py: Python<'_>) -> PyResult<Vec<Py<PyBytes>>> {
-        Ok(self
-            .inner
-            .serialize_primary_key_splits()
-            .map_err(to_py_err)?
-            .iter()
-            .map(|bytes| PyBytes::new(py, bytes).unbind())
-            .collect())
-    }
-}
-
-#[pyclass(name = "SearchResultReadBuilder", module = 
"pypaimon_rust.datafusion")]
-pub struct PySearchResultReadBuilder {
-    result: SearchResult,
-    projection: Option<Vec<String>>,
-}
-
-#[pymethods]
-impl PySearchResultReadBuilder {
-    fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) -> 
PyRefMut<'_, Self> {
-        slf.projection = Some(columns);
-        slf
-    }
-    fn read(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
-        let batches = py
-            .detach(|| {
-                runtime().block_on(async {
-                    let mut builder = self.result.new_read_builder();
-                    if let Some(projection) = &self.projection {
-                        let columns: Vec<&str> = 
projection.iter().map(String::as_str).collect();
-                        builder.with_projection(&columns);
-                    }
-                    builder.read().await?.try_collect::<Vec<_>>().await
-                })
-            })
-            .map_err(to_py_err)?;
-        batches
-            .iter()
-            .map(|batch| batch.to_pyarrow(py).map(Bound::unbind))
-            .collect()
-    }
-}
diff --git a/crates/paimon/src/table/full_text_search_builder.rs 
b/crates/paimon/src/table/full_text_search_builder.rs
index 8255768c..11d85bfb 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -38,7 +38,7 @@ use crate::table::{
     find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange, 
Table,
 };
 use arrow_array::{Array, Int64Array, LargeStringArray, RecordBatch, 
StringArray};
-use futures::{stream, StreamExt, TryStreamExt};
+use futures::{StreamExt, TryStreamExt};
 use paimon_ftindex_core::io::PosWriter;
 use paimon_ftindex_core::{FullTextIndexConfig, FullTextIndexWriter};
 use roaring::RoaringTreemap;
@@ -119,11 +119,15 @@ impl<'a> FullTextSearchBuilder<'a> {
     /// the search against full-text indexes.
     ///
     /// Reference: `FullTextSearchBuilder.executeLocal()`
+    /// For primary-key file positions, use [`Self::execute_scored`].
     pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
-        Ok(self.execute_scored().await?.to_row_ranges())
+        self.execute_scored().await?.row_ids()?.to_row_ranges()
     }
 
-    pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
+    /// Search one snapshot without materializing user columns. Results carry
+    /// global row IDs or primary-key file positions and can be read later with
+    /// [`crate::table::SearchResult::new_read_builder`].
+    pub async fn execute_scored(&self) -> 
crate::Result<crate::table::SearchResult> {
         let read = self.new_read()?;
         read.read(self.new_scan()?.scan().await?).await
     }
@@ -178,15 +182,6 @@ impl<'a> FullTextSearchBuilder<'a> {
                 .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(),
-        })?;
 
         if !resolves_to_pk_full_text_path(&core, text_column) {
             return Err(crate::Error::Unsupported {
@@ -197,74 +192,7 @@ impl<'a> FullTextSearchBuilder<'a> {
             });
         }
 
-        // FAST-only: reject FULL/DETAIL loud rather than silently degrading.
-        if core.full_text_index_search_mode()? != GlobalIndexSearchMode::Fast {
-            return Err(crate::Error::DataInvalid {
-                message: "primary-key full-text search supports only the FAST 
global-index search \
-                          mode"
-                    .to_string(),
-                source: None,
-            });
-        }
-
-        // Reject a non-positive limit at construction, before the empty-plan 
fast
-        // path — an empty table must not mask an invalid limit (mirrors Java
-        // `PrimaryKeyFullTextRead`, and matches `search_bucket`'s own guard).
-        if limit == 0 {
-            return Err(crate::Error::ConfigInvalid {
-                message: "Limit must be positive".to_string(),
-            });
-        }
-
-        // Resolve the queried column's schema field id for the scan/field-id 
guard.
-        let field_id = find_field_id_by_name(self.table.schema().fields(), 
text_column)
-            .ok_or_else(|| crate::Error::DataInvalid {
-                message: format!("full-text search column '{text_column}' does 
not exist"),
-                source: 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() {
-            return Ok(Box::pin(stream::empty()));
-        }
-
-        // A predicate-free materialization reader projecting the user table
-        // columns (mirrors `table_read.rs::new_data_file_reader` with an empty
-        // predicate list). The PK read appends the score column itself.
-        let materialize_reader = DataFileReader::new(
-            self.table.file_io().clone(),
-            self.table.schema_manager().clone(),
-            self.table.schema().id(),
-            self.table.schema().fields().to_vec(),
-            self.table.schema().fields().to_vec(),
-            Vec::new(),
-        )
-        .with_table_options(self.table.schema().options().clone());
-        let read = PrimaryKeyFullTextRead::new(
-            self.table.file_io().clone(),
-            materialize_reader,
-            self.table.location().trim_end_matches('/').to_string(),
-            self.table
-                .schema()
-                .core_options()
-                .index_file_in_data_file_dir(),
-        );
-        read.read(&plan, query_text, limit).await
+        self.execute_scored().await?.new_read_builder().read().await
     }
 }
 
@@ -1414,10 +1342,28 @@ mod tests {
         )
     }
 
+    #[tokio::test]
+    async fn pk_full_text_scan_and_read_accept_an_empty_pk_table() {
+        let table = pk_full_text_table("pk_ft_plan_read_empty", &[]);
+        let mut builder = table.new_full_text_search_builder();
+        builder
+            .with_query("body", r#"{"match":{"query":"alpha"}}"#)
+            .with_limit(10);
+        let plan = builder.new_scan().unwrap().scan().await.unwrap();
+        assert_eq!(plan.snapshot_id(), None);
+        assert!(builder
+            .new_read()
+            .unwrap()
+            .read(plan)
+            .await
+            .unwrap()
+            .is_empty());
+    }
+
     // (d) The PK full-text path produces physical positions, not global 
row-ids:
-    // `execute` / `execute_scored` must fail loud and point callers at 
execute_read.
+    // Only the ranges-only `execute` API rejects the physical-position result.
     #[tokio::test]
-    async fn pk_full_text_execute_fails_loud_use_execute_read() {
+    async fn pk_full_text_ranges_reject_physical_positions() {
         let table = pk_full_text_table("pk_ft_execute_loud", &[]);
         let err = table
             .new_full_text_search_builder()
@@ -1428,26 +1374,26 @@ mod tests {
             .await
             .unwrap_err();
         assert!(
-            matches!(&err, crate::Error::DataInvalid { message, .. } if 
message.contains("execute_read")),
-            "PK full-text execute must fail loud pointing at execute_read, 
got: {err}"
+            matches!(&err, crate::Error::Unsupported { message } if 
message.contains("physical file positions")),
+            "the ranges-only API must reject primary-key file positions, got: 
{err}"
         );
     }
 
     #[tokio::test]
-    async fn pk_full_text_execute_scored_fails_loud_use_execute_read() {
+    async fn pk_full_text_scored_empty_result_keeps_its_address_space() {
         let table = pk_full_text_table("pk_ft_scored_loud", &[]);
-        let err = table
+        let result = table
             .new_full_text_search_builder()
             .with_text_column("body")
             .with_query_text(r#"{"match":{"query":"alpha"}}"#)
             .with_limit(10)
             .execute_scored()
             .await
-            .unwrap_err();
-        assert!(
-            matches!(&err, crate::Error::DataInvalid { message, .. } if 
message.contains("global row ids")),
-            "PK full-text execute_scored must fail loud, got: {err}"
-        );
+            .unwrap();
+        assert_eq!(result.snapshot_id(), None);
+        assert!(result.positions().unwrap().is_empty());
+        assert!(result.row_ids().is_err());
+        assert!(result.serialize_primary_key_splits().unwrap().is_empty());
     }
 
     // (c) FAST-only: FULL / DETAIL global-index search modes must fail loud 
on the
@@ -1521,7 +1467,7 @@ mod tests {
 
     // Dispatch: with data-evolution ENABLED, a configured pk-full-text column 
must
     // NOT resolve to the PK path (mirrors Java 
`primaryKeyFullTextDefinition`), so
-    // execute_scored takes the append/DE path instead of the PK fail-loud 
guard.
+    // execute_scored returns global row IDs rather than physical PK positions.
     #[tokio::test]
     async fn data_evolution_enabled_does_not_take_pk_path() {
         let de_on = HashMap::from([
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
index b8948262..5992e8dc 100644
--- a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
+++ b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
@@ -135,12 +135,101 @@ fn query(table: &Table) -> FullTextSearchBuilder<'_> {
     builder
 }
 
-fn ids(result: SearchResult) -> Vec<u64> {
-    let mut ids = result.row_ids;
+fn ids(result: crate::table::SearchResult) -> Vec<u64> {
+    let mut ids = result.into_row_ids().unwrap().row_ids;
     ids.sort_unstable();
     ids
 }
 
+#[tokio::test]
+async fn 
full_text_result_reads_projection_and_scores_from_the_captured_snapshot() {
+    let table = new_table(false);
+    append(&table, 0, 8).await;
+    index(&table, "full-text", "text").await;
+    let result = query(&table).execute_scored().await.unwrap();
+    let snapshot = result.snapshot_id().unwrap();
+    let expected = result.row_ids().unwrap();
+    assert!(result.positions().is_err());
+    append(&table, 8, 4).await;
+    assert!(
+        table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap()
+            .unwrap()
+            > snapshot
+    );
+
+    let batches: Vec<RecordBatch> = result
+        .new_read_builder()
+        .with_projection(&["label", "id"])
+        .read()
+        .await
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut user_ids = Vec::new();
+    let mut scores = Vec::new();
+    for batch in batches {
+        assert_eq!(
+            batch
+                .schema()
+                .fields()
+                .iter()
+                .map(|f| f.name().as_str())
+                .collect::<Vec<_>>(),
+            ["label", "id", "__paimon_search_score"]
+        );
+        user_ids.extend_from_slice(
+            batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values(),
+        );
+        scores.extend_from_slice(
+            batch
+                .column(2)
+                .as_any()
+                .downcast_ref::<arrow_array::Float32Array>()
+                .unwrap()
+                .values(),
+        );
+    }
+    assert_eq!(
+        user_ids,
+        expected
+            .row_ids
+            .iter()
+            .map(|id| *id as i32)
+            .collect::<Vec<_>>()
+    );
+    assert_eq!(scores, expected.scores);
+    let batches: Vec<RecordBatch> = result
+        .new_read_builder()
+        .with_projection(&[])
+        .read()
+        .await
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(
+        batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+        expected.len()
+    );
+    assert!(batches.iter().all(|batch| batch.num_columns() == 1));
+    assert!(result
+        .new_read_builder()
+        .with_projection(&["missing"])
+        .read()
+        .await
+        .is_err());
+}
+
 #[tokio::test]
 async fn scalar_modes_and_refinement_follow_java_before_top_k() {
     let table = new_table(false);
@@ -293,7 +382,7 @@ async fn 
empty_string_filters_preserve_java_search_refinement() {
     }
 }
 
-async fn user_ids(table: &Table, result: SearchResult) -> Vec<i32> {
+async fn user_ids(table: &Table, result: crate::table::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();
@@ -322,6 +411,8 @@ async fn user_ids(table: &Table, result: SearchResult) -> 
Vec<i32> {
         }
     }
     let mut values = result
+        .row_ids()
+        .unwrap()
         .row_ids
         .iter()
         .map(|id| mapping[id])
@@ -461,6 +552,8 @@ async fn 
raw_filter_keeps_bm25_statistics_of_the_whole_corpus() {
         .await
         .unwrap();
     assert_eq!(ids(filtered.clone()), vec![1, 5, 7, 9, 11]);
+    let unfiltered = unfiltered.into_row_ids().unwrap();
+    let filtered = filtered.into_row_ids().unwrap();
     let scores: HashMap<_, _> = unfiltered
         .row_ids
         .into_iter()
diff --git a/crates/paimon/src/table/full_text_search_builder/read.rs 
b/crates/paimon/src/table/full_text_search_builder/read.rs
index 3a8b2169..934affe9 100644
--- a/crates/paimon/src/table/full_text_search_builder/read.rs
+++ b/crates/paimon/src/table/full_text_search_builder/read.rs
@@ -43,7 +43,32 @@ impl FullTextRead {
             .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 {
+        if context.primary_key_field_id.is_some() {
+            if builder
+                .table
+                .schema()
+                .core_options()
+                .full_text_index_search_mode()?
+                != GlobalIndexSearchMode::Fast
+            {
+                return Err(crate::Error::DataInvalid { message: "primary-key 
full-text search supports only the FAST global-index search mode".into(), 
source: None });
+            }
+            if let Some(filter) = &context.filter {
+                let (_, data) = 
super::super::bucket_filter::split_partition_and_data_predicates(
+                    filter.clone(),
+                    builder.table.schema().fields(),
+                    builder.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 query = if builder.query_is_dsl || 
context.primary_key_field_id.is_some() {
             query.to_string()
         } else {
             normalize_query_text(query, &context.column)?
@@ -56,7 +81,7 @@ impl FullTextRead {
         })
     }
 
-    pub async fn read(&self, plan: FullTextScanPlan) -> 
crate::Result<SearchResult> {
+    pub async fn read(&self, plan: FullTextScanPlan) -> 
crate::Result<crate::table::SearchResult> {
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
         
CoreOptions::new(plan.table.schema().options()).ensure_read_authorized()?;
         if self.context != plan.context {
@@ -65,12 +90,44 @@ impl FullTextRead {
                 source: None,
             });
         }
-        if plan.snapshot_id.is_none() {
-            return Ok(SearchResult::empty());
+        let table = std::sync::Arc::new(plan.table.clone());
+        if self.context.primary_key_field_id.is_some() {
+            let empty = 
super::super::pk_full_text_scan::PrimaryKeyFullTextScanPlan {
+                snapshot_id: 0,
+                splits: Vec::new(),
+            };
+            let source = plan.primary_key_plan.as_ref().unwrap_or(&empty);
+            let materialize = DataFileReader::new(
+                plan.table.file_io().clone(),
+                plan.table.schema_manager().clone(),
+                plan.table.schema().id(),
+                plan.table.schema().fields().to_vec(),
+                plan.table.schema().fields().to_vec(),
+                Vec::new(),
+            )
+            .with_table_options(plan.table.schema().options().clone());
+            let read = PrimaryKeyFullTextRead::new(
+                plan.table.file_io().clone(),
+                materialize,
+                plan.table.location().trim_end_matches('/').to_string(),
+                plan.table
+                    .schema()
+                    .core_options()
+                    .index_file_in_data_file_dir(),
+            );
+            let result = read
+                .search_route(source, &self.search.query_text, 
self.search.limit)
+                .await?;
+            return crate::table::SearchResult::from_full_text_primary_key(
+                table,
+                result.snapshot_id,
+                result.candidates,
+                result.splits,
+            );
         }
         let mut search = self.search.clone();
         search.include_row_ids = self.context.include_row_ids.clone();
-        evaluate_full_text_search(
+        let hits = evaluate_full_text_search(
             FullTextSearchEvaluation {
                 table: Some(&plan.table),
                 file_io: plan.table.file_io(),
@@ -84,6 +141,10 @@ impl FullTextRead {
             &plan.entries,
             &search,
         )
-        .await
+        .await?;
+        Ok(crate::table::SearchResult::from_full_text_row_ids(
+            table,
+            crate::vector_search::ScoredRowIds::new(hits.row_ids, hits.scores),
+        ))
     }
 }
diff --git a/crates/paimon/src/table/full_text_search_builder/scan.rs 
b/crates/paimon/src/table/full_text_search_builder/scan.rs
index 68e94f22..96e49471 100644
--- a/crates/paimon/src/table/full_text_search_builder/scan.rs
+++ b/crates/paimon/src/table/full_text_search_builder/scan.rs
@@ -29,6 +29,7 @@ pub(super) struct PlanContext {
     pub(super) column: String,
     pub(super) filter: Option<Predicate>,
     pub(super) include_row_ids: Option<RoaringTreemap>,
+    pub(super) primary_key_field_id: Option<i32>,
 }
 
 impl PlanContext {
@@ -57,8 +58,15 @@ impl PlanContext {
                 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 });
+        let primary_key_field_id =
+            
resolves_to_pk_full_text_path(&builder.table.schema().core_options(), column)
+                .then_some(field.id());
+        if primary_key_field_id.is_some() && builder.include_row_ids.is_some() 
{
+            return Err(crate::Error::Unsupported {
+                message:
+                    "primary-key full-text search uses physical file 
positions, not global row IDs"
+                        .into(),
+            });
         }
         Ok(Self {
             location: builder.table.location().trim_end_matches('/').into(),
@@ -66,11 +74,12 @@ impl PlanContext {
             column: column.into(),
             filter: builder.filter.clone(),
             include_row_ids: builder.include_row_ids.clone(),
+            primary_key_field_id,
         })
     }
 }
 
-/// One selected snapshot and its partition-pruned index entries. A plan 
remains
+/// One selected snapshot and its partition-pruned indexes or PK buckets. A 
plan remains
 /// usable after its scan is dropped, and does not re-resolve the latest 
snapshot.
 #[derive(Clone)]
 pub struct FullTextScanPlan {
@@ -81,6 +90,8 @@ pub struct FullTextScanPlan {
     pub(super) snapshot_id: Option<i64>,
     pub(super) partition_filter: Option<Predicate>,
     pub(super) row_filter: Option<Predicate>,
+    pub(super) primary_key_plan:
+        Option<super::super::pk_full_text_scan::PrimaryKeyFullTextScanPlan>,
 }
 
 impl FullTextScanPlan {
@@ -114,6 +125,7 @@ impl FullTextScan {
             snapshot_id: None,
             partition_filter: None,
             row_filter: None,
+            primary_key_plan: None,
         };
         let mut partition_pruner = None;
         if let Some(filter) = &self.context.filter {
@@ -165,6 +177,14 @@ impl FullTextScan {
         // 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(Some(&snapshot));
+        if let Some(field_id) = self.context.primary_key_field_id {
+            plan.primary_key_plan = Some(
+                PrimaryKeyFullTextScan::new(&plan.table, field_id, 
self.context.filter.clone())
+                    .plan()
+                    .await?,
+            );
+            return Ok(plan);
+        }
         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?;
diff --git a/crates/paimon/src/table/hybrid_search_builder.rs 
b/crates/paimon/src/table/hybrid_search_builder.rs
index d6edb496..602a3337 100644
--- a/crates/paimon/src/table/hybrid_search_builder.rs
+++ b/crates/paimon/src/table/hybrid_search_builder.rs
@@ -1043,7 +1043,7 @@ async fn execute_full_text_route(
 ) -> 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))
+    result.into_row_ids()
 }
 
 #[cfg(not(feature = "fulltext"))]
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 92d5b7c9..f7caeb62 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -138,6 +138,7 @@ mod row_position_selection;
 mod row_sidecar;
 mod scan_trace;
 pub(crate) mod schema_manager;
+pub(crate) mod search_result;
 mod shard_table_updater;
 pub(crate) mod snapshot_commit;
 mod snapshot_manager;
@@ -170,7 +171,6 @@ mod vector_read;
 mod vector_scan;
 mod vector_search_builder;
 mod vector_search_common;
-pub(crate) mod vector_search_result;
 #[cfg(test)]
 mod vector_search_test_utils;
 mod vindex_index_build_builder;
@@ -222,6 +222,7 @@ pub use read_builder::ReadBuilder;
 pub use rest_env::RESTEnv;
 pub use scan_trace::ScanTrace;
 pub use schema_manager::SchemaManager;
+pub use search_result::{PrimaryKeySearchPosition, SearchResult, 
SearchResultReadBuilder};
 pub use shard_table_updater::ShardTableUpdater;
 pub use snapshot_commit::{RESTSnapshotCommit, RenamingSnapshotCommit, 
SnapshotCommit};
 pub use snapshot_manager::SnapshotManager;
diff --git a/crates/paimon/src/table/pk_full_text_read.rs 
b/crates/paimon/src/table/pk_full_text_read.rs
index 18376cf5..a5523a30 100644
--- a/crates/paimon/src/table/pk_full_text_read.rs
+++ b/crates/paimon/src/table/pk_full_text_read.rs
@@ -16,28 +16,21 @@
 // under the License.
 
 //! Primary-key full-text search read foundation: the score-based candidate 
type,
-//! its cross-bucket score-descending Top-K, and the FAST-only materialized 
read
-//! orchestration (plan the buckets, search each, fuse by score, materialize 
the
-//! winning physical rows best-score-first with a `__paimon_search_score` 
column).
+//! its cross-bucket score-descending Top-K, and the FAST-only search over
+//! physical positions. SearchResult materializes the selected rows later.
 
 use std::cmp::Ordering;
 use std::collections::{BTreeMap, HashMap};
 
-use arrow_array::RecordBatch;
-use futures::{stream, TryStreamExt};
-
 use crate::deletion_vector::DeletionVector;
 use crate::io::FileIO;
 use crate::spec::BinaryRow;
 use crate::table::data_file_reader::DataFileReader;
 use crate::table::pk_full_text_bucket_search::search_bucket;
 use crate::table::pk_full_text_scan::{PrimaryKeyFullTextScanPlan, 
PrimaryKeyFullTextSearchSplit};
-use crate::table::pk_vector_indexed_split_read::{PkVectorIndexedSplit, 
PkVectorIndexedSplitRead};
+use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplit;
 use crate::table::source::DataSplitBuilder;
-use crate::table::vector_search_common::{
-    collect_ranked_rows, reorder_and_strip_position, RankedRow,
-};
-use crate::table::{ArrowRecordBatchStream, RowRange};
+use crate::table::RowRange;
 
 fn data_invalid(message: impl Into<String>) -> crate::Error {
     crate::Error::DataInvalid {
@@ -255,24 +248,19 @@ pub(crate) fn build_full_text_indexed_splits(
 /// [`PrimaryKeyFullTextRead::search_route`].
 pub(crate) struct PrimaryKeyFullTextRouteResult<'a> {
     pub(crate) candidates: Vec<PrimaryKeyFullTextCandidate>,
-    // Read by the hybrid route consumer (fuses routes before materializing); 
the
-    // materialized read reaches candidates directly and holds the plan itself.
+    // Both shared results and hybrid routes retain the selected snapshot.
     pub(crate) snapshot_id: i64,
     pub(crate) splits: &'a [PrimaryKeyFullTextSearchSplit],
 }
 
-/// FAST-only primary-key full-text materialized read. Given a planned set of
+/// FAST-only primary-key full-text search. Given a planned set of
 /// per-bucket search splits, it searches each bucket through the full-text 
archive
-/// reader, fuses the hits cross-bucket by score, materializes the winning 
physical
-/// rows, and re-orders them best-score-first with a `__paimon_search_score` 
column
-/// (the internal `_PKEY_VECTOR_POSITION` column is stripped). Mirrors Java
-/// `PrimaryKeyFullTextRead` feeding its result splits into an ordinary table 
read:
-/// the search decides which rows, the reader decides which columns.
+/// reader and fuses physical positions by score. SearchResult owns the 
selections
+/// and their later materialization, as Java PrimaryKeyScoredResult does.
 ///
 /// The planning + FAST-mode guard live in the caller 
(`FullTextSearchBuilder`);
-/// this type is the Table-free search+materialize core (it holds only the
-/// predicate-free materialization reader, the `FileIO`, and the table path 
for the
-/// archive directory), so it can be driven end-to-end from a hand-built plan.
+/// this type searches a prepared plan. Its data reader loads deletion vectors;
+/// projected user columns are read later by SearchResult.
 pub(crate) struct PrimaryKeyFullTextRead {
     file_io: FileIO,
     materialize_reader: DataFileReader,
@@ -299,8 +287,8 @@ impl PrimaryKeyFullTextRead {
     /// best-score-first Top-`limit`, WITHOUT materializing any rows. Returns 
the
     /// surviving candidates together with the route source context (the plan's
     /// resolved snapshot id and its per-bucket source splits) so a caller can 
fuse
-    /// them with another route before materializing. [`read`](Self::read) is
-    /// layered on top of this. `query` is passed verbatim to the archive 
reader
+    /// them with another route before materializing through SearchResult.
+    /// `query` is passed verbatim to the archive reader
     /// (spec D2).
     pub(crate) async fn search_route<'p>(
         &self,
@@ -362,71 +350,6 @@ impl PrimaryKeyFullTextRead {
             splits: &plan.splits,
         })
     }
-
-    /// Search every planned bucket, fuse the hits by score into a global 
Top-`limit`,
-    /// materialize the winning rows, and emit them best-score-first with the
-    /// unified score column. An empty plan or an empty result yields an empty
-    /// stream. `query` is passed verbatim to the archive reader (spec D2).
-    pub(crate) async fn read(
-        &self,
-        plan: &PrimaryKeyFullTextScanPlan,
-        query: &str,
-        limit: usize,
-    ) -> crate::Result<ArrowRecordBatchStream> {
-        let survivors = self.search_route(plan, query, 
limit).await?.candidates;
-        if survivors.is_empty() {
-            return Ok(Box::pin(stream::empty()));
-        }
-
-        // Rank each survivor by its best-first position, keyed by its FULL 
physical
-        // position `(partition bytes, bucket, file, row position)` so the 
physical
-        // materialization order can be reduced back to best-first (gap 8). 
Keying on
-        // just file+position would collide across partitions/buckets.
-        let mut rank_of: HashMap<(Vec<u8>, i32, String, i64), usize> = 
HashMap::new();
-        for (rank, c) in survivors.iter().enumerate() {
-            rank_of.insert(
-                (
-                    c.partition.to_serialized_bytes(),
-                    c.bucket,
-                    c.data_file_name.clone(),
-                    c.row_position,
-                ),
-                rank,
-            );
-        }
-
-        let indexed_splits = build_full_text_indexed_splits(survivors, 
&plan.splits)?;
-
-        // Materialize every indexed split, retaining each batch and, per row, 
the
-        // (rank, batch_index, row_index) tuple so we can reorder to 
best-first.
-        // Top-K is small, so full in-memory collection is acceptable.
-        let mut batches: Vec<RecordBatch> = Vec::new();
-        let mut ranked: Vec<RankedRow> = Vec::new();
-        for indexed in indexed_splits {
-            let partition_bytes = 
indexed.split.partition().to_serialized_bytes();
-            let bucket = indexed.split.bucket();
-            let file_name = indexed.split.data_files()[0].file_name.clone();
-            let mut stream =
-                
PkVectorIndexedSplitRead::new(self.materialize_reader.clone()).read(&indexed)?;
-            while let Some(batch) = stream.try_next().await? {
-                let batch_index = batches.len();
-                collect_ranked_rows(
-                    &batch,
-                    batch_index,
-                    &partition_bytes,
-                    bucket,
-                    &file_name,
-                    &rank_of,
-                    &mut ranked,
-                )?;
-                batches.push(batch);
-            }
-        }
-
-        // Reorder to best-first and drop the internal position column.
-        let output = reorder_and_strip_position(&batches, ranked)?;
-        Ok(Box::pin(stream::iter(output.into_iter().map(Ok))))
-    }
 }
 
 #[cfg(test)]
@@ -566,8 +489,10 @@ mod read_tests {
     use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN, 
SEARCH_SCORE_COLUMN};
     use crate::table::schema_manager::SchemaManager;
     use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
+    use crate::table::ArrowRecordBatchStream;
     use arrow_array::{Array, Float32Array, Int32Array, RecordBatch};
     use bytes::Bytes;
+    use futures::TryStreamExt;
     use paimon_ftindex_core::io::PosWriter;
     use paimon_ftindex_core::{FullTextIndexConfig, FullTextIndexWriter};
     use paimon_mosaic_core::spec::COMPRESSION_NONE;
@@ -575,6 +500,37 @@ mod read_tests {
     use std::io;
     use std::sync::Arc;
 
+    // Exercise the same shared result materialization as the public builder.
+    impl PrimaryKeyFullTextRead {
+        async fn read(
+            &self,
+            plan: &PrimaryKeyFullTextScanPlan,
+            query: &str,
+            limit: usize,
+        ) -> crate::Result<ArrowRecordBatchStream> {
+            let schema = crate::spec::Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .build()?;
+            let table = crate::table::Table::new(
+                self.file_io.clone(),
+                crate::catalog::Identifier::new("default", "text"),
+                self.table_path.clone(),
+                crate::spec::TableSchema::new(1, &schema),
+                None,
+            );
+            let route = self.search_route(plan, query, limit).await?;
+            crate::table::SearchResult::from_full_text_primary_key(
+                Arc::new(table),
+                route.snapshot_id,
+                route.candidates,
+                route.splits,
+            )?
+            .new_read_builder()
+            .read()
+            .await
+        }
+    }
+
     const FILE_SOURCE_COMPACT: i32 = 1;
 
     // ---- helpers ----
diff --git a/crates/paimon/src/table/pk_full_text_scan.rs 
b/crates/paimon/src/table/pk_full_text_scan.rs
index 350b17dc..679d20c2 100644
--- a/crates/paimon/src/table/pk_full_text_scan.rs
+++ b/crates/paimon/src/table/pk_full_text_scan.rs
@@ -43,6 +43,7 @@ fn data_invalid(message: impl Into<String>) -> crate::Error {
 /// bucket-split invariants (unique data files; each payload covers ≥1 active
 /// source; no active file double-covered; uncovered files 
active/non-duplicate/
 /// not-covered; and covered ∪ uncovered == every active data file in the 
split).
+#[derive(Clone)]
 pub(crate) struct PrimaryKeyFullTextSearchSplit {
     pub data_split: DataSplit,
     pub current_payloads: Vec<IndexFileMeta>,
@@ -248,6 +249,7 @@ impl BucketAccumulator {
 }
 
 /// The per-bucket search splits produced by planning.
+#[derive(Clone)]
 pub(crate) struct PrimaryKeyFullTextScanPlan {
     // The snapshot the plan resolved during planning (pinned before the index
     // manifest is read); the read guards every split against it before 
searching.
diff --git a/crates/paimon/src/table/vector_search_result.rs 
b/crates/paimon/src/table/search_result.rs
similarity index 79%
rename from crates/paimon/src/table/vector_search_result.rs
rename to crates/paimon/src/table/search_result.rs
index 01610637..4c5861e0 100644
--- a/crates/paimon/src/table/vector_search_result.rs
+++ b/crates/paimon/src/table/search_result.rs
@@ -45,7 +45,9 @@ pub struct PrimaryKeySearchPosition {
 #[derive(Debug, Clone)]
 enum SearchHits {
     DataEvolution {
-        vector_column: String,
+        // Vector results retain their target-column validation at read time;
+        // full-text columns have already been validated by FullTextRead.
+        vector_column: Option<String>,
         hits: ScoredRowIds,
     },
     PrimaryKey {
@@ -54,9 +56,9 @@ enum SearchHits {
     },
 }
 
-/// Ranked vector-search hits together with the source snapshot needed to read 
them.
+/// Ranked search hits together with the source snapshot needed to read them.
 ///
-/// Both single and batch searches return this type. DE hits use global row 
IDs;
+/// Single/batch vector and full-text searches return this type. DE hits use 
global row IDs;
 /// PK hits use physical file positions, as in Java's `PrimaryKeyScoredResult`.
 /// Searching does not materialize projected user columns. Use
 /// [`new_read_builder`](Self::new_read_builder) when rows are needed.
@@ -78,12 +80,51 @@ impl SearchResult {
             table,
             snapshot_id,
             hits: SearchHits::DataEvolution {
-                vector_column,
+                vector_column: Some(vector_column),
+                hits,
+            },
+        }
+    }
+
+    #[cfg(feature = "fulltext")]
+    pub(super) fn from_full_text_row_ids(table: Arc<Table>, hits: 
ScoredRowIds) -> Self {
+        let snapshot_id = table.travel_snapshot().map(|snapshot| 
snapshot.id());
+        Self {
+            table,
+            snapshot_id,
+            hits: SearchHits::DataEvolution {
+                vector_column: None,
                 hits,
             },
         }
     }
 
+    #[cfg(feature = "fulltext")]
+    pub(super) fn from_full_text_primary_key(
+        table: Arc<Table>,
+        snapshot_id: i64,
+        candidates: Vec<super::pk_full_text_read::PrimaryKeyFullTextCandidate>,
+        source_splits: 
&[super::pk_full_text_scan::PrimaryKeyFullTextSearchSplit],
+    ) -> crate::Result<Self> {
+        let positions = candidates
+            .iter()
+            .map(|c| PrimaryKeySearchPosition {
+                partition: c.partition.clone(),
+                bucket: c.bucket,
+                data_file_name: c.data_file_name.clone(),
+                row_position: c.row_position,
+                score: c.score,
+            })
+            .collect();
+        let splits =
+            
super::pk_full_text_read::build_full_text_indexed_splits(candidates, 
source_splits)?;
+        Ok(Self {
+            table,
+            snapshot_id: (snapshot_id != 0).then_some(snapshot_id),
+            hits: SearchHits::PrimaryKey { positions, splits },
+        })
+    }
+
     pub(super) fn from_primary_key(
         table: Arc<Table>,
         snapshot_id: i64,
@@ -200,7 +241,7 @@ fn no_global_row_ids() -> crate::Error {
 
 /// Reads selected user columns and `__paimon_search_score`, in relevance 
order.
 /// Internal global row IDs and PK positions are not output. Reading a result
-/// never reruns vector search or replans its primary-key source files.
+/// never reruns search or replans its primary-key source files.
 #[derive(Debug, Clone)]
 pub struct SearchResultReadBuilder<'a> {
     result: &'a SearchResult,
@@ -223,8 +264,12 @@ impl SearchResultReadBuilder<'_> {
                 vector_column,
                 hits,
             } => {
-                let read_type =
-                    DeVectorRead::read_type(table, vector_column, 
self.projection.as_deref())?;
+                let read_type = match vector_column {
+                    Some(column) => {
+                        DeVectorRead::read_type(table, column, 
self.projection.as_deref())?
+                    }
+                    None => resolve_materialize_read_type(table, 
self.projection.as_deref())?,
+                };
                 materialize_row_ids(table, hits, read_type).await
             }
             SearchHits::PrimaryKey { positions, splits } => {
diff --git a/crates/paimon/src/table/vector_search_result/tests.rs 
b/crates/paimon/src/table/search_result/tests.rs
similarity index 100%
rename from crates/paimon/src/table/vector_search_result/tests.rs
rename to crates/paimon/src/table/search_result/tests.rs
diff --git a/crates/paimon/src/vector_search.rs 
b/crates/paimon/src/vector_search.rs
index 0599b3f4..1ebc3f71 100644
--- a/crates/paimon/src/vector_search.rs
+++ b/crates/paimon/src/vector_search.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub use crate::table::vector_search_result::{
+pub use crate::table::search_result::{
     PrimaryKeySearchPosition, SearchResult, SearchResultReadBuilder,
 };
 

Reply via email to