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 19922641 Support supplied global-index results in batch scans (#1133)
19922641 is described below

commit 1992264160c9305f871ff9e80ae3536caff73e71
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 22:17:57 2026 +0800

    Support supplied global-index results in batch scans (#1133)
---
 bindings/c/src/tests.rs                            |   2 +-
 bindings/c/src/vector_search.rs                    |   4 +-
 .../python/python/pypaimon_rust/datafusion.pyi     |  67 ++--
 bindings/python/src/context.rs                     |   5 +-
 bindings/python/src/full_text_search.rs            |  14 +-
 .../{search_result.rs => global_index_result.rs}   |  64 +++-
 bindings/python/src/hybrid_search.rs               |  28 +-
 bindings/python/src/lib.rs                         |   2 +-
 bindings/python/src/read.rs                        |  43 ++-
 bindings/python/src/vector_search.rs               |  18 +-
 bindings/python/tests/test_vector_search.py        |  19 +-
 .../integrations/datafusion/src/hybrid_search.rs   |   6 +-
 .../src/table/batch_vector_search_builder.rs       |   4 +-
 crates/paimon/src/table/de_vector_read.rs          |   6 +-
 crates/paimon/src/table/de_vector_read/tests.rs    |   2 +-
 crates/paimon/src/table/de_vector_scan/tests.rs    |   4 +-
 .../paimon/src/table/full_text_search_builder.rs   |   4 +-
 .../table/full_text_search_builder/parity_tests.rs |   4 +-
 .../src/table/full_text_search_builder/read.rs     |   9 +-
 crates/paimon/src/table/global_index_result.rs     | 416 +++++++++++++++++++++
 .../paimon/src/table/global_index_result/read.rs   | 100 +++++
 .../search_tests.rs}                               |   0
 .../paimon/src/table/global_index_result/tests.rs  | 375 +++++++++++++++++++
 crates/paimon/src/table/hybrid_search_builder.rs   |  27 +-
 .../table/hybrid_search_builder/parity_tests.rs    |  38 +-
 crates/paimon/src/table/incremental_scan.rs        |  24 +-
 crates/paimon/src/table/mod.rs                     |   6 +-
 crates/paimon/src/table/pk_full_text_read.rs       |  10 +-
 crates/paimon/src/table/pk_search_position.rs      |   2 +-
 .../src/table/pk_vector_indexed_split_read.rs      |   5 +-
 crates/paimon/src/table/pk_vector_read.rs          |   6 +-
 crates/paimon/src/table/pk_vector_read/tests.rs    |  20 +-
 crates/paimon/src/table/search_result.rs           | 293 ---------------
 crates/paimon/src/table/source.rs                  | 143 +++++--
 crates/paimon/src/table/table_scan.rs              | 137 ++++++-
 crates/paimon/src/table/vector_read.rs             |   8 +-
 crates/paimon/src/table/vector_search_builder.rs   |   4 +-
 .../src/table/vector_search_builder/tests.rs       |   2 +-
 crates/paimon/src/vector_search.rs                 |   6 +-
 crates/paimon/tests/pk_vector_batch_test.rs        |   2 +-
 40 files changed, 1439 insertions(+), 490 deletions(-)

diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index 6eb98979..8a8d535f 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -3118,7 +3118,7 @@ fn test_two_commits_same_builder() {
 //
 // Two storage shapes are exercised end-to-end through the C `execute_read`
 // terminal, each compared against an independent core Rust
-// `SearchResultReadBuilder::read()` reference:
+// `GlobalIndexResultReadBuilder::read()` reference:
 //
 //   * A primary-key vector table backed by a real vindex IVF-flat ANN segment
 //     built in-process (bucket-local ANN search, residual filter supported).
diff --git a/bindings/c/src/vector_search.rs b/bindings/c/src/vector_search.rs
index 31c362f0..f79d01d9 100644
--- a/bindings/c/src/vector_search.rs
+++ b/bindings/c/src/vector_search.rs
@@ -31,8 +31,8 @@ use std::collections::HashMap;
 use std::ffi::{c_char, c_void};
 
 use paimon::spec::Predicate;
+use paimon::table::GlobalIndexResult;
 use paimon::table::{ArrowRecordBatchStream, Table, VectorSearchBuilder};
-use paimon::vector_search::SearchResult;
 
 use crate::error::{check_non_null, paimon_error, validate_cstr, 
PaimonErrorCode};
 use crate::result::{
@@ -320,7 +320,7 @@ pub unsafe extern "C" fn 
paimon_vector_search_builder_execute_read(
 }
 
 pub(crate) async fn materialize_search_result(
-    result: SearchResult,
+    result: GlobalIndexResult,
     projection: Option<&[String]>,
 ) -> paimon::Result<ArrowRecordBatchStream> {
     let mut reader = result.new_read_builder();
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 1edff10e..5b132e81 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -56,8 +56,8 @@ class Split:
         ...
     def row_count(self) -> int: ...
     def is_streaming(self) -> bool: ...
-    # Java SplitSerializer v1 binary: DataSplit v8/v9, or score-free
-    # IndexedSplit when the native split has row ranges.
+    # Java SplitSerializer v1 binary: DataSplit v8/v9, or IndexedSplit with
+    # selected row ranges and optional aligned scores.
     def serialize(self) -> bytes: ...
 
 class Plan:
@@ -67,7 +67,32 @@ class Plan:
     def splits(self) -> List[Split]: ...
     def __len__(self) -> int: ...
 
+class GlobalIndexResult:
+    """Index selection shared by table searches and scans; PK selections 
retain their snapshot."""
+    @staticmethod
+    def from_splits(snapshot_id: Optional[int], splits: Iterable[Split]) -> 
"GlobalIndexResult": ...
+    @staticmethod
+    def from_row_ranges(ranges: List[Tuple[int, int]]) -> "GlobalIndexResult": 
...
+    @staticmethod
+    def from_scored_row_ids(scores: Dict[int, float]) -> "GlobalIndexResult": 
...
+
+    def snapshot_id(self) -> Optional[int]: ...
+    def __len__(self) -> int: ...
+    def new_read_builder(self) -> "GlobalIndexResultReadBuilder": ...
+    def row_ids(self) -> Dict[int, float]:
+        """DE scores keyed by global row ID; rejects PK results, including 
empty ones."""
+        ...
+    def positions(self) -> List[Tuple[bytes, int, str, int, float]]:
+        """PK partition bytes, bucket, file, position and score in relevance 
order."""
+        ...
+    def splits(self) -> List[bytes]:
+        """PK physical selections in Java SplitSerializer v1 format, including 
scores."""
+        ...
+
 class TableScan:
+    def with_global_index_result(self, result: GlobalIndexResult) -> 
"TableScan":
+        """Reuse an index selection. Authorized scans ignore supplied index 
selections."""
+        ...
     def with_row_position_slice(self, start: int, end: int) -> "TableScan": ...
     def with_row_position_shard(self, index: int, count: int) -> "TableScan": 
...
     def with_chunk_shuffle(self, seed: str, chunk_size: int) -> "TableScan":
@@ -731,7 +756,7 @@ class VectorSearchBuilder:
         ...
     def new_vector_search_scan(self) -> "VectorScan": ...
     def new_vector_search_read(self) -> "VectorRead": ...
-    def execute_local(self) -> "SearchResult": ...
+    def execute_local(self) -> "GlobalIndexResult": ...
 
 class BatchVectorSearchBuilder:
     def with_vector_column(self, name: str) -> "BatchVectorSearchBuilder": ...
@@ -743,7 +768,7 @@ class BatchVectorSearchBuilder:
     def with_partition_filter(self, predicate: Dict[str, Any]) -> 
"BatchVectorSearchBuilder": ...
     def new_vector_search_scan(self) -> "VectorScan": ...
     def new_batch_vector_search_read(self) -> "BatchVectorRead": ...
-    def execute_batch_local(self) -> List["SearchResult"]: ...
+    def execute_batch_local(self) -> List["GlobalIndexResult"]: ...
 
 class VectorScan:
     def scan(self) -> "VectorScanPlan": ...
@@ -752,29 +777,13 @@ class VectorScanPlan:
     def snapshot_id(self) -> Optional[int]: ...
 
 class VectorRead:
-    def read_plan(self, plan: VectorScanPlan) -> "SearchResult": ...
+    def read_plan(self, plan: VectorScanPlan) -> "GlobalIndexResult": ...
 
 class BatchVectorRead:
-    def read_batch_plan(self, plan: VectorScanPlan) -> List["SearchResult"]: 
...
-
-class SearchResult:
-    """Snapshot-scoped global row IDs or primary-key physical positions."""
-    def snapshot_id(self) -> Optional[int]: ...
-    def __len__(self) -> int: ...
-    def new_read_builder(self) -> "SearchResultReadBuilder": ...
-    def row_ids(self) -> Dict[int, float]:
-        """DE scores keyed by global row ID; rejects PK results, including 
empty ones."""
-        ...
-    def positions(self) -> List[Tuple[bytes, int, str, int, float]]:
-        """PK partition bytes, bucket, file, position and score in relevance 
order."""
-        ...
-    def splits(self) -> List[bytes]:
-        """PK physical selections in Java SplitSerializer v1 format, including 
scores."""
-        ...
-
+    def read_batch_plan(self, plan: VectorScanPlan) -> 
List["GlobalIndexResult"]: ...
 
-class SearchResultReadBuilder:
-    def with_projection(self, columns: List[str]) -> 
"SearchResultReadBuilder": ...
+class GlobalIndexResultReadBuilder:
+    def with_projection(self, columns: List[str]) -> 
"GlobalIndexResultReadBuilder": ...
     def read(self) -> List[pyarrow.RecordBatch]:
         """Read user columns and scores in relevance order from the search 
snapshot."""
         ...
@@ -793,11 +802,7 @@ class HybridSearchBuilder:
                          weight: float = 1.0, options: Optional[Dict[str, 
str]] = None) -> HybridSearchBuilder: ...
     def add_full_text_route(self, field_name: str, query: str, limit: int,
                             weight: float = 1.0, options: Optional[Dict[str, 
str]] = None) -> HybridSearchBuilder: ...
-    def execute_local(self) -> HybridSearchResult: ...
-
-class HybridSearchResult:
-    def __len__(self) -> int: ...
-    def row_ids(self) -> Dict[int, float]: ...
+    def execute_local(self) -> GlobalIndexResult: ...
 
 class FullTextSearchBuilder:
     def with_query(self, field_name: str, query: str) -> 
FullTextSearchBuilder: ...
@@ -806,7 +811,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) -> SearchResult: ...
+    def execute_local(self) -> GlobalIndexResult: ...
 
 class FullTextScan:
     def scan(self) -> FullTextScanPlan: ...
@@ -815,7 +820,7 @@ class FullTextScanPlan:
     def snapshot_id(self) -> Optional[int]: ...
 
 class FullTextRead:
-    def read_plan(self, plan: FullTextScanPlan) -> SearchResult: ...
+    def read_plan(self, plan: FullTextScanPlan) -> GlobalIndexResult: ...
 
 class ShardTableUpdator:
     def arrow_reader(self) -> RecordBatchReader:
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 1c29b456..d312192a 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -802,15 +802,14 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::full_text_search::PyFullTextScanPlan>()?;
     this.add_class::<crate::full_text_search::PyFullTextRead>()?;
     this.add_class::<crate::hybrid_search::PyHybridSearchBuilder>()?;
-    this.add_class::<crate::hybrid_search::PyHybridSearchResult>()?;
     this.add_class::<crate::vector_search::PyVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyBatchVectorSearchBuilder>()?;
     this.add_class::<crate::vector_search::PyVectorScan>()?;
     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::search_result::PySearchResult>()?;
-    this.add_class::<crate::search_result::PySearchResultReadBuilder>()?;
+    this.add_class::<crate::global_index_result::PyGlobalIndexResult>()?;
+    
this.add_class::<crate::global_index_result::PyGlobalIndexResultReadBuilder>()?;
     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 ca364d1a..a3fc6a9b 100644
--- a/bindings/python/src/full_text_search.rs
+++ b/bindings/python/src/full_text_search.rs
@@ -18,8 +18,8 @@
 //! Thin bindings over core full-text Scan -> Plan -> Read.
 
 use crate::error::to_py_err;
+use crate::global_index_result::PyGlobalIndexResult;
 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;
@@ -123,10 +123,10 @@ impl PyFullTextSearchBuilder {
                 .map_err(to_py_err)?,
         })
     }
-    fn execute_local(&self, py: Python<'_>) -> PyResult<PySearchResult> {
+    fn execute_local(&self, py: Python<'_>) -> PyResult<PyGlobalIndexResult> {
         let builder = self.config.builder(&self.table).map_err(to_py_err)?;
         py.detach(|| runtime().block_on(builder.execute_scored()))
-            .map(PySearchResult::new)
+            .map(PyGlobalIndexResult::new)
             .map_err(to_py_err)
     }
 }
@@ -161,9 +161,13 @@ pub struct PyFullTextRead {
 }
 #[pymethods]
 impl PyFullTextRead {
-    fn read_plan(&self, py: Python<'_>, plan: &PyFullTextScanPlan) -> 
PyResult<PySearchResult> {
+    fn read_plan(
+        &self,
+        py: Python<'_>,
+        plan: &PyFullTextScanPlan,
+    ) -> PyResult<PyGlobalIndexResult> {
         py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
-            .map(PySearchResult::new)
+            .map(PyGlobalIndexResult::new)
             .map_err(to_py_err)
     }
 }
diff --git a/bindings/python/src/search_result.rs 
b/bindings/python/src/global_index_result.rs
similarity index 66%
rename from bindings/python/src/search_result.rs
rename to bindings/python/src/global_index_result.rs
index 2e889e30..df8f3218 100644
--- a/bindings/python/src/search_result.rs
+++ b/bindings/python/src/global_index_result.rs
@@ -16,21 +16,24 @@
 // under the License.
 
 use crate::error::to_py_err;
+use crate::read::extract_splits;
 use arrow::pyarrow::ToPyArrow;
 use futures::TryStreamExt;
-use paimon::table::SearchResult;
+use paimon::table::GlobalIndexResult;
+use paimon::table::RowRange;
 use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::PyValueError;
 use pyo3::prelude::*;
 use pyo3::types::PyBytes;
 use std::collections::HashMap;
 
-#[pyclass(name = "SearchResult", module = "pypaimon_rust.datafusion")]
-pub struct PySearchResult {
-    inner: SearchResult,
+#[pyclass(name = "GlobalIndexResult", module = "pypaimon_rust.datafusion")]
+pub struct PyGlobalIndexResult {
+    pub(crate) inner: GlobalIndexResult,
 }
 
-impl PySearchResult {
-    pub(crate) fn new(inner: SearchResult) -> Self {
+impl PyGlobalIndexResult {
+    pub(crate) fn new(inner: GlobalIndexResult) -> Self {
         Self { inner }
     }
 }
@@ -38,12 +41,44 @@ impl PySearchResult {
 type PySearchPosition = (Py<PyBytes>, i32, String, i64, f32);
 
 #[pymethods]
-impl PySearchResult {
+impl PyGlobalIndexResult {
+    #[staticmethod]
+    fn from_splits(snapshot_id: Option<i64>, splits: &Bound<'_, PyAny>) -> 
PyResult<Self> {
+        Ok(Self {
+            inner: GlobalIndexResult::from_splits(snapshot_id, 
extract_splits(splits)?),
+        })
+    }
+
+    #[staticmethod]
+    fn from_row_ranges(ranges: Vec<(i64, i64)>) -> PyResult<Self> {
+        let ranges = ranges
+            .into_iter()
+            .map(|(from, to)| {
+                if from > to {
+                    Err(PyValueError::new_err("row range start must be <= 
end"))
+                } else {
+                    Ok(RowRange::new(from, to))
+                }
+            })
+            .collect::<PyResult<Vec<_>>>()?;
+        Ok(Self {
+            inner: GlobalIndexResult::from_row_ranges(ranges),
+        })
+    }
+
+    #[staticmethod]
+    fn from_scored_row_ids(scores: HashMap<u64, f32>) -> Self {
+        Self {
+            inner: GlobalIndexResult::from_scored_row_ids(
+                paimon::vector_search::ScoredRowIds::from_scored_map(scores),
+            ),
+        }
+    }
     fn snapshot_id(&self) -> Option<i64> {
         self.inner.snapshot_id()
     }
-    fn new_read_builder(&self) -> PySearchResultReadBuilder {
-        PySearchResultReadBuilder {
+    fn new_read_builder(&self) -> PyGlobalIndexResultReadBuilder {
+        PyGlobalIndexResultReadBuilder {
             result: self.inner.clone(),
             projection: None,
         }
@@ -88,14 +123,17 @@ impl PySearchResult {
     }
 }
 
-#[pyclass(name = "SearchResultReadBuilder", module = 
"pypaimon_rust.datafusion")]
-pub struct PySearchResultReadBuilder {
-    result: SearchResult,
+#[pyclass(
+    name = "GlobalIndexResultReadBuilder",
+    module = "pypaimon_rust.datafusion"
+)]
+pub struct PyGlobalIndexResultReadBuilder {
+    result: GlobalIndexResult,
     projection: Option<Vec<String>>,
 }
 
 #[pymethods]
-impl PySearchResultReadBuilder {
+impl PyGlobalIndexResultReadBuilder {
     fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) -> 
PyRefMut<'_, Self> {
         slf.projection = Some(columns);
         slf
diff --git a/bindings/python/src/hybrid_search.rs 
b/bindings/python/src/hybrid_search.rs
index 749bdd93..0370b585 100644
--- a/bindings/python/src/hybrid_search.rs
+++ b/bindings/python/src/hybrid_search.rs
@@ -18,13 +18,14 @@
 //! Hybrid configuration forwarding; snapshot, routes and fusion belong to 
core.
 
 use crate::error::to_py_err;
+use crate::global_index_result::PyGlobalIndexResult;
 use crate::predicate::dict_to_table_predicate;
 use paimon::spec::{Predicate, Snapshot};
 use paimon::table::{HybridSearchRanker, HybridSearchRoute, Table};
-use paimon::vector_search::ScoredRowIds;
 use paimon_datafusion::runtime::runtime;
 use pyo3::prelude::*;
 use pyo3::types::PyDict;
+
 use std::collections::HashMap;
 use std::sync::Arc;
 
@@ -176,31 +177,10 @@ impl PyHybridSearchBuilder {
         Ok(slf)
     }
 
-    fn execute_local(&self, py: Python<'_>) -> PyResult<PyHybridSearchResult> {
+    fn execute_local(&self, py: Python<'_>) -> PyResult<PyGlobalIndexResult> {
         let builder = self.builder();
         py.detach(|| runtime().block_on(builder.execute_scored()))
-            .map(|inner| PyHybridSearchResult { inner })
+            .map(PyGlobalIndexResult::new)
             .map_err(to_py_err)
     }
 }
-
-#[pyclass(name = "HybridSearchResult", module = "pypaimon_rust.datafusion")]
-pub struct PyHybridSearchResult {
-    inner: ScoredRowIds,
-}
-
-#[pymethods]
-impl PyHybridSearchResult {
-    fn __len__(&self) -> usize {
-        self.inner.len()
-    }
-
-    fn row_ids(&self) -> HashMap<u64, f32> {
-        self.inner
-            .row_ids
-            .iter()
-            .copied()
-            .zip(self.inner.scores.iter().copied())
-            .collect()
-    }
-}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index c55aa5bd..4ae51342 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -24,6 +24,7 @@ mod context;
 mod error;
 mod evolution;
 mod full_text_search;
+mod global_index_result;
 mod hybrid_search;
 mod index_build;
 mod merge_into;
@@ -31,7 +32,6 @@ 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/read.rs b/bindings/python/src/read.rs
index fc2ae2fc..8b7c167b 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::global_index_result::PyGlobalIndexResult;
 use std::collections::HashMap;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex};
@@ -26,7 +27,8 @@ use futures::stream::BoxStream;
 use futures::TryStreamExt;
 use paimon::spec::{is_variant_extraction_row, DataField, DataType, Predicate, 
RowType};
 use paimon::table::{
-    ArrowRecordBatchStream, DataSplit, IncrementalScanMode, RowRange, 
StreamTableScan, Table,
+    ArrowRecordBatchStream, DataSplit, GlobalIndexResult, IncrementalScanMode, 
RowRange,
+    StreamTableScan, Table,
 };
 use paimon_datafusion::runtime::runtime;
 use pyo3::exceptions::{PyBaseException, PyRuntimeError, PyTypeError, 
PyValueError};
@@ -105,7 +107,7 @@ fn apply_read_config(
 /// any iterable (list/tuple/generator). Runs under the GIL since it touches
 /// Python objects. A non-iterable argument or a non-`Split` element raises
 /// `TypeError`.
-fn extract_splits(splits: &Bound<'_, PyAny>) -> PyResult<Vec<DataSplit>> {
+pub(crate) fn extract_splits(splits: &Bound<'_, PyAny>) -> 
PyResult<Vec<DataSplit>> {
     let iter = splits
         .try_iter()
         .map_err(|_| PyTypeError::new_err("read() expects a sequence of Split 
objects"))?;
@@ -344,6 +346,7 @@ impl PyReadBuilder {
             row_ranges: self.row_ranges.clone(),
             case_sensitive: self.case_sensitive,
             incremental_scan: None,
+            global_index_result: None,
             row_position_slice: None,
             row_position_shard: None,
             chunk_shuffle: None,
@@ -407,6 +410,7 @@ pub struct PyTableScan {
     row_ranges: Option<Vec<RowRange>>,
     case_sensitive: bool,
     incremental_scan: Option<PyIncrementalScan>,
+    global_index_result: Option<GlobalIndexResult>,
     row_position_slice: Option<(u64, u64)>,
     row_position_shard: Option<(u64, u64)>,
     chunk_shuffle: Option<PyChunkShuffle>,
@@ -443,6 +447,11 @@ fn parse_incremental_scan_mode(mode: &str) -> 
PyResult<IncrementalScanMode> {
 impl PyTableScan {
     fn core_scan(&self) -> PyResult<paimon::table::TableScan<'_>> {
         let mut scan = self.read_builder()?.new_scan();
+        if let Some(result) = &self.global_index_result {
+            scan = scan
+                .with_global_index_result(result.clone())
+                .map_err(to_py_err)?;
+        }
         if let Some((start, end)) = self.row_position_slice {
             scan = scan
                 .with_row_position_slice(start, end)
@@ -471,6 +480,11 @@ impl PyTableScan {
         mode: IncrementalScanMode,
     ) -> PyResult<paimon::table::IncrementalScan<'_>> {
         let mut scan = self.read_builder()?.new_incremental_scan(mode, start, 
end);
+        if let Some(result) = &self.global_index_result {
+            scan = scan
+                .with_global_index_result(result.clone())
+                .map_err(to_py_err)?;
+        }
         if let Some((start, end)) = self.row_position_slice {
             scan = scan
                 .with_row_position_slice(start, end)
@@ -645,6 +659,29 @@ impl PyStreamTableScan {
 
 #[pymethods]
 impl PyTableScan {
+    /// Supply an evaluated selection; validation and authorization stay in 
core.
+    fn with_global_index_result<'py>(
+        mut slf: PyRefMut<'py, Self>,
+        result: PyRef<'py, PyGlobalIndexResult>,
+    ) -> PyResult<PyRefMut<'py, Self>> {
+        let result = result.inner.clone();
+        if let Some(incremental) = slf.incremental_scan {
+            slf.core_incremental_scan(
+                incremental.start_snapshot_id,
+                incremental.end_snapshot_id,
+                incremental.mode,
+            )?
+            .with_global_index_result(result.clone())
+            .map_err(to_py_err)?;
+        } else {
+            slf.core_scan()?
+                .with_global_index_result(result.clone())
+                .map_err(to_py_err)?;
+        }
+        slf.global_index_result = Some(result);
+        Ok(slf)
+    }
+
     /// Select a half-open range of append-table row positions.
     fn with_row_position_slice(
         mut slf: PyRefMut<'_, Self>,
@@ -972,7 +1009,7 @@ impl PySplit {
     ///
     /// This is deliberately separate from the constructor used by pickle:
     /// pickle bytes are an opaque Rust JSON encoding, while this method 
accepts
-    /// Java/Python-compatible DataSplit and score-free IndexedSplit frames.
+    /// Java/Python-compatible DataSplit and IndexedSplit frames, including 
scores.
     #[staticmethod]
     fn deserialize(state: &Bound<'_, PyBytes>) -> PyResult<Self> {
         Ok(Self {
diff --git a/bindings/python/src/vector_search.rs 
b/bindings/python/src/vector_search.rs
index 540c579d..4c1173f2 100644
--- a/bindings/python/src/vector_search.rs
+++ b/bindings/python/src/vector_search.rs
@@ -25,8 +25,8 @@ use pyo3::prelude::*;
 use pyo3::types::PyDict;
 
 use crate::error::to_py_err;
+use crate::global_index_result::PyGlobalIndexResult;
 use crate::predicate::dict_to_table_predicate;
-use crate::search_result::PySearchResult;
 
 #[derive(Clone, Default)]
 struct SearchConfig {
@@ -165,7 +165,7 @@ impl PyVectorSearchBuilder {
         let inner = builder.new_read().map_err(to_py_err)?;
         Ok(PyVectorRead { inner })
     }
-    fn execute_local(&self, py: Python<'_>) -> PyResult<PySearchResult> {
+    fn execute_local(&self, py: Python<'_>) -> PyResult<PyGlobalIndexResult> {
         let mut builder = self.config.single(&self.table).map_err(to_py_err)?;
         if let Some(query) = &self.query {
             builder.with_query_vector(query.clone());
@@ -173,7 +173,7 @@ impl PyVectorSearchBuilder {
         let results = py
             .detach(|| runtime().block_on(builder.execute()))
             .map_err(to_py_err)?;
-        Ok(PySearchResult::new(results))
+        Ok(PyGlobalIndexResult::new(results))
     }
 }
 
@@ -260,7 +260,7 @@ impl PyBatchVectorSearchBuilder {
         let inner = builder.new_read().map_err(to_py_err)?;
         Ok(PyBatchVectorRead { inner })
     }
-    fn execute_batch_local(&self, py: Python<'_>) -> 
PyResult<Vec<PySearchResult>> {
+    fn execute_batch_local(&self, py: Python<'_>) -> 
PyResult<Vec<PyGlobalIndexResult>> {
         let mut builder = self.config.batch(&self.table).map_err(to_py_err)?;
         if let Some(query) = &self.query {
             builder.with_query_vectors(query.clone());
@@ -268,7 +268,7 @@ impl PyBatchVectorSearchBuilder {
         let results = py
             .detach(|| runtime().block_on(builder.execute()))
             .map_err(to_py_err)?;
-        Ok(results.into_iter().map(PySearchResult::new).collect())
+        Ok(results.into_iter().map(PyGlobalIndexResult::new).collect())
     }
 }
 
@@ -306,9 +306,9 @@ pub struct PyVectorRead {
 
 #[pymethods]
 impl PyVectorRead {
-    fn read_plan(&self, py: Python<'_>, plan: &PyVectorScanPlan) -> 
PyResult<PySearchResult> {
+    fn read_plan(&self, py: Python<'_>, plan: &PyVectorScanPlan) -> 
PyResult<PyGlobalIndexResult> {
         py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
-            .map(PySearchResult::new)
+            .map(PyGlobalIndexResult::new)
             .map_err(to_py_err)
     }
 }
@@ -324,9 +324,9 @@ impl PyBatchVectorRead {
         &self,
         py: Python<'_>,
         plan: &PyVectorScanPlan,
-    ) -> PyResult<Vec<PySearchResult>> {
+    ) -> PyResult<Vec<PyGlobalIndexResult>> {
         py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
-            .map(|results| 
results.into_iter().map(PySearchResult::new).collect())
+            .map(|results| 
results.into_iter().map(PyGlobalIndexResult::new).collect())
             .map_err(to_py_err)
     }
 }
diff --git a/bindings/python/tests/test_vector_search.py 
b/bindings/python/tests/test_vector_search.py
index f167b6df..321ee277 100644
--- a/bindings/python/tests/test_vector_search.py
+++ b/bindings/python/tests/test_vector_search.py
@@ -18,7 +18,7 @@
 import pyarrow as pa
 import pytest
 
-from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+from pypaimon_rust.datafusion import GlobalIndexResult, PaimonCatalog, 
SQLContext
 
 
 @pytest.fixture
@@ -57,6 +57,7 @@ def 
test_filters_accumulate_and_partition_uses_table_schema(table):
               .with_filter({'method': 'lessThan', 'field': 'id', 'literals': 
[4]})
               .with_partition_filter({'method': 'equal', 'field': 'pt', 
'literals': [1]})
               .execute_local())
+    assert isinstance(result, GlobalIndexResult)
     assert len(result) == 1
     assert list(result.row_ids().values()) == [1.0]
     assert result.snapshot_id() == 1
@@ -104,6 +105,7 @@ def test_batch_order_empty_results_and_scan_reuse(table):
                .with_query_vectors(queries).with_limit(1))
     plan = builder.new_vector_search_scan().scan()
     results = builder.new_batch_vector_search_read().read_batch_plan(plan)
+    assert all(isinstance(r, GlobalIndexResult) for r in results)
     assert [r.row_ids() for r in results] == [
         
_single(table).with_query_vector(q).with_limit(1).execute_local().row_ids() for 
q in queries]
     assert [r.row_ids() for r in builder.execute_batch_local()] == 
[r.row_ids() for r in results]
@@ -125,3 +127,18 @@ def test_invalid_query_is_reported_before_planning(table, 
query):
 def test_single_vector_keyword_matches_stub(table):
     builder = _single(table).with_query_vector(vector=[1, 0]).with_limit(1)
     assert builder.execute_local().row_ids() == 
_single(table).with_limit(1).execute_local().row_ids()
+
+
+def test_external_result_uses_same_scan_type_without_search_context(table):
+    found = _single(table).with_limit(1).execute_local()
+    external = GlobalIndexResult.from_scored_row_ids(found.row_ids())
+    builder = table.new_read_builder().with_projection(['id'])
+    plan = builder.new_scan().with_global_index_result(external).plan()
+    expected = 
pa.Table.from_batches(found.new_read_builder().with_projection(['id']).read())
+    actual = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+    assert actual.column('id').to_pylist() == expected.column('id').to_pylist()
+    assert external.row_ids() == found.row_ids()
+    with pytest.raises(Exception, match='require a result returned by a table 
search'):
+        external.new_read_builder().read()
+    with pytest.raises(TypeError):
+        builder.new_scan().with_global_index_result(found.row_ids())
diff --git a/crates/integrations/datafusion/src/hybrid_search.rs 
b/crates/integrations/datafusion/src/hybrid_search.rs
index 76a2889a..fd8cdddd 100644
--- a/crates/integrations/datafusion/src/hybrid_search.rs
+++ b/crates/integrations/datafusion/src/hybrid_search.rs
@@ -226,7 +226,11 @@ impl TableProvider for HybridSearchTableProvider {
                 .with_limit(self.limit)
                 .with_ranker(&self.ranker)
                 .map_err(to_datafusion_error)?;
-            builder.execute_scored().await.map_err(to_datafusion_error)
+            builder
+                .execute_scored()
+                .await
+                .and_then(|result| result.into_row_ids())
+                .map_err(to_datafusion_error)
         })
         .await?;
 
diff --git a/crates/paimon/src/table/batch_vector_search_builder.rs 
b/crates/paimon/src/table/batch_vector_search_builder.rs
index 68e83c5d..2d42bc40 100644
--- a/crates/paimon/src/table/batch_vector_search_builder.rs
+++ b/crates/paimon/src/table/batch_vector_search_builder.rs
@@ -23,7 +23,7 @@ use crate::table::vector_read::BatchVectorRead;
 use crate::table::vector_scan::{PlanContext, VectorScan};
 use crate::table::vector_search_common::{add_filter, 
validate_partition_filter};
 use crate::table::Table;
-use crate::vector_search::SearchResult;
+use crate::vector_search::GlobalIndexResult;
 use roaring::RoaringTreemap;
 use std::collections::HashMap;
 use std::sync::Arc;
@@ -157,7 +157,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
     }
 
     /// Search every query against one plan, including empty per-query results.
-    pub async fn execute(&self) -> crate::Result<Vec<SearchResult>> {
+    pub async fn execute(&self) -> crate::Result<Vec<GlobalIndexResult>> {
         let read = self.new_read()?;
         read.read(self.new_scan()?.plan().await?).await
     }
diff --git a/crates/paimon/src/table/de_vector_read.rs 
b/crates/paimon/src/table/de_vector_read.rs
index ae72aa1c..6de0f5f3 100644
--- a/crates/paimon/src/table/de_vector_read.rs
+++ b/crates/paimon/src/table/de_vector_read.rs
@@ -42,7 +42,7 @@ use crate::table::vector_search_common::{
 use crate::table::{
     find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange, 
Table,
 };
-use crate::vector_search::{GlobalIndexIOMeta, ScoredRowIds, SearchResult, 
VectorSearch};
+use crate::vector_search::{GlobalIndexIOMeta, GlobalIndexResult, ScoredRowIds, 
VectorSearch};
 use crate::vindex::executor::{
     acquire_process_global_search_permit, drain_indexed_jobs,
     ensure_global_index_executor_capacity, execute_global_index_with_guard,
@@ -185,7 +185,7 @@ pub(super) async fn materialize_row_ids(
 impl Read for DeVectorRead {
     type Plan = DeVectorScanPlan;
 
-    async fn read(&self, plan: DeVectorScanPlan) -> 
crate::Result<Vec<SearchResult>> {
+    async fn read(&self, plan: DeVectorScanPlan) -> 
crate::Result<Vec<GlobalIndexResult>> {
         let DeVectorScanPlan {
             table,
             index_entries,
@@ -201,7 +201,7 @@ impl Read for DeVectorRead {
                 .into_iter()
                 .zip(&self.vector_searches)
                 .map(|(hits, query)| {
-                    SearchResult::from_row_ids(table.clone(), 
query.field_name.clone(), hits)
+                    GlobalIndexResult::from_row_ids(table.clone(), 
query.field_name.clone(), hits)
                 })
                 .collect()
         };
diff --git a/crates/paimon/src/table/de_vector_read/tests.rs 
b/crates/paimon/src/table/de_vector_read/tests.rs
index 0d76e309..293499f7 100644
--- a/crates/paimon/src/table/de_vector_read/tests.rs
+++ b/crates/paimon/src/table/de_vector_read/tests.rs
@@ -95,7 +95,7 @@ fn make_lumina_entry(
     }
 }
 
-// ---- Task B: search-and-read (`SearchResultReadBuilder::read`) tests ----
+// ---- Task B: search-and-read (`GlobalIndexResultReadBuilder::read`) tests 
----
 
 /// Build a small materialization batch: user column `id: Int32`, the internal
 /// `_PKEY_VECTOR_POSITION: Int64`, and `__paimon_search_score: Float32` 
(mirroring
diff --git a/crates/paimon/src/table/de_vector_scan/tests.rs 
b/crates/paimon/src/table/de_vector_scan/tests.rs
index dc35c06f..0e16c045 100644
--- a/crates/paimon/src/table/de_vector_scan/tests.rs
+++ b/crates/paimon/src/table/de_vector_scan/tests.rs
@@ -23,7 +23,7 @@ use crate::table::vector_search_test_utils::{
     de_vector_table, id_gt_filter, vector_test_table, vector_test_table_at,
 };
 use crate::table::{TableCommit, TableWrite};
-use crate::vector_search::SearchResult;
+use crate::vector_search::GlobalIndexResult;
 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};
@@ -341,7 +341,7 @@ async fn 
de_scalar_filter_with_no_matching_rows_returns_empty() {
         .await
         .unwrap();
     assert_eq!(results.len(), 2);
-    assert!(results.iter().all(SearchResult::is_empty));
+    assert!(results.iter().all(GlobalIndexResult::is_empty));
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/table/full_text_search_builder.rs 
b/crates/paimon/src/table/full_text_search_builder.rs
index 11d85bfb..4b7e4c79 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -126,8 +126,8 @@ impl<'a> FullTextSearchBuilder<'a> {
 
     /// 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> {
+    /// [`crate::table::GlobalIndexResult::new_read_builder`].
+    pub async fn execute_scored(&self) -> 
crate::Result<crate::table::GlobalIndexResult> {
         let read = self.new_read()?;
         read.read(self.new_scan()?.scan().await?).await
     }
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 5992e8dc..d3788285 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,7 +135,7 @@ fn query(table: &Table) -> FullTextSearchBuilder<'_> {
     builder
 }
 
-fn ids(result: crate::table::SearchResult) -> Vec<u64> {
+fn ids(result: crate::table::GlobalIndexResult) -> Vec<u64> {
     let mut ids = result.into_row_ids().unwrap().row_ids;
     ids.sort_unstable();
     ids
@@ -382,7 +382,7 @@ async fn 
empty_string_filters_preserve_java_search_refinement() {
     }
 }
 
-async fn user_ids(table: &Table, result: crate::table::SearchResult) -> 
Vec<i32> {
+async fn user_ids(table: &Table, result: crate::table::GlobalIndexResult) -> 
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();
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 934affe9..6cee3304 100644
--- a/crates/paimon/src/table/full_text_search_builder/read.rs
+++ b/crates/paimon/src/table/full_text_search_builder/read.rs
@@ -81,7 +81,10 @@ impl FullTextRead {
         })
     }
 
-    pub async fn read(&self, plan: FullTextScanPlan) -> 
crate::Result<crate::table::SearchResult> {
+    pub async fn read(
+        &self,
+        plan: FullTextScanPlan,
+    ) -> crate::Result<crate::table::GlobalIndexResult> {
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
         
CoreOptions::new(plan.table.schema().options()).ensure_read_authorized()?;
         if self.context != plan.context {
@@ -118,7 +121,7 @@ impl FullTextRead {
             let result = read
                 .search_route(source, &self.search.query_text, 
self.search.limit)
                 .await?;
-            return crate::table::SearchResult::from_full_text_primary_key(
+            return crate::table::GlobalIndexResult::from_full_text_primary_key(
                 table,
                 result.snapshot_id,
                 result.candidates,
@@ -142,7 +145,7 @@ impl FullTextRead {
             &search,
         )
         .await?;
-        Ok(crate::table::SearchResult::from_full_text_row_ids(
+        Ok(crate::table::GlobalIndexResult::from_global_search(
             table,
             crate::vector_search::ScoredRowIds::new(hits.row_ids, hits.scores),
         ))
diff --git a/crates/paimon/src/table/global_index_result.rs 
b/crates/paimon/src/table/global_index_result.rs
new file mode 100644
index 00000000..914b09a3
--- /dev/null
+++ b/crates/paimon/src/table/global_index_result.rs
@@ -0,0 +1,416 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Global-index selections shared by searches and table scans.
+
+use super::{merge_row_ranges, DataSplit, Plan, RowRange, Table};
+use crate::spec::BinaryRow;
+use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplit;
+use crate::table::pk_vector_orchestrator::{
+    build_indexed_splits, PkVectorCandidate, PkVectorSearchSplit,
+};
+use crate::vector_search::ScoredRowIds;
+use crate::vindex::pkvector::metric::VectorSearchMetric;
+use std::sync::Arc;
+
+mod read;
+pub use read::GlobalIndexResultReadBuilder;
+
+/// One scored physical row in a primary-key table, in relevance order.
+#[derive(Debug, Clone)]
+pub struct PrimaryKeySearchPosition {
+    pub partition: BinaryRow,
+    pub bucket: i32,
+    pub data_file_name: String,
+    pub row_position: i64,
+    pub score: f32,
+}
+
+#[derive(Debug, Clone)]
+enum GlobalIndexSelection {
+    RowRanges(Vec<RowRange>),
+    ScoredRowIds(ScoredRowIds),
+    Splits(Vec<DataSplit>),
+}
+
+/// Context retained only by table searches, for ranked reads without 
re-searching.
+#[derive(Debug, Clone)]
+struct IndexReadContext {
+    table: Arc<Table>,
+    vector_column: Option<String>,
+    positions: Option<Vec<PrimaryKeySearchPosition>>,
+}
+
+/// An evaluated global-index selection, accepted directly by table scans.
+///
+/// Data Evolution results address stable global row IDs. Primary-key results
+/// retain physical splits and their source snapshot, as in Java's
+/// `GlobalIndexSplitResult`. Table searches also retain the context needed by
+/// [`new_read_builder`](Self::new_read_builder) to read rows in relevance 
order.
+#[derive(Debug, Clone)]
+pub struct GlobalIndexResult {
+    selection: GlobalIndexSelection,
+    snapshot_id: Option<i64>,
+    read_context: Option<IndexReadContext>,
+}
+
+impl GlobalIndexResult {
+    pub fn from_row_ranges(ranges: Vec<RowRange>) -> Self {
+        Self {
+            selection: 
GlobalIndexSelection::RowRanges(merge_row_ranges(ranges)),
+            snapshot_id: None,
+            read_context: None,
+        }
+    }
+
+    pub fn from_scored_row_ids(hits: ScoredRowIds) -> Self {
+        Self {
+            selection: GlobalIndexSelection::ScoredRowIds(hits),
+            snapshot_id: None,
+            read_context: None,
+        }
+    }
+
+    pub fn from_splits(snapshot_id: Option<i64>, splits: Vec<DataSplit>) -> 
Self {
+        Self {
+            selection: GlobalIndexSelection::Splits(splits),
+            snapshot_id,
+            read_context: None,
+        }
+    }
+
+    pub(super) fn from_row_ids(
+        table: Arc<Table>,
+        vector_column: String,
+        hits: ScoredRowIds,
+    ) -> Self {
+        let snapshot_id = table.travel_snapshot().map(|snapshot| 
snapshot.id());
+        Self {
+            snapshot_id,
+            selection: GlobalIndexSelection::ScoredRowIds(hits),
+            read_context: Some(IndexReadContext {
+                table,
+                vector_column: Some(vector_column),
+                positions: None,
+            }),
+        }
+    }
+
+    pub(super) fn from_global_search(table: Arc<Table>, hits: ScoredRowIds) -> 
Self {
+        let snapshot_id = table.travel_snapshot().map(|snapshot| 
snapshot.id());
+        Self {
+            snapshot_id,
+            selection: GlobalIndexSelection::ScoredRowIds(hits),
+            read_context: Some(IndexReadContext {
+                table,
+                vector_column: None,
+                positions: None,
+            }),
+        }
+    }
+
+    #[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)?;
+        Self::from_indexed_splits(table, snapshot_id, positions, splits)
+    }
+
+    pub(super) fn from_primary_key(
+        table: Arc<Table>,
+        snapshot_id: i64,
+        candidates: Vec<PkVectorCandidate>,
+        source_splits: &[PkVectorSearchSplit],
+        metric: VectorSearchMetric,
+    ) -> 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: metric.distance_to_score(c.distance),
+            })
+            .collect();
+        let splits = build_indexed_splits(candidates, source_splits, metric)?;
+        Self::from_indexed_splits(table, snapshot_id, positions, splits)
+    }
+
+    fn from_indexed_splits(
+        table: Arc<Table>,
+        snapshot_id: i64,
+        positions: Vec<PrimaryKeySearchPosition>,
+        splits: Vec<PkVectorIndexedSplit>,
+    ) -> crate::Result<Self> {
+        let splits = splits
+            .into_iter()
+            .map(|selected| {
+                let split = 
selected.split.with_selected_row_ranges(selected.row_ranges);
+                match selected.scores {
+                    Some(scores) => split.with_scores(scores),
+                    None => Ok(split),
+                }
+            })
+            .collect::<crate::Result<Vec<_>>>()?;
+        Ok(Self {
+            selection: GlobalIndexSelection::Splits(splits),
+            snapshot_id: (snapshot_id != 0).then_some(snapshot_id),
+            read_context: Some(IndexReadContext {
+                table,
+                vector_column: None,
+                positions: Some(positions),
+            }),
+        })
+    }
+
+    /// Source snapshot, or `None` when no snapshot was supplied or resolved.
+    pub fn snapshot_id(&self) -> Option<i64> {
+        self.snapshot_id
+    }
+
+    /// Source table retained by a search. External selections have no read 
context.
+    pub fn table(&self) -> Option<&Table> {
+        self.read_context
+            .as_ref()
+            .map(|context| context.table.as_ref())
+    }
+
+    /// Number of selected rows (a lower bound for splits with unknown row 
counts).
+    pub fn len(&self) -> usize {
+        match &self.selection {
+            GlobalIndexSelection::RowRanges(ranges) => {
+                ranges.iter().fold(0usize, |count, range| {
+                    count.saturating_add(
+                        usize::try_from(i128::from(range.to()) - 
i128::from(range.from()) + 1)
+                            .unwrap_or(usize::MAX),
+                    )
+                })
+            }
+            GlobalIndexSelection::ScoredRowIds(hits) => hits.len(),
+            GlobalIndexSelection::Splits(splits) => splits.iter().fold(0usize, 
|count, split| {
+                
count.saturating_add(usize::try_from(split.row_count()).unwrap_or(usize::MAX))
+            }),
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// Scored global row IDs, in relevance order; rejects physical PK 
selections.
+    pub fn row_ids(&self) -> crate::Result<&ScoredRowIds> {
+        match &self.selection {
+            GlobalIndexSelection::ScoredRowIds(hits) => Ok(hits),
+            _ => Err(no_scored_row_ids()),
+        }
+    }
+
+    pub fn into_row_ids(self) -> crate::Result<ScoredRowIds> {
+        match self.selection {
+            GlobalIndexSelection::ScoredRowIds(hits) => Ok(hits),
+            _ => Err(no_scored_row_ids()),
+        }
+    }
+
+    /// Scored physical positions in relevance order, available on PK search 
results.
+    pub fn positions(&self) -> crate::Result<&[PrimaryKeySearchPosition]> {
+        if !self.is_split_result() {
+            return Err(crate::Error::Unsupported {
+                message: "Global row-ID results use global row IDs, not 
primary-key file positions"
+                    .into(),
+            });
+        }
+        self.read_context
+            .as_ref()
+            .and_then(|context| context.positions.as_deref())
+            .ok_or_else(|| crate::Error::Unsupported {
+                message:
+                    "Ranked physical positions are only available on 
primary-key search results"
+                        .into(),
+            })
+    }
+
+    /// Snapshot-scoped PK selections, including ranges and aligned scores.
+    pub fn splits(&self) -> crate::Result<&[DataSplit]> {
+        match &self.selection {
+            GlobalIndexSelection::Splits(splits) => Ok(splits),
+            _ => Err(crate::Error::Unsupported {
+                message: "Global row-ID results do not carry primary-key 
indexed splits".into(),
+            }),
+        }
+    }
+
+    /// Snapshot-scoped PK selections in Java SplitSerializer v1 format.
+    pub fn serialize_primary_key_splits(&self) -> crate::Result<Vec<Vec<u8>>> {
+        self.splits()?
+            .iter()
+            .map(DataSplit::serialize_split_v1)
+            .collect()
+    }
+
+    pub(super) fn is_split_result(&self) -> bool {
+        matches!(self.selection, GlobalIndexSelection::Splits(_))
+    }
+
+    fn indexed_splits(&self) -> crate::Result<Vec<PkVectorIndexedSplit>> {
+        self.splits()?
+            .iter()
+            .map(|split| {
+                Ok(PkVectorIndexedSplit {
+                    split: split.clone(),
+                    row_ranges: split
+                        .row_ranges()
+                        .ok_or_else(|| crate::Error::DataInvalid {
+                            message: "Ranked primary-key splits must carry 
physical row ranges"
+                                .into(),
+                            source: None,
+                        })?
+                        .to_vec(),
+                    scores: split.scores().map(<[f32]>::to_vec),
+                })
+            })
+            .collect()
+    }
+
+    /// Read selected user columns and scores in relevance order. External 
selections
+    /// must be read through a table scan because they do not retain a source 
table.
+    pub fn new_read_builder(&self) -> GlobalIndexResultReadBuilder<'_> {
+        GlobalIndexResultReadBuilder::new(self)
+    }
+
+    pub(crate) fn row_ranges(&self) -> crate::Result<Option<Vec<RowRange>>> {
+        match &self.selection {
+            GlobalIndexSelection::RowRanges(ranges) => 
Ok(Some(merge_row_ranges(ranges.clone()))),
+            GlobalIndexSelection::Splits(_) => Ok(None),
+            GlobalIndexSelection::ScoredRowIds(hits) => {
+                if hits.row_ids.len() != hits.scores.len() {
+                    return Err(crate::Error::DataInvalid {
+                        message: "Scored row IDs and scores must have equal 
lengths".into(),
+                        source: None,
+                    });
+                }
+                let mut ranges = hits
+                    .row_ids
+                    .iter()
+                    .map(|id| {
+                        let id = i64::try_from(*id).map_err(|_| 
crate::Error::DataInvalid {
+                            message: "Global row ID exceeds the signed 
row-range address space"
+                                .into(),
+                            source: None,
+                        })?;
+                        Ok(RowRange::new(id, id))
+                    })
+                    .collect::<crate::Result<Vec<_>>>()?;
+                ranges.sort_unstable_by_key(RowRange::from);
+                if ranges.windows(2).any(|r| r[0].from() == r[1].from()) {
+                    return Err(crate::Error::DataInvalid {
+                        message: "Scored global row IDs must be unique".into(),
+                        source: None,
+                    });
+                }
+                Ok(Some(merge_row_ranges(ranges)))
+            }
+        }
+    }
+
+    fn score_map(&self) -> crate::Result<Option<std::collections::HashMap<i64, 
f32>>> {
+        let GlobalIndexSelection::ScoredRowIds(hits) = &self.selection else {
+            return Ok(None);
+        };
+        if hits.row_ids.len() != hits.scores.len() {
+            return Err(crate::Error::DataInvalid {
+                message: "Scored row IDs and scores must have equal 
lengths".into(),
+                source: None,
+            });
+        }
+        let mut scores = std::collections::HashMap::with_capacity(hits.len());
+        for (id, score) in hits.row_ids.iter().zip(&hits.scores) {
+            let id = i64::try_from(*id).map_err(|_| crate::Error::DataInvalid {
+                message: "Global row ID exceeds the signed row-range address 
space".into(),
+                source: None,
+            })?;
+            if scores.insert(id, *score).is_some() {
+                return Err(crate::Error::DataInvalid {
+                    message: "Scored global row IDs must be unique".into(),
+                    source: None,
+                });
+            }
+        }
+        Ok(Some(scores))
+    }
+
+    pub(crate) fn attach_scores(&self, plan: Plan) -> crate::Result<Plan> {
+        let Some(scores) = self.score_map()? else {
+            return Ok(plan);
+        };
+        let snapshot_id = plan.snapshot_id();
+        let splits = plan
+            .into_splits()
+            .into_iter()
+            .map(|split| {
+                let selected = split
+                    .row_ranges()
+                    .ok_or_else(|| crate::Error::DataInvalid {
+                        message: "Scored global-index splits must carry row 
ranges".into(),
+                        source: None,
+                    })?;
+                let mut values = Vec::new();
+                for range in selected {
+                    for id in range.from()..=range.to() {
+                        values.push(*scores.get(&id).ok_or_else(|| 
crate::Error::DataInvalid {
+                            message: format!("Missing score for selected 
global row ID {id}"),
+                            source: None,
+                        })?);
+                    }
+                }
+                split.with_scores(values)
+            })
+            .collect::<crate::Result<Vec<_>>>()?;
+        let plan = Plan::new(splits);
+        Ok(match snapshot_id {
+            Some(id) => plan.with_snapshot_id(id),
+            None => plan,
+        })
+    }
+}
+
+fn no_scored_row_ids() -> crate::Error {
+    crate::Error::Unsupported {
+        message: "Scored global row IDs are unavailable for primary-key 
physical file positions or unscored row ranges".into(),
+    }
+}
+
+#[cfg(test)]
+mod search_tests;
+#[cfg(test)]
+mod tests;
diff --git a/crates/paimon/src/table/global_index_result/read.rs 
b/crates/paimon/src/table/global_index_result/read.rs
new file mode 100644
index 00000000..5cf02b53
--- /dev/null
+++ b/crates/paimon/src/table/global_index_result/read.rs
@@ -0,0 +1,100 @@
+// 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.
+
+//! Ranked materialization of a snapshot-scoped global-index result.
+
+use super::{GlobalIndexResult, GlobalIndexSelection};
+use crate::spec::CoreOptions;
+use crate::table::data_file_reader::DataFileReader;
+use crate::table::de_vector_read::{materialize_row_ids, DeVectorRead};
+use crate::table::pk_vector_read::materialize_positions;
+use crate::table::vector_search_common::resolve_materialize_read_type;
+use crate::table::ArrowRecordBatchStream;
+
+/// 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 search or replans its primary-key source files.
+#[derive(Debug, Clone)]
+pub struct GlobalIndexResultReadBuilder<'a> {
+    result: &'a GlobalIndexResult,
+    projection: Option<Vec<String>>,
+}
+
+impl<'a> GlobalIndexResultReadBuilder<'a> {
+    pub(super) fn new(result: &'a GlobalIndexResult) -> Self {
+        Self {
+            result,
+            projection: None,
+        }
+    }
+
+    /// Select user columns in this order. Defaults to every user table column.
+    pub fn with_projection(&mut self, columns: &[&str]) -> &mut Self {
+        self.projection = Some(columns.iter().map(|name| 
name.to_string()).collect());
+        self
+    }
+
+    pub async fn read(&self) -> crate::Result<ArrowRecordBatchStream> {
+        let context =
+            self.result
+                .read_context
+                .as_ref()
+                .ok_or_else(|| crate::Error::Unsupported {
+                    message: "Direct result reads require a result returned by 
a table search"
+                        .into(),
+                })?;
+        let table = &context.table;
+        let core_options = CoreOptions::new(table.schema().options());
+        core_options.ensure_read_authorized()?;
+        match &self.result.selection {
+            GlobalIndexSelection::ScoredRowIds(hits) => {
+                let read_type = match &context.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
+            }
+            GlobalIndexSelection::Splits(_) => {
+                let positions =
+                    context
+                        .positions
+                        .as_deref()
+                        .ok_or_else(|| crate::Error::Unsupported {
+                            message: "Direct primary-key reads require ranked 
search positions"
+                                .into(),
+                        })?;
+                let splits = self.result.indexed_splits()?;
+                let read_type = resolve_materialize_read_type(table, 
self.projection.as_deref())?;
+                let reader = DataFileReader::new(
+                    table.file_io().clone(),
+                    table.schema_manager().clone(),
+                    table.schema().id(),
+                    table.schema().fields().to_vec(),
+                    read_type,
+                    Vec::new(),
+                )
+                .with_table_options(table.schema().options().clone());
+                materialize_positions(positions, &splits, &reader).await
+            }
+            GlobalIndexSelection::RowRanges(_) => 
Err(crate::Error::Unsupported {
+                message: "Unscored row ranges must be read through a table 
scan".into(),
+            }),
+        }
+    }
+}
diff --git a/crates/paimon/src/table/search_result/tests.rs 
b/crates/paimon/src/table/global_index_result/search_tests.rs
similarity index 100%
rename from crates/paimon/src/table/search_result/tests.rs
rename to crates/paimon/src/table/global_index_result/search_tests.rs
diff --git a/crates/paimon/src/table/global_index_result/tests.rs 
b/crates/paimon/src/table/global_index_result/tests.rs
new file mode 100644
index 00000000..100ff6ab
--- /dev/null
+++ b/crates/paimon/src/table/global_index_result/tests.rs
@@ -0,0 +1,375 @@
+// 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 super::*;
+use crate::table::vector_search_test_utils::{
+    de_vector_table, id_gt_filter, pk_vector_table, vector_test_table_at,
+};
+use crate::vector_search::ScoredRowIds;
+use std::collections::HashMap;
+
+fn selected_split() -> DataSplit {
+    
DataSplit::deserialize_split_v1(include_bytes!("../goldens/split_v1_data.bin"))
+        .unwrap()
+        .with_selected_row_ranges(vec![RowRange::new(1, 1)])
+        .with_scores(vec![0.75])
+        .unwrap()
+}
+
+#[tokio::test]
+async fn 
pk_supplied_result_preserves_snapshot_splits_and_ignores_planning_hints() {
+    // There are no snapshots or manifests to resolve. These belong to the
+    // captured result, exactly as Java PrimaryKeyBatchScan.preProcessPlan.
+    let table = pk_vector_table(&[("pk-vector.index.columns", "embedding")]);
+    let split = selected_split();
+    let mut builder = table.new_read_builder();
+    builder.with_limit(0).with_filter(id_gt_filter(&table, 100));
+    let scan = builder
+        .new_scan()
+        .with_global_index_result(GlobalIndexResult::from_splits(
+            Some(42),
+            vec![split.clone()],
+        ))
+        .unwrap()
+        .with_shard(1, 2)
+        .unwrap();
+    let plan = scan.plan().await.unwrap();
+    let (traced, trace) = scan.plan_with_trace().await.unwrap();
+    for plan in [plan, traced] {
+        assert_eq!(plan.snapshot_id(), Some(42));
+        assert_eq!(plan.splits(), std::slice::from_ref(&split));
+        assert_eq!(
+            plan.splits()[0].serialize_split_v1().unwrap(),
+            split.serialize_split_v1().unwrap()
+        );
+    }
+    assert_eq!(trace.snapshot_id, Some(42));
+    assert_eq!(trace.manifest_entries_read, 0);
+    assert_eq!(trace.final_splits, 1);
+    assert_eq!(trace.final_files, split.data_files().len());
+    assert_eq!(
+        trace.planned_data_file_bytes,
+        split
+            .data_files()
+            .iter()
+            .map(|f| f.file_size as u64)
+            .sum::<u64>()
+    );
+}
+
+#[tokio::test]
+async fn empty_pk_result_retains_its_snapshot_and_does_not_scan_latest() {
+    let table = pk_vector_table(&[("pk-vector.index.columns", "embedding")]);
+    for snapshot_id in [None, Some(7)] {
+        let scan = table
+            .new_read_builder()
+            .new_scan()
+            
.with_global_index_result(GlobalIndexResult::from_splits(snapshot_id, vec![]))
+            .unwrap();
+        let plan = scan.plan().await.unwrap();
+        assert!(plan.splits().is_empty());
+        assert_eq!(plan.snapshot_id(), snapshot_id);
+    }
+}
+
+#[tokio::test]
+async fn result_address_spaces_and_conflicting_selections_are_validated() {
+    let pk = pk_vector_table(&[("pk-vector.index.columns", "embedding")]);
+    let de = de_vector_table().await;
+    let append = vector_test_table_at("memory:/append_index_result");
+    assert!(pk
+        .new_read_builder()
+        .new_scan()
+        .with_global_index_result(GlobalIndexResult::from_row_ranges(vec![]))
+        .is_err());
+    for table in [&de, &append] {
+        assert!(table
+            .new_read_builder()
+            .new_scan()
+            .with_global_index_result(GlobalIndexResult::from_splits(None, 
vec![]))
+            .is_err());
+    }
+    assert!(append
+        .new_read_builder()
+        .new_scan()
+        .with_global_index_result(GlobalIndexResult::from_row_ranges(vec![]))
+        .is_err());
+    let mut builder = de.new_read_builder();
+    builder.with_row_ranges(vec![]);
+    assert!(builder
+        .new_scan()
+        .with_global_index_result(GlobalIndexResult::from_row_ranges(vec![]))
+        .is_err());
+    let scan = de
+        .new_read_builder()
+        .new_scan()
+        .with_global_index_result(GlobalIndexResult::from_row_ranges(vec![]))
+        .unwrap();
+    assert!(scan.clone().with_row_ranges(vec![]).plan().await.is_err());
+    assert!(scan.clone().with_scan_all_files().plan().await.is_err());
+    assert!(scan.with_chunk_shuffle(1, 1).is_err());
+    assert!(de
+        .new_read_builder()
+        .new_scan()
+        .with_chunk_shuffle(1, 1)
+        .unwrap()
+        .with_global_index_result(GlobalIndexResult::from_row_ranges(vec![]))
+        .is_err());
+}
+
+#[tokio::test]
+async fn de_scores_follow_final_ranges_after_distribution_and_limit() {
+    let table = de_vector_table().await;
+    let hits =
+        GlobalIndexResult::from_scored_row_ids(ScoredRowIds::new(vec![2, 0], 
vec![0.8, 0.1]));
+    let mut builder = table.new_read_builder();
+    builder.with_projection(&["id"]).unwrap();
+    let plan = builder
+        .new_scan()
+        .with_global_index_result(hits.clone())
+        .unwrap()
+        .plan()
+        .await
+        .unwrap();
+    let ranges = plan
+        .splits()
+        .iter()
+        .flat_map(|s| s.row_ranges().unwrap())
+        .cloned()
+        .collect::<Vec<_>>();
+    assert_eq!(ranges, vec![RowRange::new(0, 0), RowRange::new(2, 2)]);
+    let scores = plan
+        .splits()
+        .iter()
+        .flat_map(|s| s.scores().unwrap())
+        .copied()
+        .collect::<Vec<_>>();
+    assert_eq!(scores, vec![0.1, 0.8]);
+    let selected = builder
+        .new_scan()
+        .with_global_index_result(hits)
+        .unwrap()
+        .with_row_position_slice(1, 3)
+        .unwrap()
+        .plan()
+        .await
+        .unwrap();
+    assert_eq!(selected.snapshot_id(), plan.snapshot_id());
+    assert_eq!(selected.splits().len(), 1);
+    assert_eq!(
+        selected.splits()[0].row_ranges(),
+        Some([RowRange::new(2, 2)].as_slice())
+    );
+    assert_eq!(selected.splits()[0].scores(), Some([0.8].as_slice()));
+    let wire = selected.splits()[0].serialize_split_v1().unwrap();
+    assert_eq!(
+        DataSplit::deserialize_split_v1(&wire).unwrap(),
+        selected.splits()[0]
+    );
+    builder.with_limit(1);
+    let plan = builder
+        .new_scan()
+        
.with_global_index_result(GlobalIndexResult::from_scored_row_ids(ScoredRowIds::new(
+            vec![2, 0],
+            vec![0.8, 0.1],
+        )))
+        .unwrap()
+        .plan()
+        .await
+        .unwrap();
+    // LIMIT is a conservative split hint; every retained position keeps its 
score.
+    assert!(plan
+        .splits()
+        .iter()
+        .all(|s| s.scores().unwrap().len() == s.row_count() as usize));
+}
+
+#[tokio::test]
+async fn de_search_results_can_be_reused_by_an_ordinary_scan() {
+    let table = de_vector_table().await;
+    let result = table
+        .new_vector_search_builder()
+        .with_vector_column("embedding")
+        .with_query_vector(vec![1.0, 0.0])
+        .with_limit(2)
+        .execute()
+        .await
+        .unwrap();
+    let expected = result
+        .row_ids()
+        .unwrap()
+        .row_ids
+        .iter()
+        .copied()
+        .zip(result.row_ids().unwrap().scores.iter().copied())
+        .collect::<HashMap<_, _>>();
+    let plan = result
+        .table()
+        .unwrap()
+        .new_read_builder()
+        .new_scan()
+        .with_global_index_result(result.clone())
+        .unwrap()
+        .plan()
+        .await
+        .unwrap();
+    let mut actual = HashMap::new();
+    for split in plan.splits() {
+        let ids = split
+            .row_ranges()
+            .unwrap()
+            .iter()
+            .flat_map(|r| r.from()..=r.to());
+        actual.extend(
+            ids.zip(split.scores().unwrap().iter().copied())
+                .map(|(id, score)| (id as u64, score)),
+        );
+    }
+    assert_eq!(plan.snapshot_id(), result.snapshot_id());
+    assert_eq!(actual, expected);
+}
+
+#[tokio::test]
+async fn invalid_scored_ids_do_not_become_a_full_scan() {
+    let table = de_vector_table().await;
+    for hits in [
+        ScoredRowIds {
+            row_ids: vec![0],
+            scores: vec![],
+        },
+        ScoredRowIds::new(vec![0, 0], vec![1.0, 2.0]),
+        ScoredRowIds::new(vec![u64::MAX], vec![1.0]),
+    ] {
+        assert!(table
+            .new_read_builder()
+            .new_scan()
+            
.with_global_index_result(GlobalIndexResult::from_scored_row_ids(hits))
+            .unwrap()
+            .plan()
+            .await
+            .is_err());
+    }
+}
+
+#[tokio::test]
+async fn supplied_pk_result_cannot_bypass_required_authorization() {
+    let table = pk_vector_table(&[("pk-vector.index.columns", 
"embedding")]).copy_with_options(
+        HashMap::from([("query-auth.enabled".into(), "true".into())]),
+    );
+    let scan = table
+        .new_read_builder()
+        .new_scan()
+        .with_global_index_result(GlobalIndexResult::from_splits(
+            Some(42),
+            vec![selected_split()],
+        ))
+        .unwrap();
+    assert!(scan.plan().await.is_err());
+    assert!(scan.plan_with_trace().await.is_err());
+}
+
+#[tokio::test]
+async fn de_combined_incremental_results_retain_the_window_and_scores() {
+    use crate::table::{IncrementalScanMode, TableCommit};
+    use arrow_array::{types::Float32Type, Int32Array, ListArray, RecordBatch};
+    use std::sync::Arc;
+
+    let table = de_vector_table().await;
+    let start = table
+        .snapshot_manager()
+        .get_latest_snapshot_id()
+        .await
+        .unwrap()
+        .unwrap();
+    let builder = table.new_write_builder().with_commit_user("next").unwrap();
+    let mut writer = builder.new_write().unwrap();
+    let batch = RecordBatch::try_from_iter(vec![
+        (
+            "id",
+            Arc::new(Int32Array::from(vec![4])) as arrow_array::ArrayRef,
+        ),
+        (
+            "embedding",
+            Arc::new(ListArray::from_iter_primitive::<Float32Type, _, _>([Some(
+                vec![Some(1.0), Some(0.0)],
+            )])) as arrow_array::ArrayRef,
+        ),
+    ])
+    .unwrap();
+    writer.write_arrow_batch(&batch).await.unwrap();
+    TableCommit::new(table.clone(), "next".into())
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+    let end = table
+        .snapshot_manager()
+        .get_latest_snapshot_id()
+        .await
+        .unwrap()
+        .unwrap();
+    let scan = table
+        .new_read_builder()
+        .new_incremental_scan(IncrementalScanMode::Delta, start, end)
+        
.with_global_index_result(GlobalIndexResult::from_scored_row_ids(ScoredRowIds::new(
+            vec![0, 3],
+            vec![0.1, 0.9],
+        )))
+        .unwrap();
+    assert!(scan.plan().await.is_err());
+    let plan = scan.plan_combined().await.unwrap();
+    assert_eq!(plan.snapshot_id(), Some(end));
+    assert_eq!(plan.splits().len(), 1);
+    assert_eq!(
+        plan.splits()[0].row_ranges(),
+        Some([RowRange::new(3, 3)].as_slice())
+    );
+    assert_eq!(plan.splits()[0].scores(), Some([0.9].as_slice()));
+    assert!(table
+        .new_read_builder()
+        .new_incremental_scan(IncrementalScanMode::Delta, start, end)
+        .with_global_index_result(GlobalIndexResult::from_splits(Some(end), 
vec![]))
+        .is_err());
+}
+
+#[tokio::test]
+async fn external_selections_need_a_table_scan_for_reads() {
+    let ranges = GlobalIndexResult::from_row_ranges(vec![RowRange::new(0, 
i64::MAX)]);
+    assert_eq!(
+        ranges.len(),
+        usize::try_from(i128::from(i64::MAX) + 1).unwrap_or(usize::MAX)
+    );
+    let split = selected_split();
+    let physical = GlobalIndexResult::from_splits(Some(42), 
vec![split.clone()]);
+    assert_eq!(physical.len(), 1);
+    assert_eq!(
+        physical.serialize_primary_key_splits().unwrap(),
+        vec![split.serialize_split_v1().unwrap()]
+    );
+    for result in [
+        ranges,
+        physical,
+        GlobalIndexResult::from_scored_row_ids(ScoredRowIds::new(vec![1], 
vec![0.5])),
+    ] {
+        let error = match result.new_read_builder().read().await {
+            Ok(_) => panic!("external selection unexpectedly retained a source 
table"),
+            Err(error) => error,
+        };
+        assert!(error
+            .to_string()
+            .contains("require a result returned by a table search"));
+    }
+}
diff --git a/crates/paimon/src/table/hybrid_search_builder.rs 
b/crates/paimon/src/table/hybrid_search_builder.rs
index 602a3337..bd6f8b9e 100644
--- a/crates/paimon/src/table/hybrid_search_builder.rs
+++ b/crates/paimon/src/table/hybrid_search_builder.rs
@@ -20,6 +20,7 @@
 //! Reference: `org.apache.paimon.table.source.HybridSearchBuilder`.
 
 use std::collections::{BTreeMap, HashMap};
+use std::sync::Arc;
 
 use arrow_array::RecordBatch;
 use futures::{stream, StreamExt, TryStreamExt};
@@ -311,10 +312,10 @@ impl<'a> HybridSearchBuilder<'a> {
     }
 
     pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
-        self.execute_scored().await?.to_row_ranges()
+        self.execute_scored().await?.row_ids()?.to_row_ranges()
     }
 
-    pub async fn execute_scored(&self) -> crate::Result<ScoredRowIds> {
+    pub async fn execute_scored(&self) -> 
crate::Result<super::GlobalIndexResult> {
         let core = CoreOptions::new(self.table.schema().options());
         core.ensure_read_authorized()?;
         let limit = self.validate_search()?;
@@ -339,10 +340,20 @@ impl<'a> HybridSearchBuilder<'a> {
         self.validate_global_routes()?;
         // Resolve once even for an empty table or a retained tag. Re-resolving
         // selectors independently in the routes can observe different 
versions.
-        let Some(table) = self.resolve_pinned_route_table().await? else {
-            return Ok(ScoredRowIds::empty());
+        let (table, hits) = match self.resolve_pinned_route_table().await? {
+            Some(table) => {
+                let hits = self.execute_global_routes(&table, limit).await?;
+                (table, hits)
+            }
+            None => (
+                self.table.copy_with_pinned_snapshot(None),
+                ScoredRowIds::empty(),
+            ),
         };
-        self.execute_global_routes(&table, limit).await
+        Ok(super::GlobalIndexResult::from_global_search(
+            Arc::new(table),
+            hits,
+        ))
     }
 
     fn validate_search(&self) -> crate::Result<usize> {
@@ -650,11 +661,7 @@ impl<'a> HybridSearchBuilder<'a> {
             .iter()
             .map(PrimaryKeySearchPosition::from_vector_position)
             .collect::<crate::Result<Vec<_>>>()?;
-        let source_splits = result
-            .indexed_splits()?
-            .iter()
-            .map(|split| split.split.clone())
-            .collect();
+        let source_splits = result.splits()?.to_vec();
         Ok(PkRoute {
             positions,
             source_splits,
diff --git a/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs 
b/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs
index df2f8e25..8c103101 100644
--- a/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs
+++ b/crates/paimon/src/table/hybrid_search_builder/parity_tests.rs
@@ -133,7 +133,16 @@ async fn full_text_route_uses_java_structured_query() {
         )
         .unwrap();
     builder.with_limit(4);
-    assert_eq!(builder.execute_scored().await.unwrap().row_ids, vec![2]);
+    assert_eq!(
+        builder
+            .execute_scored()
+            .await
+            .unwrap()
+            .into_row_ids()
+            .unwrap()
+            .row_ids,
+        vec![2]
+    );
 }
 
 #[tokio::test]
@@ -187,8 +196,8 @@ async fn 
both_routes_filter_before_top_k_and_accumulate_predicates() {
         .with_partition_filter(pb.equal("pt", Datum::Int(1)).unwrap())
         .unwrap();
     let result = builder.execute_scored().await.unwrap();
-    assert_eq!(result.row_ids, vec![6]);
-    assert!((result.scores[0] - 2.0 / 61.0).abs() < 1e-6);
+    assert_eq!(result.row_ids().unwrap().row_ids, vec![6]);
+    assert!((result.row_ids().unwrap().scores[0] - 2.0 / 61.0).abs() < 1e-6);
     builder
         .with_partition_filter(pb.equal("pt", Datum::Int(0)).unwrap())
         .unwrap();
@@ -213,6 +222,8 @@ async fn 
scalar_coverage_and_refinement_are_shared_by_both_routes() {
             .execute_scored()
             .await
             .unwrap()
+            .into_row_ids()
+            .unwrap()
             .row_ids;
         ids.sort_unstable();
         // Vector FAST reads indexed ranges from data when no scalar index can
@@ -242,6 +253,8 @@ async fn 
scalar_coverage_and_refinement_are_shared_by_both_routes() {
             .execute_scored()
             .await
             .unwrap()
+            .into_row_ids()
+            .unwrap()
             .row_ids;
         ids.sort_unstable();
         assert_eq!(ids, if refine { vec![1, 2] } else { vec![] });
@@ -272,15 +285,24 @@ async fn 
concurrent_routes_preserve_weights_limits_and_java_fusion() {
             .with_ranker(ranker)
             .unwrap();
         let result = builder.execute_scored().await.unwrap();
-        assert_eq!(result.row_ids, ids, "ranker {ranker}");
-        for (actual, expected) in result.scores.iter().zip(scores) {
+        assert_eq!(result.row_ids().unwrap().row_ids, ids, "ranker {ranker}");
+        for (actual, expected) in 
result.row_ids().unwrap().scores.iter().zip(scores) {
             assert!(
                 (actual - expected).abs() < 1e-6,
                 "ranker {ranker}: {actual} != {expected}"
             );
         }
         builder.with_limit(1);
-        assert_eq!(builder.execute_scored().await.unwrap().row_ids, vec![3]);
+        assert_eq!(
+            builder
+                .execute_scored()
+                .await
+                .unwrap()
+                .into_row_ids()
+                .unwrap()
+                .row_ids,
+            vec![3]
+        );
     }
 }
 
@@ -315,6 +337,8 @@ async fn 
retained_tag_keeps_both_routes_on_the_expired_snapshot() {
         .execute_scored()
         .await
         .unwrap()
+        .into_row_ids()
+        .unwrap()
         .row_ids;
     old.sort_unstable();
     assert_eq!(old, vec![0, 1, 2, 3]);
@@ -357,6 +381,8 @@ async fn 
caller_pinned_empty_and_snapshot_views_do_not_read_latest() {
         .execute_scored()
         .await
         .unwrap()
+        .into_row_ids()
+        .unwrap()
         .row_ids;
     old.sort_unstable();
     assert_eq!(old, vec![0, 1, 2, 3]);
diff --git a/crates/paimon/src/table/incremental_scan.rs 
b/crates/paimon/src/table/incremental_scan.rs
index 5eff66b1..b56a65a6 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use super::{DataSplit, Plan, ScanTrace, SnapshotManager, Table, TableScan};
+use super::{DataSplit, GlobalIndexResult, Plan, ScanTrace, SnapshotManager, 
Table, TableScan};
 use crate::spec::{CommitKind, CoreOptions};
 
 /// Java IncrementalDeltaStartingScanner.betweenTimestamps. Resolve both bounds
@@ -307,6 +307,19 @@ impl<'a> IncrementalScan<'a> {
         Ok(self)
     }
 
+    /// Intersect a DE global-row-ID selection with the combined incremental
+    /// batch. Unlike PK split results, this must retain the snapshot window.
+    /// Use [`Self::plan_combined`] to retain aligned scores on the final 
splits.
+    pub fn with_global_index_result(mut self, result: GlobalIndexResult) -> 
crate::Result<Self> {
+        if result.is_split_result() {
+            return Err(crate::Error::Unsupported {
+                message: "Split-based results require an ordinary batch 
scan".into(),
+            });
+        }
+        self.scan = self.scan.with_global_index_result(result)?;
+        Ok(self)
+    }
+
     pub async fn plan(&self) -> crate::Result<IncrementalPlan> {
         let mode = self.resolve_mode();
         self.validate_per_snapshot_plan(mode).await?;
@@ -326,9 +339,10 @@ impl<'a> IncrementalScan<'a> {
         if self.scan.has_row_position_selection()
             || self.scan.has_chunk_shuffle()
             || self.scan.has_shard()
+            || self.scan.has_global_index_result()
         {
             return Err(crate::Error::Unsupported {
-                message: "Incremental row-position selection and chunk_shuffle 
require combined Delta planning; sharding requires combined Delta or Changelog 
planning"
+                message: "Incremental row-position selection and chunk_shuffle 
require combined Delta planning; sharding and global-index results require 
combined Delta or Changelog planning"
                     .into(),
             });
         }
@@ -437,8 +451,10 @@ impl<'a> IncrementalScan<'a> {
             })
             .map(str::to_owned)
             .collect();
-        scan.plan_incremental_lists_with_trace(&end_snapshot, manifest_lists, 
trace)
-            .await
+        let plan = scan
+            .plan_incremental_lists_with_trace(&end_snapshot, manifest_lists, 
trace)
+            .await?;
+        self.scan.attach_global_index_scores(plan)
     }
 
     fn resolve_mode(&self) -> IncrementalScanMode {
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index f7caeb62..bf59ab56 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -78,6 +78,7 @@ mod full_text_search_builder;
 mod global_index_build_builder;
 pub(crate) mod global_index_build_common;
 mod global_index_drop_builder;
+mod global_index_result;
 pub(crate) mod global_index_scanner;
 mod global_index_search_filter;
 mod global_index_types;
@@ -138,7 +139,6 @@ 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;
@@ -202,6 +202,9 @@ pub use full_text_search_builder::{
 use futures::stream::BoxStream;
 pub use global_index_build_builder::GlobalIndexBuildBuilder;
 pub use global_index_drop_builder::GlobalIndexDropBuilder;
+pub use global_index_result::{
+    GlobalIndexResult, GlobalIndexResultReadBuilder, PrimaryKeySearchPosition,
+};
 pub use global_index_types::{normalize_global_index_type, 
SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP};
 pub use hybrid_search_builder::{
     HybridSearchBuilder, HybridSearchRanker, HybridSearchRoute, 
HybridSearchRouteKind,
@@ -222,7 +225,6 @@ 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 a5523a30..e83504db 100644
--- a/crates/paimon/src/table/pk_full_text_read.rs
+++ b/crates/paimon/src/table/pk_full_text_read.rs
@@ -17,7 +17,7 @@
 
 //! Primary-key full-text search read foundation: the score-based candidate 
type,
 //! its cross-bucket score-descending Top-K, and the FAST-only search over
-//! physical positions. SearchResult materializes the selected rows later.
+//! physical positions. GlobalIndexResult materializes the selected rows later.
 
 use std::cmp::Ordering;
 use std::collections::{BTreeMap, HashMap};
@@ -255,12 +255,12 @@ pub(crate) struct PrimaryKeyFullTextRouteResult<'a> {
 
 /// 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 and fuses physical positions by score. SearchResult owns the 
selections
+/// reader and fuses physical positions by score. GlobalIndexResult owns the 
selections
 /// and their later materialization, as Java PrimaryKeyScoredResult does.
 ///
 /// The planning + FAST-mode guard live in the caller 
(`FullTextSearchBuilder`);
 /// this type searches a prepared plan. Its data reader loads deletion vectors;
-/// projected user columns are read later by SearchResult.
+/// projected user columns are read later by GlobalIndexResult.
 pub(crate) struct PrimaryKeyFullTextRead {
     file_io: FileIO,
     materialize_reader: DataFileReader,
@@ -287,7 +287,7 @@ 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 through SearchResult.
+    /// them with another route before materializing through GlobalIndexResult.
     /// `query` is passed verbatim to the archive reader
     /// (spec D2).
     pub(crate) async fn search_route<'p>(
@@ -519,7 +519,7 @@ mod read_tests {
                 None,
             );
             let route = self.search_route(plan, query, limit).await?;
-            crate::table::SearchResult::from_full_text_primary_key(
+            crate::table::GlobalIndexResult::from_full_text_primary_key(
                 Arc::new(table),
                 route.snapshot_id,
                 route.candidates,
diff --git a/crates/paimon/src/table/pk_search_position.rs 
b/crates/paimon/src/table/pk_search_position.rs
index 2d8c36fa..aa522228 100644
--- a/crates/paimon/src/table/pk_search_position.rs
+++ b/crates/paimon/src/table/pk_search_position.rs
@@ -222,7 +222,7 @@ mod tests {
 
     #[test]
     fn from_vector_position_preserves_score_and_validates_position() {
-        // SearchResult already converted distance to score; fusion must 
preserve it.
+        // GlobalIndexResult already converted distance to score; fusion must 
preserve it.
         let mut hit = vector_position(0.5);
         let position = 
PrimaryKeySearchPosition::from_vector_position(&hit).unwrap();
         assert_eq!(position.score(), 0.5);
diff --git a/crates/paimon/src/table/pk_vector_indexed_split_read.rs 
b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
index 4feeacb2..7fefb6f1 100644
--- a/crates/paimon/src/table/pk_vector_indexed_split_read.rs
+++ b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
@@ -49,8 +49,9 @@ fn data_invalid(message: impl Into<String>) -> crate::Error {
 /// `scores`, when present, is aligned to the expanded-range order (ascending
 /// position); `None` means no `__paimon_search_score` column in the output.
 ///
-/// Deliberately NOT reusing `DataSplit.row_ranges`, whose ranges mean 
stable/global
-/// row ids on the append/data-evolution path. Not serialized.
+/// This is the position reader's explicit physical selection. 
`GlobalIndexResult`
+/// retains the same ranges in its PK IndexedSplits and recreates this reader 
input
+/// at materialization. DE split ranges use a separate, global row-ID address 
space.
 #[derive(Debug, Clone)]
 pub(crate) struct PkVectorIndexedSplit {
     pub split: DataSplit,
diff --git a/crates/paimon/src/table/pk_vector_read.rs 
b/crates/paimon/src/table/pk_vector_read.rs
index 59f74c44..7194d35a 100644
--- a/crates/paimon/src/table/pk_vector_read.rs
+++ b/crates/paimon/src/table/pk_vector_read.rs
@@ -43,7 +43,7 @@ use crate::table::vector_search_common::{
     reorder_and_strip_position, vindex_concurrency_limits, RankedRow, 
VectorIndexBackend,
 };
 use crate::table::{ArrowRecordBatchStream, RowRange, Table};
-use crate::vector_search::{GlobalIndexIOMeta, SearchResult, VectorSearch};
+use crate::vector_search::{GlobalIndexIOMeta, GlobalIndexResult, VectorSearch};
 use crate::vindex::pkvector::ann::{AnnSegmentSource, PkVectorAnnSearcher, 
VindexAnnSearcher};
 use crate::vindex::pkvector::bucket::{BucketActiveFile, BucketAnnSegment, 
ExactFileSearchFuture};
 use crate::vindex::pkvector::metric::VectorSearchMetric;
@@ -92,7 +92,7 @@ impl PkVectorRead {
 impl Read for PkVectorRead {
     type Plan = PkVectorScanPlan;
 
-    async fn read(&self, plan: PkVectorScanPlan) -> 
crate::Result<Vec<SearchResult>> {
+    async fn read(&self, plan: PkVectorScanPlan) -> 
crate::Result<Vec<GlobalIndexResult>> {
         let core = CoreOptions::new(self.table.schema().options());
         let queries: Vec<&[f32]> = 
self.queries.iter().map(Vec::as_slice).collect();
         let candidates = search_pk_candidates_batch_with_plan(
@@ -111,7 +111,7 @@ impl Read for PkVectorRead {
         candidates
             .into_iter()
             .map(|candidates| {
-                SearchResult::from_primary_key(
+                GlobalIndexResult::from_primary_key(
                     table.clone(),
                     plan.snapshot_id,
                     candidates,
diff --git a/crates/paimon/src/table/pk_vector_read/tests.rs 
b/crates/paimon/src/table/pk_vector_read/tests.rs
index 59df08f9..a31f0003 100644
--- a/crates/paimon/src/table/pk_vector_read/tests.rs
+++ b/crates/paimon/src/table/pk_vector_read/tests.rs
@@ -1066,7 +1066,7 @@ async fn 
reader_uses_planned_splits_after_index_manifest_is_removed() {
             .collect::<Vec<_>>(),
         vec![1.0, 0.5]
     );
-    assert_eq!(result.indexed_splits().unwrap().len(), 1);
+    assert_eq!(result.splits().unwrap().len(), 1);
 
     let batches: Vec<RecordBatch> = result
         .new_read_builder()
@@ -1125,15 +1125,19 @@ async fn 
execute_returns_scored_positions_and_publishes_diagnostics() {
 
     // Every position retains the source file and snapshot needed for a later 
read.
     assert_eq!(result.snapshot_id(), Some(1), "first commit -> snapshot 1");
-    let splits = result.indexed_splits().unwrap();
+    let splits = result.splits().unwrap();
     assert_eq!(splits.len(), 1);
-    assert_eq!(splits[0].split.snapshot_id(), 1);
+    assert_eq!(splits[0].snapshot_id(), 1);
+    assert_eq!(splits[0].scores(), Some([1.0, 0.5].as_slice()));
+    assert_eq!(
+        splits[0].row_ranges(),
+        Some([crate::table::RowRange::new(1, 2)].as_slice())
+    );
     assert!(
         positions.iter().all(|p| {
-            p.data_file_name == splits[0].split.data_files()[0].file_name
-                && p.bucket == splits[0].split.bucket()
-                && p.partition.to_serialized_bytes()
-                    == splits[0].split.partition().to_serialized_bytes()
+            p.data_file_name == splits[0].data_files()[0].file_name
+                && p.bucket == splits[0].bucket()
+                && p.partition.to_serialized_bytes() == 
splits[0].partition().to_serialized_bytes()
         }),
         "positions must refer to their retained source split"
     );
@@ -1177,7 +1181,7 @@ async fn execute_empty_plan_yields_empty_pk_result() {
         "no data -> no positions"
     );
     assert!(
-        result.indexed_splits().unwrap().is_empty(),
+        result.splits().unwrap().is_empty(),
         "no data -> no source splits"
     );
     assert_eq!(result.snapshot_id(), None);
diff --git a/crates/paimon/src/table/search_result.rs 
b/crates/paimon/src/table/search_result.rs
deleted file mode 100644
index 4c5861e0..00000000
--- a/crates/paimon/src/table/search_result.rs
+++ /dev/null
@@ -1,293 +0,0 @@
-// 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-scoped search results for global row IDs and primary-key file 
positions.
-
-use crate::spec::{BinaryRow, CoreOptions};
-use crate::table::data_file_reader::DataFileReader;
-use crate::table::de_vector_read::{materialize_row_ids, DeVectorRead};
-use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplit;
-use crate::table::pk_vector_orchestrator::{
-    build_indexed_splits, PkVectorCandidate, PkVectorSearchSplit,
-};
-use crate::table::pk_vector_read::materialize_positions;
-use crate::table::vector_search_common::resolve_materialize_read_type;
-use crate::table::{ArrowRecordBatchStream, Table};
-use crate::vector_search::ScoredRowIds;
-use crate::vindex::pkvector::metric::VectorSearchMetric;
-use std::sync::Arc;
-
-/// One scored physical row in a primary-key table. Positions are local to the
-/// named file; neither the primary key nor a global row ID is synthesized.
-#[derive(Debug, Clone)]
-pub struct PrimaryKeySearchPosition {
-    pub partition: BinaryRow,
-    pub bucket: i32,
-    pub data_file_name: String,
-    pub row_position: i64,
-    pub score: f32,
-}
-
-#[derive(Debug, Clone)]
-enum SearchHits {
-    DataEvolution {
-        // 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 {
-        positions: Vec<PrimaryKeySearchPosition>,
-        splits: Vec<PkVectorIndexedSplit>,
-    },
-}
-
-/// Ranked search hits together with the source snapshot needed to read them.
-///
-/// 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.
-#[derive(Debug, Clone)]
-pub struct SearchResult {
-    table: Arc<Table>,
-    snapshot_id: Option<i64>,
-    hits: SearchHits,
-}
-
-impl SearchResult {
-    pub(super) fn from_row_ids(
-        table: Arc<Table>,
-        vector_column: String,
-        hits: ScoredRowIds,
-    ) -> Self {
-        let snapshot_id = table.travel_snapshot().map(|snapshot| 
snapshot.id());
-        Self {
-            table,
-            snapshot_id,
-            hits: SearchHits::DataEvolution {
-                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,
-        candidates: Vec<PkVectorCandidate>,
-        source_splits: &[PkVectorSearchSplit],
-        metric: VectorSearchMetric,
-    ) -> 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: metric.distance_to_score(c.distance),
-            })
-            .collect();
-        let splits = build_indexed_splits(candidates, source_splits, metric)?;
-        Ok(Self {
-            table,
-            snapshot_id: (snapshot_id != 0).then_some(snapshot_id),
-            hits: SearchHits::PrimaryKey { positions, splits },
-        })
-    }
-
-    /// Source snapshot, or `None` when the table had no resolved snapshot.
-    pub fn snapshot_id(&self) -> Option<i64> {
-        self.snapshot_id
-    }
-
-    /// The source table retained by the search; DE scans are pinned to this 
snapshot.
-    pub fn table(&self) -> &Table {
-        &self.table
-    }
-
-    pub fn len(&self) -> usize {
-        match &self.hits {
-            SearchHits::DataEvolution { hits, .. } => hits.len(),
-            SearchHits::PrimaryKey { positions, .. } => positions.len(),
-        }
-    }
-
-    pub fn is_empty(&self) -> bool {
-        self.len() == 0
-    }
-
-    /// Scored global row IDs, in relevance order. PK positions cannot be 
converted
-    /// into this address space, including when a PK search has no hits.
-    pub fn row_ids(&self) -> crate::Result<&ScoredRowIds> {
-        match &self.hits {
-            SearchHits::DataEvolution { hits, .. } => Ok(hits),
-            SearchHits::PrimaryKey { .. } => Err(no_global_row_ids()),
-        }
-    }
-
-    pub fn into_row_ids(self) -> crate::Result<ScoredRowIds> {
-        match self.hits {
-            SearchHits::DataEvolution { hits, .. } => Ok(hits),
-            SearchHits::PrimaryKey { .. } => Err(no_global_row_ids()),
-        }
-    }
-
-    /// Scored physical positions in relevance order, scoped to this result's 
snapshot.
-    pub fn positions(&self) -> crate::Result<&[PrimaryKeySearchPosition]> {
-        match &self.hits {
-            SearchHits::PrimaryKey { positions, .. } => Ok(positions),
-            SearchHits::DataEvolution { .. } => Err(crate::Error::Unsupported {
-                message: "data-evolution search results use global row IDs, 
not primary-key file positions".to_string(),
-            }),
-        }
-    }
-
-    /// Snapshot-scoped PK selections in Java SplitSerializer v1 format.
-    /// The ranges are physical file positions and scores are aligned to them.
-    pub fn serialize_primary_key_splits(&self) -> crate::Result<Vec<Vec<u8>>> {
-        self.indexed_splits()?
-            .iter()
-            .map(|selected| {
-                selected
-                    .split
-                    .clone()
-                    .with_selected_row_ranges(selected.row_ranges.clone())
-                    .serialize_split_v1_with_scores(selected.scores.as_deref())
-            })
-            .collect()
-    }
-
-    /// Snapshot-scoped file metadata and selections, reused when hybrid fusion
-    /// builds its final selections without scanning the source table again.
-    pub(super) fn indexed_splits(&self) -> 
crate::Result<&[PkVectorIndexedSplit]> {
-        match &self.hits {
-            SearchHits::PrimaryKey { splits, .. } => Ok(splits),
-            SearchHits::DataEvolution { .. } => Err(crate::Error::Unsupported {
-                message: "data-evolution search results do not carry 
primary-key indexed splits"
-                    .to_string(),
-            }),
-        }
-    }
-
-    pub fn new_read_builder(&self) -> SearchResultReadBuilder<'_> {
-        SearchResultReadBuilder {
-            result: self,
-            projection: None,
-        }
-    }
-}
-
-fn no_global_row_ids() -> crate::Error {
-    crate::Error::Unsupported {
-        message: "primary-key search results use physical file positions, not 
global row IDs"
-            .to_string(),
-    }
-}
-
-/// 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 search or replans its primary-key source files.
-#[derive(Debug, Clone)]
-pub struct SearchResultReadBuilder<'a> {
-    result: &'a SearchResult,
-    projection: Option<Vec<String>>,
-}
-
-impl SearchResultReadBuilder<'_> {
-    /// Select user columns in this order. Defaults to every user table column.
-    pub fn with_projection(&mut self, columns: &[&str]) -> &mut Self {
-        self.projection = Some(columns.iter().map(|name| 
name.to_string()).collect());
-        self
-    }
-
-    pub async fn read(&self) -> crate::Result<ArrowRecordBatchStream> {
-        let table = &self.result.table;
-        let core_options = CoreOptions::new(table.schema().options());
-        core_options.ensure_read_authorized()?;
-        match &self.result.hits {
-            SearchHits::DataEvolution {
-                vector_column,
-                hits,
-            } => {
-                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 } => {
-                let read_type = resolve_materialize_read_type(table, 
self.projection.as_deref())?;
-                let reader = DataFileReader::new(
-                    table.file_io().clone(),
-                    table.schema_manager().clone(),
-                    table.schema().id(),
-                    table.schema().fields().to_vec(),
-                    read_type,
-                    Vec::new(),
-                )
-                .with_table_options(table.schema().options().clone());
-                materialize_positions(positions, splits, &reader).await
-            }
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 623e1419..355b7694 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -494,7 +494,7 @@ pub struct DataSplit {
     partition: Arc<BinaryRow>,
     bucket: i32,
     bucket_path: Arc<str>,
-    total_buckets: i32,
+    total_buckets: Option<i32>,
     data_files: Arc<[DataFileMeta]>,
     /// Deletion file for each data file, same order as `data_files`.
     /// `None` at index `i` means no deletion file for `data_files[i]` 
(matches Java getDeletionFiles() / List<DeletionFile> with null elements).
@@ -504,6 +504,9 @@ pub struct DataSplit {
     /// Primary-key IndexedSplits use file-local physical positions, 
independent
     /// of row tracking.
     row_ranges: Option<Arc<[RowRange]>>,
+    /// Optional IndexedSplit scores, aligned to expanded row ranges.
+    #[serde(default)]
+    scores: Option<Arc<[f32]>>,
     /// Whether the split can be read raw, without the merge reader: its
     /// physical rows are exactly its logical rows (modulo deletion files).
     /// Mirrors Java `DataSplit#rawConvertible`.
@@ -565,7 +568,7 @@ impl DataSplit {
         &self.bucket_path
     }
     pub fn total_buckets(&self) -> i32 {
-        self.total_buckets
+        self.total_buckets.unwrap_or(1)
     }
 
     pub fn data_files(&self) -> &[DataFileMeta] {
@@ -581,10 +584,24 @@ impl DataSplit {
         self.row_ranges.as_deref()
     }
 
+    pub fn scores(&self) -> Option<&[f32]> {
+        self.scores.as_deref()
+    }
+
+    /// Attach scores in ascending expanded-range order, as in Java 
IndexedSplit.
+    pub fn with_scores(mut self, scores: Vec<f32>) -> crate::Result<Self> {
+        validate_scores(self.row_ranges(), &scores)?;
+        self.scores = Some(scores.into());
+        Ok(self)
+    }
+
     /// Narrow an already planned split without rebuilding or copying its file
     /// and deletion-vector metadata. Ranges use the same coordinate system as
     /// the existing `row_ranges` field.
     pub(crate) fn with_selected_row_ranges(mut self, ranges: Vec<RowRange>) -> 
Self {
+        // Scores describe the old selection; callers must supply scores for
+        // the new one explicitly rather than silently misaligning them.
+        self.scores = None;
         self.row_ranges = Some(ranges.into());
         self
     }
@@ -600,6 +617,7 @@ impl DataSplit {
             .as_ref()
             .map(|files| vec![files[file_index].clone()].into());
         split.row_ranges = None;
+        split.scores = None;
         split.raw_convertible = false;
         split
     }
@@ -797,8 +815,13 @@ impl DataSplit {
         out.extend_from_slice(&p);
         out.extend_from_slice(&self.bucket.to_be_bytes());
         write_java_utf(&mut out, &self.bucket_path)?;
-        out.push(1); // totalBuckets present (version >= 6)
-        out.extend_from_slice(&self.total_buckets.to_be_bytes());
+        match self.total_buckets {
+            Some(total) => {
+                out.push(1);
+                out.extend_from_slice(&total.to_be_bytes());
+            }
+            None => out.push(0),
+        }
         out.extend_from_slice(&0i32.to_be_bytes()); // deprecated beforeFiles 
count
         out.push(0); // beforeDeletionFiles = null list
         out.extend_from_slice(&(self.data_files.len() as i32).to_be_bytes());
@@ -935,7 +958,9 @@ impl DataSplit {
         if let Some(dels) = data_deletion_files {
             builder = builder.with_data_deletion_files(dels);
         }
-        builder.build()
+        let mut split = builder.build()?;
+        split.total_buckets = total_buckets;
+        Ok(split)
     }
 
     /// Java `SplitSerializer#serialize` frame: magic + version + type id + 
body. The cross-language
@@ -944,7 +969,7 @@ impl DataSplit {
     /// uses `QueryAuthSplit` (type 5), retaining the complete captured REST 
response. Byte-compatible with
     /// `compatibility/split-v1-data` / `split-v1-indexed`.
     pub fn serialize_split_v1(&self) -> crate::Result<Vec<u8>> {
-        self.serialize_split_v1_with_scores(None)
+        self.serialize_split_v1_with_scores(self.scores())
     }
 
     /// Serialize aligned vector scores using Java's IndexedSplit wire format.
@@ -975,18 +1000,7 @@ impl DataSplit {
         }
         self.ensure_serializable_without_grant()?;
         if let Some(scores) = scores {
-            let count = self.row_ranges.as_ref().and_then(|ranges| {
-                ranges.iter().try_fold(0usize, |count, range| {
-                    let len = 
range.to().checked_sub(range.from())?.checked_add(1)?;
-                    count.checked_add(usize::try_from(len).ok()?)
-                })
-            });
-            if count != Some(scores.len()) || scores.len() > i32::MAX as usize 
{
-                return Err(crate::Error::DataInvalid {
-                    message: "IndexedSplit scores must align with selected row 
ranges".to_string(),
-                    source: None,
-                });
-            }
+            validate_scores(self.row_ranges(), scores)?;
         }
         let mut out = Vec::new();
         out.extend_from_slice(&SPLIT_SER_MAGIC.to_be_bytes());
@@ -1025,7 +1039,7 @@ impl DataSplit {
     /// Reverse of [`DataSplit::serialize_split_v1`]: parse a Java 
`SplitSerializer` frame into a
     /// `DataSplit` (type 1), an `IndexedSplit` (type 3, which carries 
`row_ranges`), or a
     /// `QueryAuthSplit` (type 5, retaining the authorization-required marker 
without minting
-    /// a grant). Other type ids, and IndexedSplit vector scores, are 
unsupported.
+    /// a grant). IndexedSplit scores retain their range alignment. Other type 
ids are unsupported.
     /// Consumes the entire buffer.
     pub fn deserialize_split_v1(data: &[u8]) -> crate::Result<DataSplit> {
         let mut cur = data;
@@ -1105,14 +1119,21 @@ impl DataSplit {
                     }
                     ranges.push(RowRange::new(from, to));
                 }
-                let scores_flag = read_u8(cur)?;
-                if scores_flag != 0 {
-                    return Err(crate::Error::Unsupported {
-                        message: "IndexedSplit scores are not 
supported".into(),
-                    });
-                }
                 let mut body = body;
                 body.row_ranges = Some(ranges.into());
+                if read_u8(cur)? != 0 {
+                    let count = read_i32(cur)?;
+                    if count < 0 || count as usize > cur.len() / 4 {
+                        return Err(crate::Error::DataInvalid {
+                            message: "Invalid IndexedSplit scores 
count".into(),
+                            source: None,
+                        });
+                    }
+                    let scores = (0..count)
+                        .map(|_| read_i32(cur).map(|bits| f32::from_bits(bits 
as u32)))
+                        .collect::<crate::Result<Vec<_>>>()?;
+                    body = body.with_scores(scores)?;
+                }
                 body
             }
             other => {
@@ -1125,6 +1146,22 @@ impl DataSplit {
     }
 }
 
+fn validate_scores(ranges: Option<&[RowRange]>, scores: &[f32]) -> 
crate::Result<()> {
+    let count = ranges.and_then(|ranges| {
+        ranges.iter().try_fold(0usize, |count, range| {
+            let len = range.to().checked_sub(range.from())?.checked_add(1)?;
+            count.checked_add(usize::try_from(len).ok()?)
+        })
+    });
+    if count != Some(scores.len()) || scores.len() > i32::MAX as usize {
+        return Err(crate::Error::DataInvalid {
+            message: "IndexedSplit scores must align with selected row 
ranges".into(),
+            source: None,
+        });
+    }
+    Ok(())
+}
+
 /// Java `DataSplit#MAGIC` / `VERSION` for the serialize format.
 const SPLIT_MAGIC: i64 = -2394839472490812314;
 const SPLIT_VERSION: i32 = 9;
@@ -1538,10 +1575,11 @@ impl DataSplitBuilder {
             partition: Arc::new(partition),
             bucket: self.bucket,
             bucket_path: bucket_path.into(),
-            total_buckets: self.total_buckets,
+            total_buckets: Some(self.total_buckets),
             data_files: data_files.into(),
             data_deletion_files: self.data_deletion_files.map(Into::into),
             row_ranges: self.row_ranges.map(Into::into),
+            scores: None,
             raw_convertible: self.raw_convertible,
             is_streaming: self.is_streaming,
         })
@@ -2683,9 +2721,7 @@ mod tests {
             .is_err());
     }
 
-    // The scores-stripped IndexedSplit frame the Rust serializer emits (see
-    // `serialize_indexed_split_v1_matches_golden`): the Java golden's 
trailing vector scores are
-    // replaced by a `false` scores flag, which is the exact byte form 
`deserialize_split_v1` supports.
+    // A score-free frame derived from the Java fixture.
     fn indexed_split_v1_no_scores() -> Vec<u8> {
         let golden = include_bytes!("goldens/split_v1_indexed.bin");
         let scores_len = 1 + 4 + 3 * 4; // writeBoolean(true) + count + 3 
floats
@@ -2727,15 +2763,58 @@ mod tests {
     }
 
     #[test]
-    fn deserialize_split_v1_rejects_indexed_scores() {
-        // The raw Java golden carries vector scores, which the Rust split 
cannot model.
+    fn deserialize_split_v1_rejects_misaligned_indexed_scores() {
+        // This historical Java fixture has three scores for seven positions.
         let golden = include_bytes!("goldens/split_v1_indexed.bin");
         assert!(matches!(
             DataSplit::deserialize_split_v1(golden),
-            Err(crate::Error::Unsupported { .. })
+            Err(crate::Error::DataInvalid { .. })
         ));
     }
 
+    #[test]
+    fn indexed_scores_round_trip_and_reject_malformed_payloads() {
+        let split = 
DataSplit::deserialize_split_v1(&indexed_split_v1_no_scores()).unwrap();
+        let values = vec![0.5, 1.25, -2.0, 4.0, 5.0, 6.0, 7.0];
+        let split = split.with_scores(values.clone()).unwrap();
+        let bytes = split.serialize_split_v1().unwrap();
+        let restored = DataSplit::deserialize_split_v1(&bytes).unwrap();
+        assert_eq!(restored.scores(), Some(values.as_slice()));
+        assert_eq!(restored, split);
+        assert_eq!(restored.serialize_split_v1().unwrap(), bytes);
+        assert_eq!(
+            
serde_json::from_slice::<DataSplit>(&serde_json::to_vec(&split).unwrap()).unwrap(),
+            split
+        );
+        assert!(restored.clone().with_scores(vec![1.0]).is_err());
+        assert!(restored
+            .with_selected_row_ranges(vec![RowRange::new(1, 1)])
+            .scores()
+            .is_none());
+        let count_offset = bytes.len() - values.len() * 4 - 4;
+        for count in [-1, 0, 6, i32::MAX] {
+            let mut malformed = bytes.clone();
+            malformed[count_offset..count_offset + 
4].copy_from_slice(&count.to_be_bytes());
+            assert!(DataSplit::deserialize_split_v1(&malformed).is_err());
+        }
+        for length in count_offset..bytes.len() {
+            
assert!(DataSplit::deserialize_split_v1(&bytes[..length]).is_err());
+        }
+    }
+
+    #[test]
+    fn absent_total_buckets_round_trips_without_materializing_a_default() {
+        let mut split = v1_data_split_builder().build().unwrap();
+        let present_len = split.serialize_split_v1().unwrap().len();
+        split.total_buckets = None;
+        let bytes = split.serialize_split_v1().unwrap();
+        assert_eq!(bytes.len(), present_len - 4);
+        let restored = DataSplit::deserialize_split_v1(&bytes).unwrap();
+        assert_eq!(restored.total_buckets(), 1);
+        assert_eq!(restored.total_buckets, None);
+        assert_eq!(restored.serialize_split_v1().unwrap(), bytes);
+    }
+
     #[test]
     fn deserialize_split_v1_rejects_unsupported_type() {
         // hand-build a frame with type id 2
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 345021b3..c949080a 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -23,6 +23,7 @@
 use super::bucket_filter::compute_target_buckets;
 use super::chunk_shuffle::{chunk_shuffle_splits, ChunkShuffle};
 use super::format_table_scan::FormatTableScan;
+use super::global_index_result::GlobalIndexResult;
 use super::global_index_scanner::RowRangeIndex;
 use super::global_index_types::normalize_queryable_global_index_type;
 use super::kv_file_reader::retain_primary_key_conjuncts;
@@ -1269,6 +1270,25 @@ impl<'a> TableScan<'a> {
         }
     }
 
+    /// Supply an already evaluated global-index selection. PK split results
+    /// bypass snapshot resolution and further pruning, preserving their source
+    /// metadata exactly. Query-authorized scans ignore supplied selections,
+    /// since indexes describe raw values that masks may change.
+    pub fn with_global_index_result(mut self, result: GlobalIndexResult) -> 
crate::Result<Self> {
+        match &mut self.0 {
+            TableScanKind::Paimon(scan) => {
+                scan.split_selection
+                    .get_or_insert_with(Default::default)
+                    .global_index_result = Some(result);
+                scan.validate_global_index_result()?;
+                Ok(self)
+            }
+            TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "Format tables do not support global-index 
results".into(),
+            }),
+        }
+    }
+
     /// Select a half-open range of logical positions in an append snapshot.
     ///
     /// Data-evolution positions are assigned before group statistics, 
projection
@@ -1303,10 +1323,13 @@ impl<'a> TableScan<'a> {
                         message: "chunk_shuffle cannot combine with 
limit".to_string(),
                     });
                 }
-                if scan.row_ranges.is_some() || 
scan.row_position_selection().is_some() {
+                if scan.row_ranges.is_some()
+                    || scan.row_position_selection().is_some()
+                    || scan.global_index_result().is_some()
+                {
                     return Err(crate::Error::Unsupported {
                         message:
-                            "chunk_shuffle cannot combine with row ranges or 
positional selection"
+                            "chunk_shuffle cannot combine with row ranges, 
global index results or positional selection"
                                 .to_string(),
                     });
                 }
@@ -1328,6 +1351,7 @@ impl<'a> TableScan<'a> {
                     only_real_buckets: scan.only_real_buckets(),
                     snapshot_mode: scan.snapshot_mode(),
                     snapshot_levels: scan.snapshot_levels().cloned(),
+                    global_index_result: None,
                 }));
                 Ok(Self(TableScanKind::Paimon(scan)))
             }
@@ -1371,6 +1395,7 @@ impl<'a> TableScan<'a> {
                             only_real_buckets: false,
                             snapshot_mode: None,
                             snapshot_levels: None,
+                            global_index_result: None,
                         }));
                     }
                 }
@@ -1397,6 +1422,17 @@ impl<'a> TableScan<'a> {
         matches!(&self.0, TableScanKind::Paimon(scan) if 
scan.shard().is_some())
     }
 
+    pub(super) fn has_global_index_result(&self) -> bool {
+        matches!(&self.0, TableScanKind::Paimon(scan) if 
scan.global_index_result().is_some())
+    }
+
+    pub(super) fn attach_global_index_scores(&self, plan: Plan) -> 
crate::Result<Plan> {
+        match &self.0 {
+            TableScanKind::Paimon(scan) => scan.attach_index_scores(plan),
+            TableScanKind::Format(_) => Ok(plan),
+        }
+    }
+
     fn with_row_position_selection(self, selection: RowPositionSelection) -> 
crate::Result<Self> {
         match self.0 {
             TableScanKind::Paimon(mut scan) if 
scan.table.schema().primary_keys().is_empty() => {
@@ -1433,6 +1469,7 @@ impl<'a> TableScan<'a> {
                     only_real_buckets: scan.only_real_buckets(),
                     snapshot_mode: scan.snapshot_mode(),
                     snapshot_levels: scan.snapshot_levels().cloned(),
+                    global_index_result: scan.global_index_result().cloned(),
                 }));
                 Ok(Self(TableScanKind::Paimon(scan)))
             }
@@ -1564,6 +1601,7 @@ struct ScanSplitSelection {
     only_real_buckets: bool,
     snapshot_mode: Option<SnapshotScanMode>,
     snapshot_levels: Option<std::ops::RangeInclusive<i32>>,
+    global_index_result: Option<GlobalIndexResult>,
 }
 
 // A stream scan owns its table so clients can retain the scan across polls.
@@ -1614,6 +1652,52 @@ struct PaimonTableScan<'a> {
 }
 
 impl<'a> PaimonTableScan<'a> {
+    fn global_index_result(&self) -> Option<&GlobalIndexResult> {
+        self.split_selection
+            .as_deref()
+            .and_then(|s| s.global_index_result.as_ref())
+    }
+
+    fn validate_global_index_result(&self) -> crate::Result<()> {
+        let Some(result) = self.global_index_result() else {
+            return Ok(());
+        };
+        let options = self.table.schema().core_options();
+        let message = if self.row_ranges.is_some() {
+            Some("with_global_index_result and with_row_ranges are mutually 
exclusive")
+        } else if self.chunk_shuffle().is_some() {
+            Some("chunk_shuffle cannot combine with global index results")
+        } else if self.scan_all_files || self.is_streaming() {
+            Some("Global-index results require a batch data scan")
+        } else {
+            if result.is_split_result() && 
self.table.schema().primary_keys().is_empty() {
+                Some("Split-based global-index results require a primary-key 
table")
+            } else if !result.is_split_result() && 
!options.data_evolution_enabled() {
+                Some("Row-range global-index results require a Data Evolution 
table")
+            } else {
+                None
+            }
+        };
+        match message {
+            Some(message) => Err(crate::Error::Unsupported {
+                message: message.into(),
+            }),
+            None => Ok(()),
+        }
+    }
+
+    fn supplied_split_plan(&self) -> Option<Plan> {
+        if self.table.schema().core_options().query_auth_enabled() {
+            return None;
+        }
+        let result = self.global_index_result()?;
+        let mut plan = Plan::new(result.splits().ok()?.to_vec());
+        if let Some(id) = result.snapshot_id() {
+            plan = plan.with_snapshot_id(id);
+        }
+        Some(plan)
+    }
+
     fn snapshot_levels(&self) -> Option<&std::ops::RangeInclusive<i32>> {
         self.split_selection
             .as_deref()
@@ -1748,7 +1832,24 @@ impl<'a> PaimonTableScan<'a> {
     /// for `scan.version`; the strict selectors mirror Java's typed
     /// `scan.snapshot-id` / `scan.tag-name` handling.
     pub async fn plan(&self) -> crate::Result<Plan> {
+        let plan = self.plan_selection().await?;
+        self.attach_index_scores(plan)
+    }
+
+    fn attach_index_scores(&self, plan: Plan) -> crate::Result<Plan> {
+        match self.global_index_result() {
+            Some(result) if 
!self.table.schema().core_options().query_auth_enabled() => {
+                result.attach_scores(plan)
+            }
+            _ => Ok(plan),
+        }
+    }
+
+    async fn plan_selection(&self) -> crate::Result<Plan> {
         let scan = self.authorized_scan().await?;
+        if let Some(plan) = scan.supplied_split_plan() {
+            return Ok(plan);
+        }
         if let Some((start, end)) = scan
             .table
             .schema()
@@ -1771,11 +1872,26 @@ impl<'a> PaimonTableScan<'a> {
 
     /// Plan the full scan and return metadata-pruning trace counters.
     pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
+        let (plan, trace) = self.plan_selection_with_trace().await?;
+        Ok((self.attach_index_scores(plan)?, trace))
+    }
+
+    async fn plan_selection_with_trace(&self) -> crate::Result<(Plan, 
ScanTrace)> {
         let scan = self.authorized_scan().await?;
         let mut trace = ScanTrace {
             limit: scan.limit,
             ..Default::default()
         };
+        if let Some(plan) = scan.supplied_split_plan() {
+            trace.snapshot_id = plan.snapshot_id();
+            trace.planned_data_file_bytes = plan.planned_data_file_bytes();
+            trace.record_final_plan(
+                plan.splits().len(),
+                plan.splits().len(),
+                plan.splits().iter().map(|s| s.data_files().len()).sum(),
+            );
+            return Ok((plan, trace));
+        }
         if let Some((start, end)) = scan
             .table
             .schema()
@@ -1946,6 +2062,7 @@ impl<'a> PaimonTableScan<'a> {
     }
 
     async fn authorized_scan(&self) -> crate::Result<Cow<'_, Self>> {
+        self.validate_global_index_result()?;
         // Timestamp-window planning delegates to combined incremental planning
         // within the same operation. Reuse its scope, not another REST 
request.
         if self.query_auth_grant.is_some() {
@@ -1955,7 +2072,21 @@ impl<'a> PaimonTableScan<'a> {
         let grant = self.authorize_query().await?;
         Ok(match grant {
             Some(grant) => Cow::Owned(self.restricted_by(&grant)),
-            None => Cow::Borrowed(self),
+            None => match self
+                .global_index_result()
+                .map(GlobalIndexResult::row_ranges)
+                .transpose()?
+                .flatten()
+            {
+                Some(ranges) => {
+                    let mut scan = self.clone();
+                    scan.row_ranges = Some(ranges);
+                    // The selection is now an ordinary row-ID restriction.
+                    scan.split_selection.as_mut().unwrap().global_index_result 
= None;
+                    Cow::Owned(scan)
+                }
+                _ => Cow::Borrowed(self),
+            },
         })
     }
 
diff --git a/crates/paimon/src/table/vector_read.rs 
b/crates/paimon/src/table/vector_read.rs
index be2c55f0..c8ae740d 100644
--- a/crates/paimon/src/table/vector_read.rs
+++ b/crates/paimon/src/table/vector_read.rs
@@ -24,7 +24,7 @@ use 
crate::table::pk_vector_search_params::PkVectorSearchParams;
 use crate::table::vector_scan::{PlanContext, VectorScanPlan, VectorScanWork};
 use crate::table::vector_search_common::{take_only_result, 
targets_primary_key_column};
 use crate::table::Table;
-use crate::vector_search::SearchResult;
+use crate::vector_search::GlobalIndexResult;
 use std::collections::HashMap;
 use std::future::Future;
 
@@ -38,7 +38,7 @@ pub(super) trait Read: Sync {
     fn read(
         &self,
         plan: Self::Plan,
-    ) -> impl Future<Output = crate::Result<Vec<SearchResult>>> + Send;
+    ) -> impl Future<Output = crate::Result<Vec<GlobalIndexResult>>> + Send;
 }
 
 /// Searches a common plan with one query, without replanning or materializing 
rows.
@@ -47,7 +47,7 @@ pub struct VectorRead {
 }
 
 impl VectorRead {
-    pub async fn read(&self, plan: VectorScanPlan) -> 
crate::Result<SearchResult> {
+    pub async fn read(&self, plan: VectorScanPlan) -> 
crate::Result<GlobalIndexResult> {
         take_only_result(self.batch.read(plan).await?, "vector search")
     }
 }
@@ -88,7 +88,7 @@ impl BatchVectorRead {
         Ok(Self { context, reader })
     }
 
-    pub async fn read(&self, plan: VectorScanPlan) -> 
crate::Result<Vec<SearchResult>> {
+    pub async fn read(&self, plan: VectorScanPlan) -> 
crate::Result<Vec<GlobalIndexResult>> {
         if self.context != plan.context {
             return Err(crate::Error::DataInvalid {
                 message: "vector plan and reader must use the same table, 
column and pre-filter"
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 8614795b..d8fc37f8 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -22,7 +22,7 @@ use crate::table::vector_read::{BatchVectorRead, VectorRead};
 use crate::table::vector_scan::{PlanContext, VectorScan};
 use crate::table::vector_search_common::{add_filter, 
validate_partition_filter};
 use crate::table::Table;
-use crate::vector_search::SearchResult;
+use crate::vector_search::GlobalIndexResult;
 use std::collections::HashMap;
 
 pub struct VectorSearchBuilder<'a> {
@@ -119,7 +119,7 @@ impl<'a> VectorSearchBuilder<'a> {
 
     /// Search locally using the same Scan -> Plan -> Read API exposed to 
engines.
     /// Use the result's `new_read_builder()` to materialize projected columns.
-    pub async fn execute(&self) -> crate::Result<SearchResult> {
+    pub async fn execute(&self) -> crate::Result<GlobalIndexResult> {
         let read = self.new_read()?;
         read.read(self.new_scan()?.plan().await?).await
     }
diff --git a/crates/paimon/src/table/vector_search_builder/tests.rs 
b/crates/paimon/src/table/vector_search_builder/tests.rs
index 3638254e..40b27ade 100644
--- a/crates/paimon/src/table/vector_search_builder/tests.rs
+++ b/crates/paimon/src/table/vector_search_builder/tests.rs
@@ -614,7 +614,7 @@ async fn 
result_read_empty_plan_lumina_array_float_is_admitted() {
 #[tokio::test]
 async fn result_read_projection_reserved_name_fails_loud() {
     // Projecting a reserved metadata / row-id column must fail loud. The guard
-    // lives in `resolve_materialize_read_type`, which 
`SearchResultReadBuilder::read` invokes
+    // lives in `resolve_materialize_read_type`, which 
`GlobalIndexResultReadBuilder::read` invokes
     // before the empty-plan early return; assert on the resolver directly 
here.
     let table = pk_vector_table(&[
         ("pk-vector.index.columns", "embedding"),
diff --git a/crates/paimon/src/vector_search.rs 
b/crates/paimon/src/vector_search.rs
index 1ebc3f71..dc532785 100644
--- a/crates/paimon/src/vector_search.rs
+++ b/crates/paimon/src/vector_search.rs
@@ -15,9 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub use crate::table::search_result::{
-    PrimaryKeySearchPosition, SearchResult, SearchResultReadBuilder,
-};
+pub use crate::table::{GlobalIndexResult, GlobalIndexResultReadBuilder, 
PrimaryKeySearchPosition};
 
 use std::cmp::Ordering;
 use std::collections::{BinaryHeap, HashMap};
@@ -116,7 +114,7 @@ impl GlobalIndexIOMeta {
 }
 
 /// Global row IDs and aligned scores used by DE index evaluation and ranking.
-/// Table searches return [`SearchResult`], which also represents PK file 
positions.
+/// Table searches return [`GlobalIndexResult`], which also represents PK file 
positions.
 #[derive(Debug, Clone)]
 pub struct ScoredRowIds {
     pub row_ids: Vec<u64>,
diff --git a/crates/paimon/tests/pk_vector_batch_test.rs 
b/crates/paimon/tests/pk_vector_batch_test.rs
index c7fcb32d..9181939d 100644
--- a/crates/paimon/tests/pk_vector_batch_test.rs
+++ b/crates/paimon/tests/pk_vector_batch_test.rs
@@ -373,7 +373,7 @@ async fn drain_ids_and_scores(stream: 
ArrowRecordBatchStream) -> (Vec<i32>, Vec<
     (ids, scores)
 }
 
-/// Single-query `SearchResultReadBuilder::read` into `(id, score)` tuples.
+/// Single-query `GlobalIndexResultReadBuilder::read` into `(id, score)` 
tuples.
 async fn single_read(table: &Table, query: Vec<f32>, limit: usize) -> 
(Vec<i32>, Vec<f32>) {
     let mut builder = table.new_vector_search_builder();
     builder

Reply via email to