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 5437659  [core] Support scalar residual filter on primary-key vector 
search (#533)
5437659 is described below

commit 54376590893b02625dfd50989bb79fb178731b9a
Author: Junrui Lee <[email protected]>
AuthorDate: Sat Jul 18 10:21:08 2026 +0800

    [core] Support scalar residual filter on primary-key vector search (#533)
---
 crates/paimon/src/spec/core_options.rs            |  12 +
 crates/paimon/src/table/pk_vector_orchestrator.rs | 160 ++++-
 crates/paimon/src/table/pk_vector_scan.rs         | 286 ++++++++-
 crates/paimon/src/table/vector_search_builder.rs  | 722 +++++++++++++++++++++-
 crates/paimon/src/vindex/pkvector/ann.rs          | 222 ++++++-
 crates/paimon/src/vindex/pkvector/bucket.rs       | 213 ++++++-
 crates/paimon/tests/pk_vector_baseline_test.rs    | 239 +++++++
 7 files changed, 1820 insertions(+), 34 deletions(-)

diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 277f04c..d186519 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -18,6 +18,7 @@
 use std::collections::{HashMap, HashSet};
 
 const DELETION_VECTORS_ENABLED_OPTION: &str = "deletion-vectors.enabled";
+const DELETION_VECTORS_MERGE_ON_READ_OPTION: &str = 
"deletion-vectors.merge-on-read";
 pub(crate) const QUERY_AUTH_ENABLED_OPTION: &str = "query-auth.enabled";
 const DATA_EVOLUTION_ENABLED_OPTION: &str = "data-evolution.enabled";
 const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
@@ -369,6 +370,17 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or(false)
     }
 
+    /// Whether `deletion-vectors.merge-on-read` is set (default `false`, 
matching
+    /// Java `CoreOptions.DELETION_VECTORS_MERGE_ON_READ`). When true, 
uncompacted
+    /// (level-0) data is made visible by merging on read; when false, deletion
+    /// vectors alone determine live rows over the compacted files.
+    pub fn deletion_vectors_merge_on_read(&self) -> bool {
+        self.options
+            .get(DELETION_VECTORS_MERGE_ON_READ_OPTION)
+            .map(|value| value.eq_ignore_ascii_case("true"))
+            .unwrap_or(false)
+    }
+
     /// Whether `query-auth.enabled` is set.
     ///
     /// When set, the server enforces a per-user row filter / column masking 
that this client
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs 
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index 59ee64c..6004a98 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -26,6 +26,8 @@ use std::cmp::Ordering;
 use std::collections::HashMap;
 use std::sync::Arc;
 
+use roaring::RoaringTreemap;
+
 use crate::deletion_vector::DeletionVector;
 use crate::spec::BinaryRow;
 use crate::table::data_file_reader::DataFileReader;
@@ -275,6 +277,13 @@ impl PkVectorOrchestrator {
     /// preserved). The exact-reader factory is split-scoped: it receives the
     /// current split index and split so a caller can build a reader keyed to 
the
     /// specific split/file. `skip_exact_fallback` forwards to `bucket_search`.
+    ///
+    /// `residual_by_split`, when present, carries one per-file allow-list of
+    /// physical row positions per split (indexed parallel to `splits`): only
+    /// positions listed for a file may survive that bucket's search. A file
+    /// absent from its split's map (or mapped to an empty set) contributes no
+    /// candidates. `None` applies no residual filtering. The slice must have 
the
+    /// same length as `splits`.
     #[allow(clippy::too_many_arguments)]
     #[allow(clippy::type_complexity)]
     pub(crate) async fn search_candidates(
@@ -292,6 +301,7 @@ impl PkVectorOrchestrator {
                   + Send),
         search_options: &HashMap<String, String>,
         skip_exact_fallback: bool,
+        residual_by_split: Option<&[HashMap<String, RoaringTreemap>]>,
     ) -> crate::Result<Vec<PkVectorCandidate>> {
         // Eager input-shape validation (Java checkArgument parity).
         if limit == 0 {
@@ -300,6 +310,13 @@ impl PkVectorOrchestrator {
         if query.is_empty() {
             return Err(data_invalid("vector search query must not be empty"));
         }
+        if let Some(per_split) = residual_by_split {
+            if per_split.len() != splits.len() {
+                return Err(data_invalid(
+                    "residual range map count does not match split count",
+                ));
+            }
+        }
 
         // Eager per-bucket search -> tagged candidates.
         let mut candidates: Vec<PkVectorCandidate> = Vec::new();
@@ -308,6 +325,7 @@ impl PkVectorOrchestrator {
             // Wrap the split-scoped factory into bucket_search's per-file 
signature.
             let mut bucket_factory =
                 |file: &BucketActiveFile| exact_reader_factory(split_index, 
split, file);
+            let residual_ranges = residual_by_split.map(|per_split| 
&per_split[split_index]);
             let results = bucket_search(
                 ann_searcher,
                 &split.ann_segments,
@@ -319,6 +337,7 @@ impl PkVectorOrchestrator {
                 limit,
                 search_options,
                 skip_exact_fallback,
+                residual_ranges,
             )?;
             for PkVectorSearchResult {
                 data_file_name,
@@ -849,6 +868,7 @@ mod e2e_tests {
             _active_source_files: &HashSet<String>,
             _dvs: &HashMap<String, Arc<DeletionVector>>,
             _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
         ) -> crate::Result<Vec<PkVectorSearchResult>> {
             Ok(self.hits.clone())
         }
@@ -875,7 +895,17 @@ mod e2e_tests {
         // expects; the split index/split are unused here.
         let mut wrapped = |_: usize, _: &PkVectorSearchSplit, f: 
&BucketActiveFile| factory(f);
         let survivors = orch
-            .search_candidates(splits, query, metric, limit, ann, &mut 
wrapped, opts, false)
+            .search_candidates(
+                splits,
+                query,
+                metric,
+                limit,
+                ann,
+                &mut wrapped,
+                opts,
+                false,
+                None,
+            )
             .await?;
         let indexed_splits = build_indexed_splits(survivors, splits, metric)?;
         let mut out = Vec::new();
@@ -911,6 +941,7 @@ mod e2e_tests {
                 &mut factory,
                 &opts,
                 false,
+                None,
             )
             .await
             .map(|_| ())
@@ -940,6 +971,7 @@ mod e2e_tests {
                 &mut factory,
                 &opts,
                 false,
+                None,
             )
             .await
             .map(|_| ())
@@ -1276,6 +1308,7 @@ mod e2e_tests {
                 &mut factory,
                 &opts,
                 false,
+                None,
             )
             .await
             .unwrap();
@@ -1289,6 +1322,130 @@ mod e2e_tests {
         );
     }
 
+    #[tokio::test]
+    async fn search_candidates_applies_residual_ranges() {
+        // Same single bucket / three rows as above, but a per-split residual 
map
+        // allows only physical positions {0, 2}. The best recalled hit (pos1,
+        // d=1) is filtered out; the survivors are the allowed positions in
+        // best-first order: pos2 (d=4) then pos0 (d=9). This proves the 
residual
+        // allow-list is threaded through to the bucket search rather than 
merely
+        // stored.
+        let table_path = "memory:/pkvo_residual";
+        let bucket_path = format!("{table_path}/bucket-0");
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let meta = write_file(&file_io, &bucket_path, "r.mosaic", vec![1, 2, 
3]).await;
+        let split = PkVectorSearchSplit {
+            data_split: DataSplitBuilder::new()
+                .with_snapshot(1)
+                .with_partition(BinaryRow::new(0))
+                .with_bucket(0)
+                .with_bucket_path(bucket_path)
+                .with_total_buckets(1)
+                .with_data_files(vec![meta])
+                .build()
+                .unwrap(),
+            ann_segments: Vec::new(),
+            active_files: vec![active("r.mosaic", 3)],
+        };
+        // pos0 {3,0} d=9, pos1 {1,0} d=1, pos2 {2,0} d=4.
+        let mut factory = |_: usize,
+                           _: &PkVectorSearchSplit,
+                           f: &BucketActiveFile|
+         -> crate::Result<Box<dyn PkVectorReader>> {
+            assert_eq!(f.file_name, "r.mosaic");
+            Ok(Box::new(ArrayReader::new(
+                2,
+                vec![
+                    Some(vec![3.0, 0.0]),
+                    Some(vec![1.0, 0.0]),
+                    Some(vec![2.0, 0.0]),
+                ],
+            )))
+        };
+        // Allow only positions 0 and 2 for "r.mosaic"; pos1 (the best hit) is
+        // excluded by the residual.
+        let mut allowed = RoaringTreemap::new();
+        allowed.insert(0);
+        allowed.insert(2);
+        let residual_by_split = vec![HashMap::from([("r.mosaic".to_string(), 
allowed)])];
+        let opts = HashMap::new();
+        let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
+            .search_candidates(
+                &[split],
+                &[0.0, 0.0],
+                VectorSearchMetric::L2,
+                3,
+                None,
+                &mut factory,
+                &opts,
+                false,
+                Some(&residual_by_split),
+            )
+            .await
+            .unwrap();
+        // Best-first among allowed positions: pos2 (d=4) then pos0 (d=9).
+        assert_eq!(
+            cands
+                .iter()
+                .map(|c| (c.row_position, c.distance))
+                .collect::<Vec<_>>(),
+            vec![(2, 4.0), (0, 9.0)]
+        );
+    }
+
+    #[tokio::test]
+    async fn search_candidates_rejects_residual_length_mismatch() {
+        // A residual slice whose length differs from the split count is a 
caller
+        // bug (the map is indexed by split); it must fail loud rather than 
panic
+        // or silently misalign.
+        let table_path = "memory:/pkvo_residual_mismatch";
+        let bucket_path = format!("{table_path}/bucket-0");
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let meta = write_file(&file_io, &bucket_path, "m.mosaic", vec![1, 
2]).await;
+        let split = PkVectorSearchSplit {
+            data_split: DataSplitBuilder::new()
+                .with_snapshot(1)
+                .with_partition(BinaryRow::new(0))
+                .with_bucket(0)
+                .with_bucket_path(bucket_path)
+                .with_total_buckets(1)
+                .with_data_files(vec![meta])
+                .build()
+                .unwrap(),
+            ann_segments: Vec::new(),
+            active_files: vec![active("m.mosaic", 2)],
+        };
+        let mut factory = |_: usize,
+                           _: &PkVectorSearchSplit,
+                           _: &BucketActiveFile|
+         -> crate::Result<Box<dyn PkVectorReader>> {
+            unreachable!("length guard must fire before any bucket search")
+        };
+        // Two residual maps for a single split.
+        let residual_by_split: Vec<HashMap<String, RoaringTreemap>> =
+            vec![HashMap::new(), HashMap::new()];
+        let opts = HashMap::new();
+        let err = PkVectorOrchestrator::new(make_reader(file_io, table_path))
+            .search_candidates(
+                &[split],
+                &[0.0, 0.0],
+                VectorSearchMetric::L2,
+                3,
+                None,
+                &mut factory,
+                &opts,
+                false,
+                Some(&residual_by_split),
+            )
+            .await
+            .map(|_| ())
+            .expect_err("residual length mismatch must fail loud");
+        assert!(
+            format!("{err:?}").contains("does not match split count"),
+            "got: {err:?}"
+        );
+    }
+
     #[tokio::test]
     async fn search_candidates_fast_mode_skips_exact_factory() {
         let table_path = "memory:/pkvo_fast";
@@ -1325,6 +1482,7 @@ mod e2e_tests {
                 &mut factory,
                 &opts,
                 true,
+                None,
             )
             .await
             .unwrap();
diff --git a/crates/paimon/src/table/pk_vector_scan.rs 
b/crates/paimon/src/table/pk_vector_scan.rs
index a8219fd..aebea91 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -24,7 +24,7 @@ use std::collections::{BTreeMap, HashSet};
 
 use crate::spec::{
     BinaryRow, DataFileMeta, FileKind, GlobalIndexMeta, IndexManifest, 
PkVectorSourceFile,
-    PkVectorSourceMeta,
+    PkVectorSourceMeta, Predicate,
 };
 use crate::table::pk_vector_orchestrator::PkVectorSearchSplit;
 use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
@@ -204,14 +204,21 @@ pub(crate) struct PkVectorScan<'a> {
     table: &'a Table,
     vector_field_id: i32,
     index_type: String,
+    filter: Option<Predicate>,
 }
 
 impl<'a> PkVectorScan<'a> {
-    pub(crate) fn new(table: &'a Table, vector_field_id: i32, index_type: 
String) -> Self {
+    pub(crate) fn new(
+        table: &'a Table,
+        vector_field_id: i32,
+        index_type: String,
+        filter: Option<Predicate>,
+    ) -> Self {
         Self {
             table,
             vector_field_id,
             index_type,
+            filter,
         }
     }
 
@@ -226,9 +233,17 @@ impl<'a> PkVectorScan<'a> {
         // index from it). It also avoids a time-travel mismatch (data from the
         // travelled snapshot, index from latest) and a TOCTOU where a 
concurrent
         // commit lands between two independent resolutions.
-        let data_splits = self
-            .table
-            .new_read_builder()
+        //
+        // The residual scalar filter, when set, is pushed into the read 
builder so
+        // scan planning drops files whose stats cannot match the predicate, 
mirroring
+        // Java `PrimaryKeyVectorScan` applying the filter at scan time. Files 
that
+        // survive are still residual-filtered per row downstream; this only 
avoids
+        // re-reading files the predicate already excludes.
+        let mut read_builder = self.table.new_read_builder();
+        if let Some(filter) = &self.filter {
+            read_builder.with_filter(filter.clone());
+        }
+        let data_splits = read_builder
             .new_scan()
             .with_scan_all_files()
             .plan()
@@ -613,4 +628,265 @@ mod tests {
         // Both files are COMPACT + level>0, so both appear as active files.
         assert_eq!(splits[0].active_files.len(), 2);
     }
+
+    // ---- Real-table planning tests for filter push-down ----
+    //
+    // Gated off Windows: these fixtures build a table at a `file://` URL 
derived
+    // from a temp dir path, which `FileIO` cannot resolve on Windows (see 
#397).
+    #[cfg(not(windows))]
+    mod prune_pushdown_tests {
+        use super::*;
+        use crate::catalog::Identifier;
+        use crate::io::{FileIO, FileIOBuilder};
+        use crate::spec::stats::compute_column_stats;
+        use crate::spec::{
+            DataType, Datum, FloatType, IntType, PredicateBuilder, Schema, 
TableSchema, VectorType,
+        };
+        use crate::table::{CommitMessage, SchemaManager, Table, TableCommit, 
TableWrite};
+        use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
+        use arrow_array::{ArrayRef, Int32Array, RecordBatch};
+        use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, 
Schema as ArrowSchema};
+        use std::sync::Arc;
+
+        /// Vector dimension for the pruning fixtures.
+        const PRUNE_DIM: usize = 4;
+        /// The primary-key vector column name.
+        const PRUNE_VECTOR_COLUMN: &str = "embedding";
+        /// vindex index type string; only used to route `PkVectorScan::new`, 
no index
+        /// segment is built for these tests.
+        const PRUNE_INDEX_TYPE: &str = "ivf-flat";
+        /// Number of rows written; `id`/`score` values live in 
`0..PRUNE_ROWS`.
+        const PRUNE_ROWS: i32 = 4;
+        /// A predicate literal guaranteed to fall outside the written 
`id`/`score`
+        /// range, so file stats cannot match it.
+        const OUT_OF_RANGE: i32 = 1_000_000;
+
+        /// Schema `(id INT PRIMARY KEY, score INT, embedding VECTOR<FLOAT>)`. 
When
+        /// `with_deletion_vectors`, enable deletion vectors (merge-on-read 
left at the
+        /// default `false`) so a non-PK scalar predicate also stats-prunes; 
otherwise a
+        /// plain PK table where only PK-column conjuncts prune.
+        fn prune_schema(with_deletion_vectors: bool) -> TableSchema {
+            let mut builder = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("score", DataType::Int(IntType::new()))
+                .column(
+                    PRUNE_VECTOR_COLUMN,
+                    DataType::Vector(
+                        VectorType::try_new(
+                            true,
+                            PRUNE_DIM as u32,
+                            DataType::Float(FloatType::new()),
+                        )
+                        .unwrap(),
+                    ),
+                )
+                .primary_key(["id"])
+                .option("bucket".to_string(), "1".to_string());
+            if with_deletion_vectors {
+                builder =
+                    builder.option("deletion-vectors.enabled".to_string(), 
"true".to_string());
+            }
+            TableSchema::new(0, &builder.build().unwrap())
+        }
+
+        /// Arrow batch matching the schema: `id` and `score` both equal the 
physical
+        /// position (`0..n`), plus a `FixedSizeList<Float32>` vector column.
+        fn prune_data_batch(n: usize) -> RecordBatch {
+            let ids: Vec<i32> = (0..n as i32).collect();
+            let scores: Vec<i32> = (0..n as i32).collect();
+
+            let element_field = Arc::new(ArrowField::new("element", 
ArrowDataType::Float32, true));
+            let mut vector_builder =
+                FixedSizeListBuilder::new(Float32Builder::new(), PRUNE_DIM as 
i32)
+                    .with_field(element_field.clone());
+            for i in 0..n {
+                for d in 0..PRUNE_DIM {
+                    vector_builder.values().append_value((i + d) as f32);
+                }
+                vector_builder.append(true);
+            }
+
+            let schema = Arc::new(ArrowSchema::new(vec![
+                ArrowField::new("id", ArrowDataType::Int32, false),
+                ArrowField::new("score", ArrowDataType::Int32, false),
+                ArrowField::new(
+                    PRUNE_VECTOR_COLUMN,
+                    ArrowDataType::FixedSizeList(element_field, PRUNE_DIM as 
i32),
+                    true,
+                ),
+            ]));
+            RecordBatch::try_new(
+                schema,
+                vec![
+                    Arc::new(Int32Array::from(ids)) as ArrayRef,
+                    Arc::new(Int32Array::from(scores)) as ArrayRef,
+                    Arc::new(vector_builder.finish()) as ArrayRef,
+                ],
+            )
+            .unwrap()
+        }
+
+        async fn prune_open_table(file_io: &FileIO, location: &str) -> Table {
+            let schema = SchemaManager::new(file_io.clone(), 
location.to_string())
+                .latest()
+                .await
+                .expect("failed to list schemas")
+                .expect("table has no schema");
+            Table::new(
+                file_io.clone(),
+                Identifier::new("default", "pkvector_prune"),
+                location.to_string(),
+                (*schema).clone(),
+                None,
+            )
+        }
+
+        /// Build a real single-file primary-key table via the public write 
path, in a
+        /// fresh temp dir. Persists the schema and writes one data batch, 
then commits
+        /// the written data file with real `value_stats` for the `id`/`score` 
columns.
+        ///
+        /// The stats injection mirrors the meta-modification the baseline 
fixture uses
+        /// for `level`/`file_source`: the Rust key-value (primary-key) writer 
records
+        /// column stats in `key_stats` and leaves `value_stats` empty, but 
scan-time
+        /// file pruning reads `value_stats`. Java primary-key writers 
populate value
+        /// stats, so committing them here makes the file prunable exactly as 
it would be
+        /// in a table written by the Java engine. Returns the temp dir (kept 
alive by
+        /// the caller) and the opened table.
+        async fn build_pruning_test_table(
+            with_deletion_vectors: bool,
+        ) -> (tempfile::TempDir, Table) {
+            let tmp = tempfile::tempdir().expect("create temp dir");
+            let location = format!("file://{}", tmp.path().display());
+            let file_io = FileIOBuilder::new("file").build().unwrap();
+
+            for dir in ["schema", "snapshot", "manifest", "index"] {
+                file_io.mkdirs(&format!("{location}/{dir}")).await.unwrap();
+            }
+            let schema = prune_schema(with_deletion_vectors);
+            file_io
+                .new_output(&format!("{location}/schema/schema-{}", 
schema.id()))
+                .unwrap()
+                
.write(bytes::Bytes::from(serde_json::to_vec(&schema).unwrap()))
+                .await
+                .unwrap();
+
+            let table = prune_open_table(&file_io, &location).await;
+
+            let batch = prune_data_batch(PRUNE_ROWS as usize);
+            let mut writer = TableWrite::new(&table, 
"pkvector-prune".to_string()).unwrap();
+            writer.write_arrow_batch(&batch).await.unwrap();
+            let messages = writer.prepare_commit().await.unwrap();
+            assert_eq!(messages.len(), 1, "single bucket -> one write 
message");
+            let written = &messages[0];
+            assert_eq!(written.new_files.len(), 1, "single data file 
expected");
+            let base_meta = written.new_files[0].clone();
+            let bucket = written.bucket;
+            let partition = written.partition.clone();
+
+            // Real value stats over the `id` (col 0) and `score` (col 1) 
columns, so a
+            // predicate outside the written [0, PRUNE_ROWS) range can prune 
the file.
+            let int = DataType::Int(IntType::new());
+            let value_stats: BinaryTableStats =
+                compute_column_stats(&batch, &[0, 1], &[int.clone(), 
int]).unwrap();
+            let indexed_meta = DataFileMeta {
+                value_stats,
+                value_stats_cols: Some(vec!["id".to_string(), 
"score".to_string()]),
+                ..base_meta
+            };
+
+            let message = CommitMessage::new(partition, bucket, 
vec![indexed_meta]);
+            TableCommit::new(table.clone(), "pkvector-prune".to_string())
+                .commit(vec![message])
+                .await
+                .unwrap();
+
+            (tmp, table)
+        }
+
+        fn prune_vector_field_id(table: &Table) -> i32 {
+            table
+                .schema()
+                .fields()
+                .iter()
+                .find(|f| f.name() == PRUNE_VECTOR_COLUMN)
+                .expect("vector field present")
+                .id()
+        }
+
+        fn prune_equal(table: &Table, column: &str, value: i32) -> Predicate {
+            PredicateBuilder::new(table.schema().fields())
+                .equal(column, Datum::Int(value))
+                .unwrap()
+        }
+
+        #[tokio::test]
+        async fn plan_prunes_file_when_pk_predicate_excludes_it() {
+            // Real PK table, one data file with id in [0, PRUNE_ROWS). A 
predicate
+            // `id = OUT_OF_RANGE` cannot match the file's id stats, so the 
scan drops
+            // the file and plan() returns no splits. Control (no filter) 
returns one.
+            let (_tmp, table) = build_pruning_test_table(false).await;
+            let field_id = prune_vector_field_id(&table);
+
+            let unfiltered =
+                PkVectorScan::new(&table, field_id, 
PRUNE_INDEX_TYPE.to_string(), None)
+                    .plan()
+                    .await
+                    .unwrap();
+            assert_eq!(
+                unfiltered.splits.len(),
+                1,
+                "control: file present without a filter"
+            );
+
+            let out_of_range = prune_equal(&table, "id", OUT_OF_RANGE);
+            let filtered = PkVectorScan::new(
+                &table,
+                field_id,
+                PRUNE_INDEX_TYPE.to_string(),
+                Some(out_of_range),
+            )
+            .plan()
+            .await
+            .unwrap();
+            assert!(
+                filtered.splits.is_empty(),
+                "pk predicate stats-excludes the only file"
+            );
+        }
+
+        #[tokio::test]
+        async fn plan_prunes_file_on_non_pk_predicate_under_deletion_vectors() 
{
+            // Under deletion vectors (merge-on-read off), a non-PK column's 
stats also
+            // prune. A `score` predicate outside the written range drops the 
file.
+            let (_tmp, table) = build_pruning_test_table(true).await;
+            let field_id = prune_vector_field_id(&table);
+
+            // Control: without a filter the file is present.
+            let unfiltered =
+                PkVectorScan::new(&table, field_id, 
PRUNE_INDEX_TYPE.to_string(), None)
+                    .plan()
+                    .await
+                    .unwrap();
+            assert_eq!(
+                unfiltered.splits.len(),
+                1,
+                "control: file present without a filter"
+            );
+
+            let out_of_range = prune_equal(&table, "score", OUT_OF_RANGE);
+            let filtered = PkVectorScan::new(
+                &table,
+                field_id,
+                PRUNE_INDEX_TYPE.to_string(),
+                Some(out_of_range),
+            )
+            .plan()
+            .await
+            .unwrap();
+            assert!(
+                filtered.splits.is_empty(),
+                "non-pk predicate stats-excludes the file under deletion 
vectors"
+            );
+        }
+    }
 }
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 122af04..facc1f9 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -15,12 +15,14 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::arrow::format::FilePredicates;
+use crate::arrow::residual::{filter_record_batch_by_predicates, 
widen_scan_fields};
 use crate::io::FileIO;
 use crate::lumina::reader::LuminaVectorGlobalIndexReader;
 use crate::lumina::{is_lumina_index_type, LuminaIndexMeta, LuminaVectorMetric};
 use crate::spec::{
-    CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta, 
IndexManifest,
-    IndexManifestEntry, ROW_ID_FIELD_NAME,
+    BigIntType, CoreOptions, DataField, DataType, FileKind, 
GlobalIndexSearchMode, IndexFileMeta,
+    IndexManifest, IndexManifestEntry, Predicate, ROW_ID_FIELD_ID, 
ROW_ID_FIELD_NAME,
 };
 use crate::table::data_file_reader::DataFileReader;
 use crate::table::global_index_scanner::{
@@ -38,6 +40,7 @@ use crate::table::pk_vector_position_read::{
 };
 use crate::table::pk_vector_scan::{PkVectorScan, PkVectorScanPlan};
 use crate::table::read_builder::resolve_projected_fields;
+use crate::table::source::DataSplit;
 use crate::table::{
     find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange, 
Table,
 };
@@ -92,6 +95,7 @@ pub struct VectorSearchBuilder<'a> {
     limit: Option<usize>,
     options: HashMap<String, String>,
     projection: Option<Vec<String>>,
+    filter: Option<Predicate>,
 }
 
 pub struct BatchVectorSearchBuilder<'a> {
@@ -111,6 +115,7 @@ impl<'a> VectorSearchBuilder<'a> {
             limit: None,
             options: HashMap::new(),
             projection: None,
+            filter: None,
         }
     }
 
@@ -134,6 +139,26 @@ impl<'a> VectorSearchBuilder<'a> {
         self
     }
 
+    /// Attach a residual scalar predicate applied *after* vector recall on the
+    /// primary-key vector path: each recalled candidate file is re-read and 
only
+    /// rows satisfying `filter` survive, folded into the search so best-first
+    /// order and Top-K still hold. Mirrors Java `PrimaryKeyVectorRead`'s
+    /// residual-filter support. Only the primary-key vector path consumes it, 
and
+    /// only when the table exposes physical rows directly (deletion vectors
+    /// enabled without merge-on-read); otherwise the query fails loud. A query
+    /// that does not resolve to the primary-key vector path (no PK-vector 
index,
+    /// or a non-PK-vector column) also fails loud rather than silently 
ignoring
+    /// the filter.
+    ///
+    /// The whole predicate is both pushed into the scan — where it prunes 
whole
+    /// data files by their column stats — and applied per row as a residual 
over
+    /// the surviving files, so results stay exact. Sub-file row-range 
narrowing is
+    /// not performed; a surviving file is re-read in full for the residual.
+    pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
+        self.filter = Some(filter);
+        self
+    }
+
     /// Restrict the columns materialized by 
[`execute_read`](Self::execute_read)
     /// to `cols` (plus the always-appended `_PKEY_VECTOR_SCORE`). Without this
     /// call `execute_read` materializes every user table column. Only affects
@@ -191,6 +216,19 @@ impl<'a> VectorSearchBuilder<'a> {
             }
         }
 
+        // The data-evolution (global-index) fall-through path cannot honor a
+        // residual filter — it never reads physical rows. Rather than silently
+        // drop the predicate and return unfiltered results, fail loud when a
+        // filter is set on a query that does not resolve to the primary-key
+        // vector path.
+        if self.filter.is_some() {
+            return Err(crate::Error::DataInvalid {
+                message: "vector search filter is only supported on the 
primary-key vector path"
+                    .to_string(),
+                source: None,
+            });
+        }
+
         let mut batch_builder = BatchVectorSearchBuilder::new(self.table);
         let mut results = batch_builder
             .with_vector_column(vector_column)
@@ -284,11 +322,26 @@ impl<'a> VectorSearchBuilder<'a> {
         query_vector: &[f32],
         limit: usize,
     ) -> crate::Result<(Vec<PkVectorCandidate>, PkVectorScanPlan, 
VectorSearchMetric)> {
-        // Residual-filter guard: PK vector search accepts partition filters 
only.
-        // This builder exposes no data-predicate setter, so there is nothing 
to
-        // reject here; the guard mirrors Java `checkArgument(filter == null)` 
and,
-        // if a filter setter is ever added, it must error rather than be 
ignored.
-
+        // Residual pre-filter guard, mirroring Java `PrimaryKeyVectorScan`. A 
data
+        // predicate set via `with_filter` is applied post-recall by re-reading
+        // each candidate file's physical rows (see below). That 
physical-position
+        // filtering only agrees with the bucket search when the table exposes
+        // physical rows directly: deletion vectors enabled and merge-on-read
+        // disabled. Under merge-on-read (or without deletion vectors) a read
+        // merges multiple key versions, so a scalar filter could retain a 
stale
+        // version whose live version does not match — a silent wrong-read. 
Reject
+        // such queries rather than answer them incorrectly. No filter → 
nothing to
+        // guard, so the search-only and read paths are unaffected.
+        let physical_row_read =
+            core.deletion_vectors_enabled() && 
!core.deletion_vectors_merge_on_read();
+        if self.filter.is_some() && !physical_row_read {
+            return Err(crate::Error::DataInvalid {
+                message:
+                    "primary-key vector pre-filter requires deletion vectors 
without merge-on-read"
+                        .to_string(),
+                source: None,
+            });
+        }
         // `primary_key_vector_distance_metric` returns a validated name; 
re-parse
         // into the enum for the numeric semantics.
         let metric = 
VectorSearchMetric::parse(&core.primary_key_vector_distance_metric(pk_col)?)?;
@@ -315,7 +368,7 @@ impl<'a> VectorSearchBuilder<'a> {
         let search_mode = core.global_index_search_mode()?;
         let skip_exact_fallback = search_mode == GlobalIndexSearchMode::Fast;
 
-        let plan = PkVectorScan::new(self.table, field_id, index_type)
+        let plan = PkVectorScan::new(self.table, field_id, index_type, 
self.filter.clone())
             .plan()
             .await?;
         if plan.splits.is_empty() {
@@ -365,12 +418,61 @@ impl<'a> VectorSearchBuilder<'a> {
             });
         let ann_searcher = VindexAnnSearcher::new(field_name, scorer);
 
+        // Residual (post-recall) filtering: for each candidate file, re-read 
its
+        // physical rows and keep the positions whose rows satisfy the filter. 
The
+        // per-split allow-list is threaded into the bucket search so the 
residual
+        // folds into recall (best-first order and Top-K are preserved). Built 
only
+        // when a filter is set; otherwise `None` leaves the search 
unfiltered. The
+        // residual reader projects the predicate columns plus `_ROW_ID` (used 
to
+        // recover file-local physical positions) and carries no pushdown, 
matching
+        // `residual_positions_by_file`. Computed before the exact-reader 
preload so
+        // the preload can skip files the residual allow-list leaves empty.
+        let residual_by_split: Option<Vec<HashMap<String, RoaringTreemap>>> = 
match &self.filter {
+            Some(filter) => {
+                let file_predicates = FilePredicates {
+                    predicates: vec![filter.clone()],
+                    file_fields: self.table.schema().fields().to_vec(),
+                };
+                let row_id_field = DataField::new(
+                    ROW_ID_FIELD_ID,
+                    ROW_ID_FIELD_NAME.to_string(),
+                    DataType::BigInt(BigIntType::new()),
+                );
+                let residual_read_type =
+                    widen_scan_fields(std::slice::from_ref(&row_id_field), 
Some(&file_predicates));
+                let residual_reader = DataFileReader::new(
+                    self.table.file_io().clone(),
+                    self.table.schema_manager().clone(),
+                    self.table.schema().id(),
+                    self.table.schema().fields().to_vec(),
+                    residual_read_type,
+                    Vec::new(),
+                );
+                let mut per_split = Vec::with_capacity(plan.splits.len());
+                for split in &plan.splits {
+                    per_split.push(
+                        residual_positions_by_file(
+                            &residual_reader,
+                            &split.data_split,
+                            &split.active_files,
+                            &file_predicates,
+                        )
+                        .await?,
+                    );
+                }
+                Some(per_split)
+            }
+            None => None,
+        };
+
         // Exact-fallback readers, keyed by (split_index, file_name). In FAST 
mode
         // the kernel never invokes the factory, so skip the in-memory column 
read
         // entirely. Otherwise preload only the *uncovered* active files: 
files an
         // ANN segment already covers never reach the exact fallback, so 
reading
         // their vector column here would be wasted IO/memory. Mirrors Java, 
which
-        // creates a `PkVectorReader` lazily only for uncovered files.
+        // creates a `PkVectorReader` lazily only for uncovered files. When a
+        // residual filter leaves a file's allow-list empty (or absent) the 
bucket
+        // search skips it, so its reader is not preloaded either.
         let mut exact_readers: HashMap<(usize, String), Box<dyn 
PkVectorReader>> = HashMap::new();
         if !skip_exact_fallback {
             for (split_index, split) in plan.splits.iter().enumerate() {
@@ -384,6 +486,13 @@ impl<'a> VectorSearchBuilder<'a> {
                     if covered.contains(&active.file_name) {
                         continue;
                     }
+                    if !should_preload_exact_reader(
+                        residual_by_split.as_deref(),
+                        split_index,
+                        &active.file_name,
+                    ) {
+                        continue;
+                    }
                     let r = factory.create(active).await?;
                     exact_readers.insert((split_index, 
active.file_name.clone()), r);
                 }
@@ -411,6 +520,7 @@ impl<'a> VectorSearchBuilder<'a> {
                 &mut factory,
                 &search_options,
                 skip_exact_fallback,
+                residual_by_split.as_deref(),
             )
             .await?;
 
@@ -912,6 +1022,114 @@ fn is_vector_global_index_file(index_file: 
&IndexFileMeta) -> bool {
     VectorIndexBackend::from_index_type(&index_file.index_type).is_some()
 }
 
+/// Whether the exact-fallback reader for `file_name` in split `split_index`
+/// should be preloaded. With a residual filter, a file absent from the split's
+/// allow-list or with an empty allow-list has no candidate rows, so the bucket
+/// search skips it and preloading its vector column would be wasted IO.
+fn should_preload_exact_reader(
+    residual_by_split: Option<&[HashMap<String, RoaringTreemap>]>,
+    split_index: usize,
+    file_name: &str,
+) -> bool {
+    match residual_by_split {
+        None => true,
+        Some(per_split) => per_split
+            .get(split_index)
+            .and_then(|m| m.get(file_name))
+            .is_some_and(|allowed| !allowed.is_empty()),
+    }
+}
+
+/// Compute, per data file in `split`, the set of physical row positions whose
+/// rows satisfy the residual predicate. Mirrors the row-collecting half of 
Java
+/// `PrimaryKeyVectorRead`'s `executeFilter`: because
+/// [`DataFileReader::read_single_file_stream`] rejects projecting `_ROW_ID`
+/// alongside a row-filtering predicate (the residual filter would drop rows
+/// before `_ROW_ID` is assigned positionally, desyncing it), the predicate is
+/// NOT pushed down. Instead `reader` projects the residual columns together 
with
+/// `_ROW_ID` and carries no pushdown predicate; the residual is applied here 
at
+/// the Arrow level, after `_ROW_ID` is materialized, and each surviving row's
+/// `_ROW_ID - first_row_id` is the file-local physical position.
+///
+/// Every *active* data file in the split gets an entry, possibly empty. The
+/// bucket search treats an absent entry and an empty entry identically (the 
file
+/// contributes no candidates), so the empty entries only make the map cover 
every
+/// active file. Non-active files (e.g. level-0 files the bucket search 
excludes)
+/// are skipped entirely: they are never searched, so re-reading them would be
+/// wasted IO and their possibly-absent `first_row_id` must not fail an 
otherwise
+/// valid query.
+///
+/// `reader` must project `_ROW_ID` and be predicate-free; 
`residual.file_fields`
+/// are the fields the residual leaf indices point into (resolved by name 
against
+/// each emitted batch). A data file without `first_row_id` fails loud, 
matching
+/// the position-read guard.
+async fn residual_positions_by_file(
+    reader: &DataFileReader,
+    split: &DataSplit,
+    active_files: &[BucketActiveFile],
+    residual: &FilePredicates,
+) -> crate::Result<HashMap<String, RoaringTreemap>> {
+    let scan_fields = reader.read_type().to_vec();
+    let active_names: HashSet<&str> = active_files.iter().map(|f| 
f.file_name.as_str()).collect();
+    let mut out: HashMap<String, RoaringTreemap> = HashMap::new();
+    for file_meta in split.data_files() {
+        // Only files the bucket search actually recalls from need residual
+        // positions; skip everything else so a non-active file cannot trigger 
the
+        // `first_row_id` guard below or incur a wasted read.
+        if !active_names.contains(file_meta.file_name.as_str()) {
+            continue;
+        }
+        let first_row_id = file_meta
+            .first_row_id
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: format!(
+                    "residual position read requires data file '{}' to have 
first_row_id",
+                    file_meta.file_name
+                ),
+                source: None,
+            })?;
+        let data_fields = reader.derive_data_fields(file_meta).await?;
+        let mut stream =
+            reader.read_single_file_stream(split, file_meta.clone(), 
data_fields, None, None)?;
+        // Register the file up front so a file whose rows all fail the 
residual
+        // still appears in the map (empty set).
+        let positions = out.entry(file_meta.file_name.clone()).or_default();
+        while let Some(batch) = stream.try_next().await? {
+            let filtered = filter_record_batch_by_predicates(batch, residual, 
&scan_fields)?;
+            if filtered.num_rows() == 0 {
+                continue;
+            }
+            let row_id_idx = 
filtered.schema().index_of(ROW_ID_FIELD_NAME).map_err(|_| {
+                crate::Error::DataInvalid {
+                    message: "residual position read batch is missing the 
_ROW_ID column"
+                        .to_string(),
+                    source: None,
+                }
+            })?;
+            let row_ids = filtered
+                .column(row_id_idx)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .ok_or_else(|| crate::Error::DataInvalid {
+                    message: "residual position read _ROW_ID column is not 
Int64".to_string(),
+                    source: None,
+                })?;
+            for i in 0..row_ids.len() {
+                let position = row_ids.value(i) - first_row_id;
+                let position = u64::try_from(position).map_err(|_| 
crate::Error::DataInvalid {
+                    message: format!(
+                        "residual position {position} is negative for data 
file '{}'",
+                        file_meta.file_name
+                    ),
+                    source: None,
+                })?;
+                positions.insert(position);
+            }
+        }
+    }
+    Ok(out)
+}
+
 /// Preload every ANN segment's bytes into a map keyed by the resolved 
(globally
 /// unique) segment path. The scorer closure reads from this map so the vindex
 /// reader is driven from memory without per-search IO.
@@ -1954,8 +2172,8 @@ mod tests {
     use crate::lumina::{LEGACY_LUMINA_VECTOR_ANN_IDENTIFIER, 
LUMINA_IDENTIFIER};
     use crate::spec::stats::BinaryTableStats;
     use crate::spec::{
-        ArrayType, BinaryRow, DataFileMeta, DataType, FloatType, 
GlobalIndexMeta, IndexFileMeta,
-        IndexManifestEntry, IntType, Schema, TableSchema,
+        ArrayType, BinaryRow, DataFileMeta, DataType, Datum, FloatType, 
GlobalIndexMeta,
+        IndexFileMeta, IndexManifestEntry, IntType, PredicateBuilder, Schema, 
TableSchema,
     };
     use crate::table::source::DataSplitBuilder;
     use crate::vindex::IVF_FLAT_IDENTIFIER;
@@ -2014,6 +2232,26 @@ mod tests {
         assert_eq!(find_field_id_by_name(&fields, "nonexistent"), None);
     }
 
+    #[test]
+    fn should_preload_skips_empty_or_absent_residual_files() {
+        use roaring::RoaringTreemap;
+        use std::collections::HashMap;
+        // No filter -> always preload.
+        assert!(should_preload_exact_reader(None, 0, "f0"));
+        // Filter present: file with a non-empty allow-list -> preload.
+        let mut m0: HashMap<String, RoaringTreemap> = HashMap::new();
+        m0.insert("f0".to_string(), RoaringTreemap::from_iter([0u64]));
+        let per_split = vec![m0];
+        assert!(should_preload_exact_reader(Some(&per_split), 0, "f0"));
+        // Filter present: file absent -> skip.
+        assert!(!should_preload_exact_reader(Some(&per_split), 0, "missing"));
+        // Filter present: file with empty allow-list -> skip.
+        let mut m1: HashMap<String, RoaringTreemap> = HashMap::new();
+        m1.insert("f1".to_string(), RoaringTreemap::new());
+        let per_split2 = vec![m1];
+        assert!(!should_preload_exact_reader(Some(&per_split2), 0, "f1"));
+    }
+
     #[test]
     fn test_raw_vector_score_matches_java_metric_semantics() {
         let l2 = compute_raw_vector_score(&[1.0, 2.0], &[1.0, 4.0], 
RawVectorMetric::L2);
@@ -2817,6 +3055,147 @@ mod tests {
         }
     }
 
+    /// `id > threshold` built against the table's user fields (leaf index 
resolves
+    /// against `table.schema().fields()`).
+    fn id_gt_filter(table: &Table, threshold: i32) -> Predicate {
+        PredicateBuilder::new(table.schema().fields())
+            .greater_than("id", Datum::Int(threshold))
+            .unwrap()
+    }
+
+    #[tokio::test]
+    async fn pk_branch_filter_without_deletion_vectors_fails_loud() {
+        // A residual filter on a PK-vector table that does NOT enable deletion
+        // vectors must be rejected (merge-on-read semantics would make 
physical
+        // -position filtering unsound). Mirrors Java `PrimaryKeyVectorScan`.
+        let table = pk_vector_table(&[
+            ("pk-vector.index.columns", "embedding"),
+            ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
+            ("fields.embedding.pk-vector.distance.metric", "l2"),
+        ]);
+        let filter = id_gt_filter(&table, 2);
+        let err = table
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0])
+            .with_limit(5)
+            .with_filter(filter)
+            .execute_scored()
+            .await
+            .map(|_| ())
+            .expect_err("filter without deletion vectors must fail loud");
+        assert!(
+            matches!(err, crate::Error::DataInvalid { ref message, .. }
+                if message.contains("deletion vectors without merge-on-read")),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[tokio::test]
+    async fn execute_read_filter_without_deletion_vectors_fails_loud() {
+        let table = pk_vector_table(&[
+            ("pk-vector.index.columns", "embedding"),
+            ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
+            ("fields.embedding.pk-vector.distance.metric", "l2"),
+        ]);
+        let filter = id_gt_filter(&table, 2);
+        let err = table
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0])
+            .with_limit(5)
+            .with_filter(filter)
+            .execute_read()
+            .await
+            .map(|_| ())
+            .expect_err("read filter without deletion vectors must fail loud");
+        assert!(
+            matches!(err, crate::Error::DataInvalid { ref message, .. }
+                if message.contains("deletion vectors without merge-on-read")),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[tokio::test]
+    async fn execute_scored_filter_on_non_pk_vector_path_fails_loud() {
+        // No PK-vector index configured, so `execute_scored` would fall 
through to
+        // the data-evolution path, which never consumes the filter. Silently
+        // returning unfiltered rows is a wrong-read; the query must fail loud
+        // instead.
+        let table = pk_vector_table(&[]);
+        let filter = id_gt_filter(&table, 2);
+        let err = table
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0])
+            .with_limit(5)
+            .with_filter(filter)
+            .execute_scored()
+            .await
+            .map(|_| ())
+            .expect_err("filter on the non-PK-vector path must fail loud");
+        assert!(
+            matches!(err, crate::Error::DataInvalid { ref message, .. }
+                if message.contains("only supported on the primary-key vector 
path")),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[tokio::test]
+    async fn pk_branch_filter_with_merge_on_read_fails_loud() {
+        // Deletion vectors enabled BUT merge-on-read on: still rejected, 
because a
+        // merge-on-read scan can surface stale key versions that a 
physical-row
+        // filter cannot reconcile.
+        let table = pk_vector_table(&[
+            ("pk-vector.index.columns", "embedding"),
+            ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
+            ("fields.embedding.pk-vector.distance.metric", "l2"),
+            ("deletion-vectors.enabled", "true"),
+            ("deletion-vectors.merge-on-read", "true"),
+        ]);
+        let filter = id_gt_filter(&table, 2);
+        let err = table
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0])
+            .with_limit(5)
+            .with_filter(filter)
+            .execute_scored()
+            .await
+            .map(|_| ())
+            .expect_err("merge-on-read filter must fail loud");
+        assert!(
+            matches!(err, crate::Error::DataInvalid { ref message, .. }
+                if message.contains("deletion vectors without merge-on-read")),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[tokio::test]
+    async fn pk_branch_filter_with_deletion_vectors_passes_guard() {
+        // Deletion vectors enabled, merge-on-read off (default): the residual 
guard
+        // passes. With no snapshot the plan is empty, so the (guarded) filter 
path
+        // simply yields an empty result rather than erroring — proving the 
guard
+        // admits a legal filtered query.
+        let table = pk_vector_table(&[
+            ("pk-vector.index.columns", "embedding"),
+            ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
+            ("fields.embedding.pk-vector.distance.metric", "l2"),
+            ("deletion-vectors.enabled", "true"),
+        ]);
+        let filter = id_gt_filter(&table, 2);
+        let result = table
+            .new_vector_search_builder()
+            .with_vector_column("embedding")
+            .with_query_vector(vec![1.0])
+            .with_limit(5)
+            .with_filter(filter)
+            .execute_scored()
+            .await
+            .expect("guarded filter query must be admitted");
+        assert!(result.is_empty());
+    }
+
     fn make_lumina_entry(
         file_name: &str,
         index_type: &str,
@@ -3114,3 +3493,324 @@ mod tests {
         assert_eq!(names, vec!["id"]);
     }
 }
+
+/// Tests for [`residual_positions_by_file`]: the residual predicate is 
applied at
+/// the Arrow level (no pushdown) against the predicate columns plus `_ROW_ID`,
+/// and each surviving row's `_ROW_ID` is converted back to a file-local 
physical
+/// position.
+#[cfg(test)]
+mod residual_positions_tests {
+    use super::*;
+    use crate::arrow::build_target_arrow_schema;
+    use crate::arrow::format::FilePredicates;
+    use crate::io::FileIOBuilder;
+    use crate::spec::stats::BinaryTableStats;
+    use crate::spec::{
+        BigIntType, BinaryRow, DataField, DataFileMeta, DataType, Datum, 
IntType, PredicateBuilder,
+        ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME,
+    };
+    use crate::table::data_file_reader::DataFileReader;
+    use crate::table::schema_manager::SchemaManager;
+    use crate::table::source::{DataSplit, DataSplitBuilder};
+    use arrow_array::{Int32Array, RecordBatch};
+    use bytes::Bytes;
+    use paimon_mosaic_core::spec::COMPRESSION_NONE;
+    use paimon_mosaic_core::writer::{MosaicWriter, OutputFile, WriterOptions};
+    use std::io;
+    use std::sync::Arc;
+
+    struct MemOutputFile {
+        data: Vec<u8>,
+    }
+
+    impl OutputFile for MemOutputFile {
+        fn write(&mut self, data: &[u8]) -> io::Result<()> {
+            self.data.extend_from_slice(data);
+            Ok(())
+        }
+        fn flush(&mut self) -> io::Result<()> {
+            Ok(())
+        }
+        fn pos(&self) -> u64 {
+            self.data.len() as u64
+        }
+    }
+
+    fn id_field() -> DataField {
+        DataField::new(0, "id".to_string(), DataType::Int(IntType::new()))
+    }
+
+    fn row_id_field() -> DataField {
+        DataField::new(
+            ROW_ID_FIELD_ID,
+            ROW_ID_FIELD_NAME.to_string(),
+            DataType::BigInt(BigIntType::new()),
+        )
+    }
+
+    fn id_batch(ids: Vec<i32>) -> RecordBatch {
+        let schema = build_target_arrow_schema(&[id_field()]).unwrap();
+        RecordBatch::try_new(schema, 
vec![Arc::new(Int32Array::from(ids))]).unwrap()
+    }
+
+    fn write_mosaic(batch: &RecordBatch) -> Bytes {
+        let mut writer = MosaicWriter::new(
+            MemOutputFile { data: Vec::new() },
+            batch.schema().as_ref(),
+            WriterOptions {
+                compression: COMPRESSION_NONE,
+                num_buckets: 2,
+                row_group_max_size: u64::MAX,
+                ..Default::default()
+            },
+        )
+        .unwrap();
+        writer.write_batch(batch).unwrap();
+        writer.close().unwrap();
+        Bytes::from(writer.output().data.to_vec())
+    }
+
+    fn data_file(
+        file_name: &str,
+        file_size: i64,
+        row_count: i64,
+        first_row_id: Option<i64>,
+    ) -> DataFileMeta {
+        DataFileMeta {
+            file_name: file_name.to_string(),
+            file_size,
+            row_count,
+            min_key: Vec::new(),
+            max_key: Vec::new(),
+            key_stats: BinaryTableStats::empty(),
+            value_stats: BinaryTableStats::empty(),
+            min_sequence_number: 0,
+            max_sequence_number: 0,
+            schema_id: 1,
+            level: 0,
+            extra_files: Vec::new(),
+            creation_time: None,
+            delete_row_count: None,
+            embedded_index: None,
+            file_source: None,
+            value_stats_cols: None,
+            external_path: None,
+            first_row_id,
+            write_cols: None,
+        }
+    }
+
+    /// Build a predicate-free reader (read_type = `id` + `_ROW_ID`) over a 
split
+    /// containing `files` (each `(name, ids, first_row_id)`), written as 
Mosaic
+    /// data files in the same bucket. The returned active-file list covers 
every
+    /// file (all files active).
+    async fn build_reader_and_split(
+        table_path: &str,
+        files: &[(&str, Vec<i32>, i64)],
+    ) -> (DataFileReader, DataSplit, Vec<BucketActiveFile>) {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let bucket_path = format!("{table_path}/bucket-0");
+        let mut metas = Vec::new();
+        let mut active_files = Vec::new();
+        for (name, ids, first_row_id) in files {
+            let data = write_mosaic(&id_batch(ids.clone()));
+            file_io
+                .new_output(&format!("{bucket_path}/{name}"))
+                .unwrap()
+                .write(data.clone())
+                .await
+                .unwrap();
+            metas.push(data_file(
+                name,
+                data.len() as i64,
+                ids.len() as i64,
+                Some(*first_row_id),
+            ));
+            active_files.push(BucketActiveFile {
+                file_name: name.to_string(),
+                row_count: ids.len() as i64,
+            });
+        }
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket_path)
+            .with_total_buckets(1)
+            .with_data_files(metas)
+            .build()
+            .unwrap();
+        let reader = DataFileReader::new(
+            file_io.clone(),
+            SchemaManager::new(file_io, table_path.to_string()),
+            1,
+            vec![id_field()],
+            vec![id_field(), row_id_field()],
+            Vec::new(),
+        );
+        (reader, split, active_files)
+    }
+
+    /// `id > threshold`, with `file_fields` = `[id]` so the leaf index 
resolves.
+    fn residual_id_gt(threshold: i32) -> FilePredicates {
+        let pred = PredicateBuilder::new(&[id_field()])
+            .greater_than("id", Datum::Int(threshold))
+            .unwrap();
+        FilePredicates {
+            predicates: vec![pred],
+            file_fields: vec![id_field()],
+        }
+    }
+
+    fn sorted(t: &roaring::RoaringTreemap) -> Vec<u64> {
+        t.iter().collect()
+    }
+
+    #[tokio::test]
+    async fn test_residual_selects_matching_positions() {
+        // ids [1,2,3,4,5] at first_row_id 0; id > 2 -> ids 3,4,5 -> positions 
2,3,4.
+        let (reader, split, active) = build_reader_and_split(
+            "memory:/rpf_basic",
+            &[("part-0.mosaic", vec![1, 2, 3, 4, 5], 0)],
+        )
+        .await;
+        let map = residual_positions_by_file(&reader, &split, &active, 
&residual_id_gt(2))
+            .await
+            .unwrap();
+        assert_eq!(sorted(&map["part-0.mosaic"]), vec![2, 3, 4]);
+    }
+
+    #[tokio::test]
+    async fn test_residual_matches_none_yields_empty_entry() {
+        // id > 100 matches nothing; the file still gets a (present, empty) 
entry.
+        let (reader, split, active) =
+            build_reader_and_split("memory:/rpf_none", &[("part-0.mosaic", 
vec![1, 2, 3], 0)])
+                .await;
+        let map = residual_positions_by_file(&reader, &split, &active, 
&residual_id_gt(100))
+            .await
+            .unwrap();
+        assert!(map.contains_key("part-0.mosaic"));
+        assert!(map["part-0.mosaic"].is_empty());
+    }
+
+    #[tokio::test]
+    async fn test_residual_matches_all_yields_full_set() {
+        let (reader, split, active) =
+            build_reader_and_split("memory:/rpf_all", &[("part-0.mosaic", 
vec![1, 2, 3], 0)]).await;
+        let map = residual_positions_by_file(&reader, &split, &active, 
&residual_id_gt(0))
+            .await
+            .unwrap();
+        assert_eq!(sorted(&map["part-0.mosaic"]), vec![0, 1, 2]);
+    }
+
+    #[tokio::test]
+    async fn test_residual_positions_are_file_local_across_files() {
+        // Two files with distinct first_row_id; positions must be 0-based 
within
+        // each file, not global. id > 3 keeps ids 4,5 in both -> positions 
{3,4}.
+        let (reader, split, active) = build_reader_and_split(
+            "memory:/rpf_multi",
+            &[
+                ("part-0.mosaic", vec![1, 2, 3, 4, 5], 0),
+                ("part-1.mosaic", vec![1, 2, 3, 4, 5], 100),
+            ],
+        )
+        .await;
+        let map = residual_positions_by_file(&reader, &split, &active, 
&residual_id_gt(3))
+            .await
+            .unwrap();
+        assert_eq!(sorted(&map["part-0.mosaic"]), vec![3, 4]);
+        assert_eq!(sorted(&map["part-1.mosaic"]), vec![3, 4]);
+    }
+
+    #[tokio::test]
+    async fn test_non_active_files_are_skipped() {
+        // Two files in the split, but only `part-0.mosaic` is active. The 
bucket
+        // search never recalls from `part-1.mosaic` (level-0 / non-active), 
so it
+        // must not appear in the residual map — and even though it lacks a
+        // `first_row_id`, the query still succeeds because non-active files 
are
+        // skipped before the guard.
+        let (reader, split, mut active) = build_reader_and_split(
+            "memory:/rpf_nonactive",
+            &[("part-0.mosaic", vec![1, 2, 3, 4, 5], 0)],
+        )
+        .await;
+        // Append a non-active file (missing first_row_id) directly to the 
split's
+        // data files, but leave it out of the active list.
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let bucket_path = "memory:/rpf_nonactive/bucket-0";
+        let data = write_mosaic(&id_batch(vec![9, 9, 9]));
+        file_io
+            .new_output(&format!("{bucket_path}/part-1.mosaic"))
+            .unwrap()
+            .write(data.clone())
+            .await
+            .unwrap();
+        let mut metas = split.data_files().to_vec();
+        metas.push(data_file("part-1.mosaic", data.len() as i64, 3, None));
+        // `active` already lists only part-0.mosaic; keep it that way.
+        let _ = &mut active;
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket_path.to_string())
+            .with_total_buckets(1)
+            .with_data_files(metas)
+            .build()
+            .unwrap();
+        let map = residual_positions_by_file(&reader, &split, &active, 
&residual_id_gt(2))
+            .await
+            .unwrap();
+        assert_eq!(sorted(&map["part-0.mosaic"]), vec![2, 3, 4]);
+        assert!(
+            !map.contains_key("part-1.mosaic"),
+            "non-active file must be skipped"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_missing_first_row_id_is_error() {
+        let (reader, split, active) = 
build_reader_and_split_no_first_row_id().await;
+        let err = residual_positions_by_file(&reader, &split, &active, 
&residual_id_gt(0))
+            .await
+            .expect_err("missing first_row_id must error");
+        assert!(format!("{err:?}").contains("first_row_id"), "got: {err:?}");
+    }
+
+    async fn build_reader_and_split_no_first_row_id(
+    ) -> (DataFileReader, DataSplit, Vec<BucketActiveFile>) {
+        let table_path = "memory:/rpf_nofrid";
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let bucket_path = format!("{table_path}/bucket-0");
+        let data = write_mosaic(&id_batch(vec![1, 2, 3]));
+        file_io
+            .new_output(&format!("{bucket_path}/part-0.mosaic"))
+            .unwrap()
+            .write(data.clone())
+            .await
+            .unwrap();
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket_path)
+            .with_total_buckets(1)
+            .with_data_files(vec![data_file("part-0.mosaic", data.len() as 
i64, 3, None)])
+            .build()
+            .unwrap();
+        let reader = DataFileReader::new(
+            file_io.clone(),
+            SchemaManager::new(file_io, table_path.to_string()),
+            1,
+            vec![id_field()],
+            vec![id_field(), row_id_field()],
+            Vec::new(),
+        );
+        // The lone file is active, so the `first_row_id` guard applies to it.
+        let active = vec![BucketActiveFile {
+            file_name: "part-0.mosaic".to_string(),
+            row_count: 3,
+        }];
+        (reader, split, active)
+    }
+}
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs 
b/crates/paimon/src/vindex/pkvector/ann.rs
index aad463f..627c052 100644
--- a/crates/paimon/src/vindex/pkvector/ann.rs
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -35,12 +35,21 @@ use crate::vector_search::VectorSearch;
 /// longer readable in this snapshot). Deletion vectors are applied only to 
active
 /// sources.
 ///
-/// Returns `None` only when every source file is active AND no deletion 
vector is
-/// relevant — nothing to mask. Otherwise returns the masked live ids.
+/// `residual_ranges` (when `Some`) restricts each source file to the physical 
row
+/// positions allowed by a residual predicate on the data columns: `key = file
+/// name`, `value = allowed physical positions`. A file with no entry (or an 
empty
+/// entry) has no allowed rows and contributes nothing. When `residual_ranges` 
is
+/// `Some`, a mask is always required (the residual can only narrow the live 
set),
+/// so the result is always `Some`. Mirrors Java `rowRangesByFile`.
+///
+/// Returns `None` only when there is no residual, every source file is 
active, AND
+/// no deletion vector is relevant — nothing to mask. Otherwise returns the 
masked
+/// live ids.
 pub(crate) fn build_live_row_ids(
     source_files: &[PkVectorSourceFile],
     active_source_files: &HashSet<String>,
     deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+    residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
 ) -> crate::Result<Option<roaring::RoaringTreemap>> {
     let all_active = source_files
         .iter()
@@ -48,7 +57,7 @@ pub(crate) fn build_live_row_ids(
     let has_relevant_dv = source_files
         .iter()
         .any(|f| deletion_vectors.contains_key(f.file_name()));
-    if all_active && !has_relevant_dv {
+    if residual_ranges.is_none() && all_active && !has_relevant_dv {
         return Ok(None);
     }
 
@@ -63,7 +72,32 @@ pub(crate) fn build_live_row_ids(
             .ok_or_else(|| data_invalid("vector source row counts overflow 
u64"))?;
         let active = active_source_files.contains(source_file.file_name());
         if active && row_count > 0 {
-            live.insert_range(file_offset..end);
+            match residual_ranges {
+                // No residual: the whole active file range is live.
+                None => {
+                    live.insert_range(file_offset..end);
+                }
+                // Residual present: only allowed physical positions of this 
file
+                // become live, mapped into global ordinal space (position +
+                // file_offset). A missing/empty entry allows no rows.
+                Some(ranges) => {
+                    if let Some(allowed) = ranges.get(source_file.file_name()) 
{
+                        for position in allowed.iter() {
+                            if position >= row_count {
+                                return Err(data_invalid(format!(
+                                    "residual position {position} is out of 
range for source file {} ({} rows)",
+                                    source_file.file_name(),
+                                    row_count
+                                )));
+                            }
+                            let global = 
file_offset.checked_add(position).ok_or_else(|| {
+                                data_invalid("vector residual position 
overflows u64")
+                            })?;
+                            live.insert(global);
+                        }
+                    }
+                }
+            }
         }
         if active {
             if let Some(dv) = deletion_vectors.get(source_file.file_name()) {
@@ -91,6 +125,7 @@ pub(crate) fn map_ann_results(
     source_meta: &PkVectorSourceMeta,
     active_source_files: &HashSet<String>,
     deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+    residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
     metric: VectorSearchMetric,
 ) -> crate::Result<Vec<PkVectorSearchResult>> {
     let mut results = Vec::with_capacity(scored.len());
@@ -103,15 +138,23 @@ pub(crate) fn map_ann_results(
                 "ANN segment returned inactive source {data_file_name}"
             )));
         }
+        let pos = u64::try_from(row_position)
+            .map_err(|_| data_invalid("resolved row position must not be 
negative"))?;
         if let Some(dv) = deletion_vectors.get(&data_file_name) {
-            let pos = u64::try_from(row_position)
-                .map_err(|_| data_invalid("resolved row position must not be 
negative"))?;
             if dv.is_deleted(pos) {
                 return Err(data_invalid(format!(
                     "ANN segment returned snapshot-deleted row position 
{row_position} in {data_file_name}"
                 )));
             }
         }
+        if let Some(ranges) = residual_ranges {
+            let allowed = ranges.get(&data_file_name).is_some_and(|r| 
r.contains(pos));
+            if !allowed {
+                return Err(data_invalid(format!(
+                    "ANN segment returned row position {row_position} in 
{data_file_name} outside the residual pre-filter"
+                )));
+            }
+        }
         results.push(PkVectorSearchResult {
             data_file_name,
             row_position,
@@ -143,6 +186,7 @@ pub(crate) trait PkVectorAnnSearcher: Send + Sync {
         active_source_files: &HashSet<String>,
         deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
         search_options: &HashMap<String, String>,
+        residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
     ) -> crate::Result<Vec<PkVectorSearchResult>>;
 }
 
@@ -185,6 +229,7 @@ impl PkVectorAnnSearcher for VindexAnnSearcher {
         active_source_files: &HashSet<String>,
         deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
         search_options: &HashMap<String, String>,
+        residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
     ) -> crate::Result<Vec<PkVectorSearchResult>> {
         if limit == 0 {
             return Err(data_invalid("vector search limit must be positive"));
@@ -192,8 +237,12 @@ impl PkVectorAnnSearcher for VindexAnnSearcher {
         let source_files = segment.source_meta.source_files();
         let mut search = VectorSearch::new(query.to_vec(), limit, 
self.field_name.clone())?
             .with_options(search_options.clone());
-        if let Some(live) = build_live_row_ids(source_files, 
active_source_files, deletion_vectors)?
-        {
+        if let Some(live) = build_live_row_ids(
+            source_files,
+            active_source_files,
+            deletion_vectors,
+            residual_ranges,
+        )? {
             search = search.with_include_row_ids(live);
         }
         let scored = match (self.scorer)(segment, &search)? {
@@ -206,6 +255,7 @@ impl PkVectorAnnSearcher for VindexAnnSearcher {
             &segment.source_meta,
             active_source_files,
             deletion_vectors,
+            residual_ranges,
             metric,
         )
     }
@@ -241,13 +291,15 @@ mod tests {
         let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
         let active = active_set(&["f0"]);
         // All active + empty map -> None.
-        assert!(build_live_row_ids(&files, &active, &HashMap::new())
+        assert!(build_live_row_ids(&files, &active, &HashMap::new(), None)
             .unwrap()
             .is_none());
         // All active + non-empty map but no matching file name -> None.
         let mut dvs = HashMap::new();
         dvs.insert("other".to_string(), dv(&[0]));
-        assert!(build_live_row_ids(&files, &active, &dvs).unwrap().is_none());
+        assert!(build_live_row_ids(&files, &active, &dvs, None)
+            .unwrap()
+            .is_none());
     }
 
     #[test]
@@ -258,7 +310,7 @@ mod tests {
             PkVectorSourceFile::new("f0".into(), 3).unwrap(),
             PkVectorSourceFile::new("f1".into(), 2).unwrap(),
         ];
-        let live = build_live_row_ids(&files, &active_set(&["f0"]), 
&HashMap::new())
+        let live = build_live_row_ids(&files, &active_set(&["f0"]), 
&HashMap::new(), None)
             .unwrap()
             .unwrap();
         assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 1, 2]);
@@ -274,7 +326,7 @@ mod tests {
         let mut dvs = HashMap::new();
         dvs.insert("f0".to_string(), dv(&[1])); // deletes global 1
         dvs.insert("f1".to_string(), dv(&[0])); // deletes global 3
-        let live = build_live_row_ids(&files, &active_set(&["f0", "f1"]), &dvs)
+        let live = build_live_row_ids(&files, &active_set(&["f0", "f1"]), 
&dvs, None)
             .unwrap()
             .unwrap();
         assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 2, 4]);
@@ -290,6 +342,7 @@ mod tests {
             &meta,
             &active_set(&["f0", "f1"]),
             &HashMap::new(),
+            None,
             VectorSearchMetric::L2,
         )
         .unwrap();
@@ -318,6 +371,7 @@ mod tests {
             &meta,
             &active_set(&["f0"]),
             &HashMap::new(),
+            None,
             VectorSearchMetric::L2,
         )
         .unwrap_err();
@@ -333,6 +387,7 @@ mod tests {
             &meta,
             &active_set(&["f0"]),
             &HashMap::new(),
+            None,
             VectorSearchMetric::L2,
         )
         .unwrap_err();
@@ -349,6 +404,7 @@ mod tests {
             &meta,
             &active_set(&["f0"]),
             &dvs,
+            None,
             VectorSearchMetric::L2,
         )
         .unwrap_err();
@@ -399,6 +455,7 @@ mod tests {
                 &active_set(&["f0", "f1"]),
                 &dvs,
                 &HashMap::new(),
+                None,
             )
             .unwrap();
         // Sorted BEST_FIRST by distance: (f1,0) dist 1.0 then (f0,0) dist 3.0.
@@ -431,6 +488,7 @@ mod tests {
                 &active_set(&["f0"]),
                 &HashMap::new(),
                 &HashMap::new(),
+                None,
             )
             .unwrap_err();
         assert!(err.to_string().contains("positive"));
@@ -456,8 +514,148 @@ mod tests {
                 &active_set(&["f0"]),
                 &HashMap::new(),
                 &HashMap::new(),
+                None,
             )
             .unwrap();
         assert!(results.is_empty());
     }
+
+    fn treemap(positions: &[u64]) -> roaring::RoaringTreemap {
+        let mut t = roaring::RoaringTreemap::new();
+        for &p in positions {
+            t.insert(p);
+        }
+        t
+    }
+
+    #[test]
+    fn test_build_live_row_ids_residual_intersects_with_active_and_dv() {
+        // f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4). Both active.
+        // dv on f0 deletes pos1 (global 1). residual allows f0={0,1}, f1 has 
no
+        // entry (empty allow). Result: f0 keeps {0} (1 is residual-allowed but
+        // deleted, 2 not residual-allowed); f1 contributes nothing.
+        let files = vec![
+            PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+            PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+        ];
+        let mut dvs = HashMap::new();
+        dvs.insert("f0".to_string(), dv(&[1]));
+        let mut residual = HashMap::new();
+        residual.insert("f0".to_string(), treemap(&[0, 1]));
+        let live = build_live_row_ids(&files, &active_set(&["f0", "f1"]), 
&dvs, Some(&residual))
+            .unwrap()
+            .unwrap();
+        assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0]);
+    }
+
+    #[test]
+    fn test_build_live_row_ids_residual_maps_positions_across_file_offsets() {
+        // f0 rows global 0,1,2; f1 rows global 3,4. residual allows f0={2}, 
f1={1}.
+        // f1 physical pos 1 -> global 3 + 1 = 4. Result {2, 4}. No DV.
+        let files = vec![
+            PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+            PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+        ];
+        let mut residual = HashMap::new();
+        residual.insert("f0".to_string(), treemap(&[2]));
+        residual.insert("f1".to_string(), treemap(&[1]));
+        let live = build_live_row_ids(
+            &files,
+            &active_set(&["f0", "f1"]),
+            &HashMap::new(),
+            Some(&residual),
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(live.iter().collect::<Vec<u64>>(), vec![2, 4]);
+    }
+
+    #[test]
+    fn 
test_build_live_row_ids_residual_some_returns_mask_even_when_all_active_no_dv() 
{
+        // All active, no DV: without residual this returns None. With a 
residual
+        // present, a mask is always required.
+        let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
+        let mut residual = HashMap::new();
+        residual.insert("f0".to_string(), treemap(&[0, 2]));
+        let live = build_live_row_ids(
+            &files,
+            &active_set(&["f0"]),
+            &HashMap::new(),
+            Some(&residual),
+        )
+        .unwrap()
+        .expect("residual present -> mask required");
+        assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 2]);
+    }
+
+    #[test]
+    fn test_build_live_row_ids_rejects_out_of_range_residual_position() {
+        // Source file "f0" has 3 rows (valid positions 0..=2). A residual 
allow-list
+        // naming position 3 is out of range and must fail loud, not be 
skipped.
+        let files = source_meta(&[("f0", 3)]);
+        let mut residual = HashMap::new();
+        residual.insert("f0".to_string(), treemap(&[0, 3]));
+        let err = build_live_row_ids(
+            files.source_files(),
+            &active_set(&["f0"]),
+            &HashMap::new(),
+            Some(&residual),
+        )
+        .unwrap_err();
+        assert!(err.to_string().contains("out of range"));
+    }
+
+    #[test]
+    fn test_map_ann_results_rejects_hit_outside_residual_allow_list() {
+        // ordinal 1 -> (f0, 1). Residual allows only {0} in f0, so a hit at 
position 1
+        // (e.g. an ANN reader that ignored include_row_ids) must fail loud.
+        let meta = source_meta(&[("f0", 3)]);
+        let mut residual = HashMap::new();
+        residual.insert("f0".to_string(), treemap(&[0]));
+        let err = map_ann_results(
+            &[(1u64, 0.5)],
+            &meta,
+            &active_set(&["f0"]),
+            &HashMap::new(),
+            Some(&residual),
+            VectorSearchMetric::L2,
+        )
+        .unwrap_err();
+        assert!(err.to_string().contains("residual"));
+    }
+
+    #[test]
+    fn test_vindex_adapter_sets_include_row_ids_to_residual_intersection() {
+        // Recording scorer captures the include_row_ids the adapter built. All
+        // active, no DV, residual f0={0,2} -> include_row_ids must equal 
{0,2}.
+        use std::sync::{Arc, Mutex};
+        let seen_rows: Arc<Mutex<Option<Vec<u64>>>> = 
Arc::new(Mutex::new(None));
+        let scorer_rows = Arc::clone(&seen_rows);
+        let searcher = VindexAnnSearcher::new(
+            "embedding".to_string(),
+            Box::new(move |_segment: &BucketAnnSegment, search: &VectorSearch| 
{
+                *scorer_rows.lock().unwrap() = search
+                    .include_row_ids
+                    .as_ref()
+                    .map(|t| t.iter().collect::<Vec<u64>>());
+                Ok(None)
+            }),
+        );
+        let segment = BucketAnnSegment::for_test(source_meta(&[("f0", 3)]));
+        let mut residual = HashMap::new();
+        residual.insert("f0".to_string(), treemap(&[0, 2]));
+        searcher
+            .search(
+                &segment,
+                &[0.0, 0.0],
+                VectorSearchMetric::L2,
+                2,
+                &active_set(&["f0"]),
+                &HashMap::new(),
+                &HashMap::new(),
+                Some(&residual),
+            )
+            .unwrap();
+        assert_eq!(seen_rows.lock().unwrap().clone(), Some(vec![0, 2]));
+    }
 }
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs 
b/crates/paimon/src/vindex/pkvector/bucket.rs
index fcd7d1f..604ac7d 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -142,6 +142,13 @@ pub(crate) fn covered_source_files(
 ///
 /// `ann_searcher` may be `None` only when there are no ANN segments; segments
 /// present with `None` is an error.
+///
+/// `residual_ranges` (when `Some`) is a residual-predicate allow-list keyed by
+/// data-file name whose value is the set of physical row positions in that 
file
+/// that pass the predicate; only those rows may produce candidates. `None` 
applies
+/// no residual restriction (every row is allowed). A file absent from the map 
(or
+/// with an empty set) has no allowed rows and produces no candidates. Mirrors 
Java
+/// `rowRangesByFile`.
 #[allow(clippy::too_many_arguments)]
 pub(crate) fn bucket_search(
     ann_searcher: Option<&dyn PkVectorAnnSearcher>,
@@ -156,6 +163,7 @@ pub(crate) fn bucket_search(
     limit: usize,
     search_options: &HashMap<String, String>,
     skip_exact_fallback: bool,
+    residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
 ) -> crate::Result<Vec<PkVectorSearchResult>> {
     if limit == 0 {
         return Err(data_invalid("vector search limit must be positive"));
@@ -240,6 +248,7 @@ pub(crate) fn bucket_search(
             &active_source_files,
             deletion_vectors,
             search_options,
+            residual_ranges,
         )? {
             add_candidate(&mut heap, result, limit);
         }
@@ -250,13 +259,35 @@ pub(crate) fn bucket_search(
             if covered.contains(&file.file_name) {
                 continue;
             }
+            // Residual allow-list: when present, only rows whose physical 
position
+            // passes the predicate may produce candidates. A file with no 
entry (or
+            // an empty entry) has no allowed rows, so it is skipped without 
reading.
+            let residual_allowed: Option<&roaring::RoaringTreemap> = match 
residual_ranges {
+                Some(ranges) => match ranges.get(&file.file_name) {
+                    Some(allowed) if !allowed.is_empty() => Some(allowed),
+                    _ => continue,
+                },
+                None => None,
+            };
             let dv = deletion_vectors.get(&file.file_name).cloned();
             let is_excluded = move |position: i64| -> bool {
-                match &dv {
+                let dv_deleted = match &dv {
                     Some(dv) => u64::try_from(position)
                         .map(|p| dv.is_deleted(p))
                         .unwrap_or(false),
                     None => false,
+                };
+                if dv_deleted {
+                    return true;
+                }
+                match residual_allowed {
+                    // No residual restriction: the row is allowed.
+                    None => false,
+                    // Residual present: exclude positions outside the 
allow-list.
+                    Some(allowed) => match u64::try_from(position) {
+                        Ok(p) => !allowed.contains(p),
+                        Err(_) => true,
+                    },
                 }
             };
             let mut reader = exact_reader_factory(file)?;
@@ -319,6 +350,7 @@ mod tests {
             _active_source_files: &HashSet<String>,
             _dvs: &HashMap<String, Arc<DeletionVector>>,
             _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
         ) -> crate::Result<Vec<PkVectorSearchResult>> {
             Ok(self.result.clone())
         }
@@ -339,6 +371,7 @@ mod tests {
             0,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(err.to_string().contains("positive"));
@@ -379,6 +412,7 @@ mod tests {
             3,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap();
         // Top-3 BEST_FIRST: (data-1,0), (data-1,1), (data-1,2) — the larger
@@ -394,10 +428,10 @@ mod tests {
 
     #[test]
     fn nan_ann_hit_never_evicts_finite_candidate_from_top1() {
-        // The core failure mode from review: an ANN hit with a negative-NaN
-        // distance must not win the single bucket Top-1 slot over a finite 
hit.
-        // Under f32::total_cmp the -NaN would rank best and evict the finite
-        // candidate here in the bucket heap, before any cross-bucket merge.
+        // The core failure mode: an ANN hit with a negative-NaN distance must 
not
+        // win the single bucket Top-1 slot over a finite hit. Under 
f32::total_cmp
+        // the -NaN would rank best and evict the finite candidate here in the
+        // bucket heap, before any cross-bucket merge.
         let negative_nan = f32::from_bits(0xffc00000);
         assert!(negative_nan.is_nan());
         let segment = BucketAnnSegment::for_test(meta(&[("data-1", 2)]));
@@ -428,6 +462,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap();
         assert_eq!(results.len(), 1);
@@ -467,6 +502,7 @@ mod tests {
             2,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap();
         assert_eq!(
@@ -516,6 +552,7 @@ mod tests {
             2,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap();
         // Candidates: data-2 pos0 {1,0} dist 1.0; data-1 pos1 {2,0} dist 4.0.
@@ -552,6 +589,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(err.to_string().contains("duplicate") || 
err.to_string().contains("Duplicate"));
@@ -576,6 +614,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(
@@ -614,6 +653,7 @@ mod tests {
             2,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap();
         assert_eq!(
@@ -644,6 +684,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(
@@ -669,6 +710,7 @@ mod tests {
             2,
             &HashMap::new(),
             true, // skip_exact_fallback
+            None,
         )
         .unwrap();
         assert!(results.is_empty());
@@ -702,6 +744,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(
@@ -738,6 +781,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(
@@ -763,6 +807,7 @@ mod tests {
             1,
             &HashMap::new(),
             false,
+            None,
         )
         .unwrap_err();
         assert!(err.to_string().contains("row count") || 
err.to_string().contains("-1"));
@@ -796,4 +841,162 @@ mod tests {
         let covered = covered_source_files(&[segment], &active);
         assert!(covered.is_empty());
     }
+
+    fn treemap(positions: &[u64]) -> roaring::RoaringTreemap {
+        let mut t = roaring::RoaringTreemap::new();
+        for &p in positions {
+            t.insert(p);
+        }
+        t
+    }
+
+    #[test]
+    fn test_exact_residual_allow_list_restricts_positions() {
+        // No ANN. data-1 has 3 rows: pos0 {1,0} dist 1.0, pos1 {2,0} dist 4.0,
+        // pos2 {3,0} dist 9.0. residual allows only {0, 2} -> pos1 excluded 
even
+        // though it is not deletion-vector deleted.
+        let mut factory = |_: &BucketActiveFile| -> crate::Result<Box<dyn 
PkVectorReader>> {
+            Ok(Box::new(ArrayReader::new(
+                2,
+                vec![
+                    Some(vec![1.0, 0.0]),
+                    Some(vec![2.0, 0.0]),
+                    Some(vec![3.0, 0.0]),
+                ],
+            )))
+        };
+        let mut residual: HashMap<String, roaring::RoaringTreemap> = 
HashMap::new();
+        residual.insert("data-1".into(), treemap(&[0, 2]));
+        let results = bucket_search(
+            None,
+            &[],
+            &[active("data-1", 3)],
+            &HashMap::new(),
+            &mut factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            5,
+            &HashMap::new(),
+            false,
+            Some(&residual),
+        )
+        .unwrap();
+        assert_eq!(
+            results,
+            vec![
+                PkVectorSearchResult {
+                    data_file_name: "data-1".into(),
+                    row_position: 0,
+                    distance: 1.0
+                },
+                PkVectorSearchResult {
+                    data_file_name: "data-1".into(),
+                    row_position: 2,
+                    distance: 9.0
+                },
+            ]
+        );
+    }
+
+    #[test]
+    fn test_exact_residual_file_absent_from_map_is_skipped_without_reading() {
+        // residual covers only data-1; data-2 has no entry -> no allowed 
rows, so
+        // data-2 is skipped entirely (its factory reader is never built).
+        let calls = RefCell::new(Vec::<String>::new());
+        let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn 
PkVectorReader>> {
+            calls.borrow_mut().push(f.file_name.clone());
+            Ok(Box::new(ArrayReader::new(
+                2,
+                vec![Some(vec![1.0, 0.0]), Some(vec![2.0, 0.0])],
+            )))
+        };
+        let mut residual: HashMap<String, roaring::RoaringTreemap> = 
HashMap::new();
+        residual.insert("data-1".into(), treemap(&[0, 1]));
+        let results = bucket_search(
+            None,
+            &[],
+            &[active("data-1", 2), active("data-2", 2)],
+            &HashMap::new(),
+            &mut factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            5,
+            &HashMap::new(),
+            false,
+            Some(&residual),
+        )
+        .unwrap();
+        // Only data-1 rows appear; data-2 was never read.
+        assert!(results.iter().all(|r| r.data_file_name == "data-1"));
+        assert_eq!(calls.borrow().as_slice(), &["data-1".to_string()]);
+    }
+
+    #[test]
+    fn test_exact_residual_empty_set_file_is_skipped_without_reading() {
+        // data-1 has an entry but it is empty -> no allowed rows, skipped 
without
+        // reading. Mirrors a file with no residual matches.
+        let calls = RefCell::new(0);
+        let mut factory = |_: &BucketActiveFile| -> crate::Result<Box<dyn 
PkVectorReader>> {
+            *calls.borrow_mut() += 1;
+            unreachable!("data-1 has an empty allow set and must not be read")
+        };
+        let mut residual: HashMap<String, roaring::RoaringTreemap> = 
HashMap::new();
+        residual.insert("data-1".into(), treemap(&[]));
+        let results = bucket_search(
+            None,
+            &[],
+            &[active("data-1", 3)],
+            &HashMap::new(),
+            &mut factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            5,
+            &HashMap::new(),
+            false,
+            Some(&residual),
+        )
+        .unwrap();
+        assert!(results.is_empty());
+        assert_eq!(*calls.borrow(), 0);
+    }
+
+    #[test]
+    fn test_exact_residual_intersects_with_deletion_vector() {
+        // residual allows {0, 1, 2} but the deletion vector deletes pos0; the
+        // surviving candidates are the residual-allowed AND not-deleted rows.
+        let mut factory = |_: &BucketActiveFile| -> crate::Result<Box<dyn 
PkVectorReader>> {
+            Ok(Box::new(ArrayReader::new(
+                2,
+                vec![
+                    Some(vec![1.0, 0.0]),
+                    Some(vec![2.0, 0.0]),
+                    Some(vec![3.0, 0.0]),
+                ],
+            )))
+        };
+        let mut dvs: HashMap<String, Arc<DeletionVector>> = HashMap::new();
+        let mut bm = RoaringBitmap::new();
+        bm.insert(0); // pos0 deleted
+        dvs.insert("data-1".into(), Arc::new(DeletionVector::from_bitmap(bm)));
+        let mut residual: HashMap<String, roaring::RoaringTreemap> = 
HashMap::new();
+        residual.insert("data-1".into(), treemap(&[0, 1, 2]));
+        let results = bucket_search(
+            None,
+            &[],
+            &[active("data-1", 3)],
+            &dvs,
+            &mut factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            5,
+            &HashMap::new(),
+            false,
+            Some(&residual),
+        )
+        .unwrap();
+        assert_eq!(
+            results.iter().map(|r| r.row_position).collect::<Vec<_>>(),
+            vec![1, 2]
+        );
+    }
 }
diff --git a/crates/paimon/tests/pk_vector_baseline_test.rs 
b/crates/paimon/tests/pk_vector_baseline_test.rs
index e1e3177..55b144a 100644
--- a/crates/paimon/tests/pk_vector_baseline_test.rs
+++ b/crates/paimon/tests/pk_vector_baseline_test.rs
@@ -58,6 +58,9 @@ use paimon::spec::{
     DataFileMeta, DataType, FloatType, GlobalIndexMeta, IndexFileMeta, 
IntType, Schema,
     TableSchema, VectorType,
 };
+// Used only by the residual end-to-end test, which is gated off Windows.
+#[cfg(not(windows))]
+use paimon::spec::{Datum, Predicate, PredicateBuilder};
 use paimon::table::{CommitMessage, SchemaManager, Table, TableCommit};
 use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer, 
VectorIndexWriter};
 use paimon_vindex_core::io::PosWriter;
@@ -103,9 +106,19 @@ fn analytic_topk(query: &[f32], vectors: &[[f32; DIM]], k: 
usize) -> Vec<(u64, f
 /// Table options that route searches into the primary-key vector branch
 /// (`VectorSearchBuilder::execute_primary_key_vector_search`). Default search
 /// mode is FAST, so only the ANN segment is consulted (no exact fallback).
+///
+/// `deletion-vectors.enabled = true` (and merge-on-read left at its default
+/// `false`) is what makes the table expose physical rows directly, the
+/// precondition Java `PrimaryKeyVectorScan` requires before a residual data
+/// predicate (`with_filter`) may be applied post-recall. This fixture never
+/// actually deletes a row, so no deletion files are written; the option only
+/// satisfies the residual guard and does not otherwise change the write/read
+/// path (default `deduplicate` merge-engine, no deletion files -> no DV 
factory
+/// is built on read).
 fn table_options() -> Vec<(String, String)> {
     vec![
         ("bucket".to_string(), "1".to_string()),
+        ("deletion-vectors.enabled".to_string(), "true".to_string()),
         (
             "pk-vector.index.columns".to_string(),
             VECTOR_COLUMN.to_string(),
@@ -685,3 +698,229 @@ async fn 
pk_vector_read_orders_rows_best_first_not_by_position() {
         );
     }
 }
+
+/// Fixture #3 (residual): the unrestricted nearest neighbours sit at low ids
+/// (0, 1, 2), but the residual predicate `id >= 3` excludes exactly those, so
+/// the residual result set is disjoint from the unfiltered one. Among the rows
+/// the residual keeps (ids 3, 4, 5) the best-first order is [4, 5, 3], which 
is
+/// neither the ascending id order [3, 4, 5] nor a prefix of the unfiltered
+/// order — proving the residual genuinely reshapes the result.
+///
+///   query [10,0,0,0]
+///   pos0 [10,0,0,0] ->  0   (unfiltered nearest; excluded by id >= 3)
+///   pos1 [ 9,0,0,0] ->  1   (excluded)
+///   pos2 [ 8,0,0,0] ->  4   (excluded)
+///   pos3 [ 5,0,0,0] -> 25   (kept; farthest of the kept rows)
+///   pos4 [ 7,0,0,0] ->  9   (kept; nearest of the kept rows)
+///   pos5 [ 6,0,0,0] -> 16   (kept)
+/// Strict gaps 0 < 1 < 4 < 9 < 16 < 25 make every top-k order unique.
+///   unfiltered top-3 = [0, 1, 2]
+///   residual (id >= 3) top-3 = [4, 5, 3]
+#[cfg(not(windows))]
+fn fixture_residual() -> ([f32; DIM], Vec<[f32; DIM]>) {
+    let query = [10.0, 0.0, 0.0, 0.0];
+    let vectors = vec![
+        [10.0, 0.0, 0.0, 0.0], // pos 0 -> 0
+        [9.0, 0.0, 0.0, 0.0],  // pos 1 -> 1
+        [8.0, 0.0, 0.0, 0.0],  // pos 2 -> 4
+        [5.0, 0.0, 0.0, 0.0],  // pos 3 -> 25
+        [7.0, 0.0, 0.0, 0.0],  // pos 4 -> 9
+        [6.0, 0.0, 0.0, 0.0],  // pos 5 -> 16
+    ];
+    (query, vectors)
+}
+
+/// Run `execute_read()` with a residual `filter` attached via `with_filter` 
and
+/// flatten the stream into per-row `(id, score)` tuples in emission order
+/// (best-first), returning the collected batches too for schema / row-content
+/// assertions. Mirrors `read_id_and_scores` but exercises the residual path.
+#[cfg(not(windows))]
+async fn read_id_and_scores_filtered(
+    table: &Table,
+    query: Vec<f32>,
+    limit: usize,
+    filter: Predicate,
+) -> (Vec<i32>, Vec<f32>, Vec<RecordBatch>) {
+    let mut builder = table.new_vector_search_builder();
+    builder
+        .with_vector_column(VECTOR_COLUMN)
+        .with_query_vector(query)
+        .with_limit(limit)
+        .with_filter(filter);
+    let batches = builder
+        .execute_read()
+        .await
+        .expect("primary-key vector residual read failed")
+        .try_collect::<Vec<_>>()
+        .await
+        .expect("collecting residual read batches failed");
+
+    let ids: Vec<i32> = batches
+        .iter()
+        .flat_map(|b| {
+            let idx = b.schema().index_of("id").unwrap();
+            b.column(idx)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect();
+    let scores: Vec<f32> = batches
+        .iter()
+        .flat_map(|b| {
+            let idx = b.schema().index_of("_PKEY_VECTOR_SCORE").unwrap();
+            b.column(idx)
+                .as_any()
+                .downcast_ref::<Float32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect();
+    (ids, scores, batches)
+}
+
+/// End-to-end coverage of the residual data predicate (`with_filter`) on the
+/// public primary-key vector path. Mirrors Java `PrimaryKeyVectorRead`'s
+/// residual-filter support: the residual columns are re-read per candidate 
file,
+/// the surviving physical positions are folded into recall, and only rows
+/// satisfying the predicate remain — best-first and Top-K preserved.
+///
+/// The fixture is built so the residual actually changes the result set: the
+/// unfiltered top-3 is [0, 1, 2] while the residual (`id >= 3`) top-3 is
+/// [4, 5, 3]. The two sets are disjoint, so a residual that silently did 
nothing
+/// (or was ignored) would surface here.
+// Gated off Windows for the same `file://` tempdir reason as the tests above.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn pk_vector_residual_filter_excludes_non_matching_rows() {
+    let (query, vectors) = fixture_residual();
+    let (_tmp, table) = build_table(&query, &vectors, 3).await;
+
+    // Ground truth: unrestricted top-3, and the top-3 restricted to ids >= 3
+    // (the residual only ranks rows whose id passes the predicate).
+    let unfiltered = analytic_topk(&query, &vectors, 3);
+    let unfiltered_ids: Vec<u64> = unfiltered.iter().map(|(id, _)| 
*id).collect();
+    assert_eq!(
+        unfiltered_ids,
+        vec![0, 1, 2],
+        "fixture guard: unfiltered top-3 must be [0, 1, 2]"
+    );
+
+    let residual_threshold = 3;
+    let mut residual_ranked: Vec<(u64, f32)> = analytic_topk(&query, &vectors, 
vectors.len())
+        .into_iter()
+        .filter(|(id, _)| *id >= residual_threshold)
+        .collect();
+    residual_ranked.truncate(3);
+    let expected_ids: Vec<i32> = residual_ranked.iter().map(|(id, _)| *id as 
i32).collect();
+    let expected_scores: Vec<f32> = residual_ranked.iter().map(|(_, d)| 
l2_score(*d)).collect();
+    // The whole point of this fixture: residual result != unfiltered result, 
and
+    // best-first over the kept rows is not ascending id order.
+    assert_eq!(
+        expected_ids,
+        vec![4, 5, 3],
+        "fixture guard: residual (id >= 3) top-3 must be best-first [4, 5, 3]"
+    );
+
+    // Build the residual predicate on the data column `id` via the public
+    // PredicateBuilder, exactly as a caller would.
+    let residual = PredicateBuilder::new(table.schema().fields())
+        .greater_or_equal("id", Datum::Int(residual_threshold as i32))
+        .expect("build residual predicate on id");
+
+    // Search-only: unfiltered vs residual must differ, and every residual hit
+    // must satisfy the predicate (id >= 3), disjoint from the unfiltered set.
+    let unfiltered_result = table
+        .new_vector_search_builder()
+        .with_vector_column(VECTOR_COLUMN)
+        .with_query_vector(query.to_vec())
+        .with_limit(3)
+        .execute_scored()
+        .await
+        .expect("unfiltered primary-key vector search failed");
+    assert_eq!(
+        unfiltered_result.row_ids, unfiltered_ids,
+        "unfiltered search must return [0, 1, 2]"
+    );
+
+    let residual_result = table
+        .new_vector_search_builder()
+        .with_vector_column(VECTOR_COLUMN)
+        .with_query_vector(query.to_vec())
+        .with_limit(3)
+        .with_filter(residual.clone())
+        .execute_scored()
+        .await
+        .expect("residual primary-key vector search failed");
+    let residual_row_ids: Vec<u64> = expected_ids.iter().map(|&id| id as 
u64).collect();
+    assert_eq!(
+        residual_result.row_ids, residual_row_ids,
+        "residual search must return best-first [4, 5, 3]"
+    );
+    assert_ne!(
+        residual_result.row_ids, unfiltered_result.row_ids,
+        "residual must change the result set relative to no filter"
+    );
+    for &id in &residual_result.row_ids {
+        assert!(
+            id >= residual_threshold,
+            "residual search returned id {id} that fails the predicate id >= 
{residual_threshold}"
+        );
+    }
+
+    // Search-and-read with the residual: default projection materializes id +
+    // vector column, best-first, with an aligned `_PKEY_VECTOR_SCORE`.
+    let (ids, scores, batches) =
+        read_id_and_scores_filtered(&table, query.to_vec(), 3, residual).await;
+
+    assert_eq!(
+        ids, expected_ids,
+        "residual read must emit only kept rows, best-first [4, 5, 3]"
+    );
+    for &id in &ids {
+        assert!(
+            id >= residual_threshold as i32,
+            "residual read returned id {id} that fails the predicate id >= 
{residual_threshold}"
+        );
+    }
+
+    // Row content: the materialized vector for each emitted row equals the 
source
+    // vector at that physical position.
+    let got_vectors = collect_vectors(&batches);
+    assert_eq!(got_vectors.len(), 3, "three kept rows expected");
+    for (row_idx, (id, _)) in residual_ranked.iter().enumerate() {
+        assert_eq!(
+            got_vectors[row_idx],
+            vectors[*id as usize].to_vec(),
+            "materialized vector for row id {id} diverges from source data"
+        );
+    }
+
+    // Score alignment on the residual read path.
+    assert_eq!(scores.len(), 3);
+    for (got, want) in scores.iter().zip(&expected_scores) {
+        assert!(
+            (got - want).abs() < 1e-4,
+            "residual read score diverges: got {got}, want {want}"
+        );
+    }
+
+    // Hidden metadata columns must not leak, even on the residual path.
+    for batch in &batches {
+        assert!(
+            batch.schema().index_of("_ROW_ID").is_err(),
+            "_ROW_ID must not leak into residual read output"
+        );
+        assert!(
+            batch.schema().index_of("_PKEY_VECTOR_POSITION").is_err(),
+            "_PKEY_VECTOR_POSITION must not leak into residual read output"
+        );
+        assert!(
+            batch.schema().index_of(VECTOR_COLUMN).is_ok(),
+            "default projection must materialize the vector column"
+        );
+    }
+}

Reply via email to