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 316aa3e  Support data evolution deletes with deletion vectors (#451)
316aa3e is described below

commit 316aa3ec410003fccb54a52925094147c09fc9d0
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 4 23:03:51 2026 +0800

    Support data evolution deletes with deletion vectors (#451)
---
 crates/integrations/datafusion/src/delete.rs       |  60 +++-
 crates/integrations/datafusion/src/merge_into.rs   |  37 +-
 .../integrations/datafusion/tests/delete_tests.rs  |  47 ++-
 .../datafusion/tests/merge_into_tests.rs           |  35 +-
 crates/paimon/src/deletion_vector/core.rs          |  40 +++
 crates/paimon/src/deletion_vector/factory.rs       |   2 +-
 crates/paimon/src/lib.rs                           |   8 +-
 crates/paimon/src/table/data_evolution_reader.rs   | 239 +++++++++++--
 crates/paimon/src/table/data_evolution_writer.rs   | 382 ++++++++++++++++++++-
 .../paimon/src/table/full_text_search_builder.rs   |  34 +-
 crates/paimon/src/table/global_index_scanner.rs    | 158 ++++++++-
 crates/paimon/src/table/mod.rs                     |   2 +-
 crates/paimon/src/table/source.rs                  |  28 +-
 crates/paimon/src/table/table_commit.rs            | 111 +++++-
 crates/paimon/src/table/vector_search_builder.rs   |  48 ++-
 crates/paimon/src/table/write_builder.rs           |   7 +-
 crates/paimon/src/tantivy/full_text_search.rs      |  40 +++
 crates/paimon/src/vector_search.rs                 |  40 +++
 18 files changed, 1247 insertions(+), 71 deletions(-)

diff --git a/crates/integrations/datafusion/src/delete.rs 
b/crates/integrations/datafusion/src/delete.rs
index c890cf6..832acd9 100644
--- a/crates/integrations/datafusion/src/delete.rs
+++ b/crates/integrations/datafusion/src/delete.rs
@@ -29,8 +29,8 @@ use paimon::table::{CopyOnWriteMergeWriter, Table};
 use crate::error::to_datafusion_error;
 use crate::merge_into::TempTableTracker;
 use crate::merge_into::{
-    build_partition_set_from_where, extract_tracking_columns, 
is_delete_conflict, ok_result,
-    register_cow_target_table, retry_on_conflict,
+    build_partition_set_from_where, extract_tracking_columns, 
is_delete_conflict,
+    is_row_id_conflict, ok_result, register_cow_target_table, 
retry_on_conflict,
 };
 use crate::sql_context::SQLContext;
 
@@ -60,9 +60,7 @@ pub(crate) async fn execute_delete(
     let core_options = CoreOptions::new(schema.options());
 
     if core_options.data_evolution_enabled() {
-        return Err(DataFusionError::Plan(
-            "DELETE on data-evolution tables is not yet supported".to_string(),
-        ));
+        return execute_data_evolution_delete(ctx, delete, &table, 
table_ref).await;
     }
     if !schema.trimmed_primary_keys().is_empty() {
         return Err(DataFusionError::Plan(
@@ -73,6 +71,58 @@ pub(crate) async fn execute_delete(
     execute_cow_delete(ctx, delete, &table, table_ref).await
 }
 
+/// Execute DELETE on a data-evolution table with retry on row ID conflict.
+async fn execute_data_evolution_delete(
+    ctx: &SQLContext,
+    delete: &Delete,
+    table: &Table,
+    table_ref: &str,
+) -> DFResult<DataFrame> {
+    retry_on_conflict("Data-evolution DELETE", is_row_id_conflict, || {
+        execute_data_evolution_delete_once(ctx, delete, table, table_ref)
+    })
+    .await
+}
+
+/// Single attempt of data-evolution DELETE execution.
+async fn execute_data_evolution_delete_once(
+    ctx: &SQLContext,
+    delete: &Delete,
+    table: &Table,
+    table_ref: &str,
+) -> DFResult<DataFrame> {
+    let wb = table.new_write_builder();
+    let mut writer = wb.new_delete().map_err(to_datafusion_error)?;
+
+    let where_clause = match &delete.selection {
+        Some(expr) => format!(" WHERE {expr}"),
+        None => String::new(),
+    };
+    let query_sql = format!("SELECT \"_ROW_ID\" FROM 
{table_ref}{where_clause}");
+    let batches = ctx.ctx().sql(&query_sql).await?.collect().await?;
+
+    let total_count: u64 = batches.iter().map(|batch| batch.num_rows() as 
u64).sum();
+    if total_count == 0 {
+        return ok_result(ctx.ctx(), 0);
+    }
+
+    for batch in batches {
+        writer
+            .add_matched_batch(batch)
+            .map_err(to_datafusion_error)?;
+    }
+
+    let messages = writer.prepare_commit().await.map_err(to_datafusion_error)?;
+    if !messages.is_empty() {
+        wb.new_commit()
+            .commit(messages)
+            .await
+            .map_err(to_datafusion_error)?;
+    }
+
+    ok_result(ctx.ctx(), total_count)
+}
+
 /// Execute DELETE on an append-only table with retry on delete conflict.
 async fn execute_cow_delete(
     ctx: &SQLContext,
diff --git a/crates/integrations/datafusion/src/merge_into.rs 
b/crates/integrations/datafusion/src/merge_into.rs
index 79a8a0b..203538d 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -640,6 +640,11 @@ async fn execute_merge_into_once(
     } else {
         None
     };
+    let delete_writer = if parsed.delete {
+        Some(wb.new_delete().map_err(to_datafusion_error)?)
+    } else {
+        None
+    };
 
     let (target_ref, target_alias) = extract_table_ref(&merge.table)?;
     let (source_ref, source_alias) = extract_source_ref(&merge.source)?;
@@ -685,7 +690,7 @@ async fn execute_merge_into_once(
     // Separate matched and not-matched rows
     let (matched_batches, not_matched_batches) = 
split_by_row_id(&join_result)?;
 
-    // 4. Handle matched rows (UPDATE)
+    // 4. Handle matched rows (UPDATE or DELETE)
     if let Some(mut writer) = update_writer {
         let upd = parsed.update.as_ref().unwrap();
         let matched_count: usize = matched_batches.iter().map(|b| 
b.num_rows()).sum();
@@ -702,6 +707,19 @@ async fn execute_merge_into_once(
             total_count += matched_count as u64;
         }
     }
+    if let Some(mut writer) = delete_writer {
+        let matched_count: usize = matched_batches.iter().map(|b| 
b.num_rows()).sum();
+        if matched_count > 0 {
+            for batch in &matched_batches {
+                writer
+                    .add_matched_batch(batch.clone())
+                    .map_err(to_datafusion_error)?;
+            }
+            let delete_messages = 
writer.prepare_commit().await.map_err(to_datafusion_error)?;
+            all_messages.extend(delete_messages);
+            total_count += matched_count as u64;
+        }
+    }
 
     // 5. Handle not-matched rows (INSERT)
     if !parsed.inserts.is_empty() {
@@ -970,18 +988,20 @@ struct MergeUpdateClause {
 /// Parsed merge clauses.
 struct ParsedMergeClauses {
     update: Option<MergeUpdateClause>,
+    delete: bool,
     inserts: Vec<MergeInsertClause>,
 }
 
 /// Extract UPDATE and INSERT clauses from the MERGE AST.
 fn extract_merge_clauses(merge: &Merge) -> DFResult<ParsedMergeClauses> {
     let mut update: Option<MergeUpdateClause> = None;
+    let mut delete = false;
     let mut inserts: Vec<MergeInsertClause> = Vec::new();
 
     for clause in &merge.clauses {
         match clause.clause_kind {
             MergeClauseKind::Matched => {
-                if update.is_some() {
+                if update.is_some() || delete {
                     return Err(DataFusionError::Plan(
                         "Multiple WHEN MATCHED clauses are not yet 
supported".to_string(),
                     ));
@@ -1020,10 +1040,7 @@ fn extract_merge_clauses(merge: &Merge) -> 
DFResult<ParsedMergeClauses> {
                         update = Some(MergeUpdateClause { columns, exprs });
                     }
                     MergeAction::Delete { .. } => {
-                        return Err(DataFusionError::Plan(
-                            "WHEN MATCHED THEN DELETE is not supported for 
data evolution tables"
-                                .to_string(),
-                        ));
+                        delete = true;
                     }
                     MergeAction::Insert(_) => {
                         return Err(DataFusionError::Plan(
@@ -1077,13 +1094,17 @@ fn extract_merge_clauses(merge: &Merge) -> 
DFResult<ParsedMergeClauses> {
         }
     }
 
-    if update.is_none() && inserts.is_empty() {
+    if update.is_none() && !delete && inserts.is_empty() {
         return Err(DataFusionError::Plan(
             "MERGE INTO requires at least one WHEN MATCHED or WHEN NOT MATCHED 
clause".to_string(),
         ));
     }
 
-    Ok(ParsedMergeClauses { update, inserts })
+    Ok(ParsedMergeClauses {
+        update,
+        delete,
+        inserts,
+    })
 }
 
 /// Extract table name and optional alias from a TableFactor.
diff --git a/crates/integrations/datafusion/tests/delete_tests.rs 
b/crates/integrations/datafusion/tests/delete_tests.rs
index ac9ef95..aa247af 100644
--- a/crates/integrations/datafusion/tests/delete_tests.rs
+++ b/crates/integrations/datafusion/tests/delete_tests.rs
@@ -481,7 +481,7 @@ async fn test_delete_rejects_primary_key_table() {
 }
 
 #[tokio::test]
-async fn test_delete_rejects_data_evolution_table() {
+async fn test_delete_rejects_data_evolution_table_without_deletion_vectors() {
     let (tmp, catalog) = create_test_env();
     let sql_context = create_sql_context(catalog).await;
     sql_context
@@ -503,12 +503,55 @@ async fn test_delete_rejects_data_evolution_table() {
     assert_sql_error(
         &sql_context,
         "DELETE FROM paimon.test_db.de_t WHERE id = 1",
-        "DELETE on data-evolution tables is not yet supported",
+        "deletion-vectors.enabled",
     )
     .await;
     drop(tmp);
 }
 
+#[tokio::test]
+async fn test_delete_data_evolution_table_with_deletion_vectors() {
+    let (tmp, catalog) = create_test_env();
+    let sql_context = create_sql_context(catalog).await;
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap();
+    sql_context
+        .sql(
+            "CREATE TABLE paimon.test_db.de_t (\
+                id INT NOT NULL, name VARCHAR, age INT\
+            ) WITH (\
+                'row-tracking.enabled' = 'true',\
+                'data-evolution.enabled' = 'true',\
+                'deletion-vectors.enabled' = 'true'\
+            )",
+        )
+        .await
+        .unwrap();
+    exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.de_t (id, name, age) VALUES (1, 'a', 10), 
(2, 'b', 20), (3, 'c', 30)",
+    )
+    .await;
+
+    let cnt = dml_count(&sql_context, "DELETE FROM paimon.test_db.de_t WHERE 
id = 2").await;
+    assert_eq!(cnt, 1);
+
+    assert_eq!(
+        query_int_str_int(
+            &sql_context,
+            "SELECT id, name, age FROM paimon.test_db.de_t ORDER BY id",
+        )
+        .await,
+        vec![(1, "a".into(), 10), (3, "c".into(), 30)]
+    );
+
+    let second_cnt = dml_count(&sql_context, "DELETE FROM paimon.test_db.de_t 
WHERE id = 2").await;
+    assert_eq!(second_cnt, 0);
+    drop(tmp);
+}
+
 #[tokio::test]
 async fn test_delete_rejects_table_alias() {
     let (_tmp, sql_context) = setup().await;
diff --git a/crates/integrations/datafusion/tests/merge_into_tests.rs 
b/crates/integrations/datafusion/tests/merge_into_tests.rs
index c6271ed..f3e9358 100644
--- a/crates/integrations/datafusion/tests/merge_into_tests.rs
+++ b/crates/integrations/datafusion/tests/merge_into_tests.rs
@@ -827,20 +827,31 @@ async fn 
test_merge_into_rejects_duplicate_matched_updates() {
 }
 
 #[tokio::test]
-async fn test_rejects_when_matched_delete() {
+async fn test_when_matched_delete_with_deletion_vectors() {
     let (_tmp, catalog) = create_test_env();
     let sql_context = create_sql_context(catalog.clone()).await;
     setup_data_evolution_table(&sql_context).await;
 
     sql_context
-        .sql("INSERT INTO paimon.test_db.target (id, name, value) VALUES (1, 
'alice', 10)")
+        .sql("INSERT INTO paimon.test_db.target (id, name, value) VALUES (1, 
'alice', 10), (2, 'bob', 20)")
         .await
         .unwrap()
         .collect()
         .await
         .unwrap();
 
-    enable_data_evolution(&sql_context).await;
+    sql_context
+        .sql(
+            "ALTER TABLE paimon.test_db.target SET TBLPROPERTIES(\
+                'data-evolution.enabled' = 'true',\
+                'deletion-vectors.enabled' = 'true'\
+            )",
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
 
     register_source(
         &sql_context,
@@ -848,13 +859,23 @@ async fn test_rejects_when_matched_delete() {
     )
     .await;
 
-    assert_merge_error(
+    sql_context
+        .sql(
+            "MERGE INTO paimon.test_db.target t USING paimon.test_db.src_del s 
ON t.id = s.id \
+             WHEN MATCHED THEN DELETE",
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    let rows = collect_rows_3col(
         &sql_context,
-        "MERGE INTO paimon.test_db.target t USING paimon.test_db.src_del s ON 
t.id = s.id \
-         WHEN MATCHED THEN DELETE",
-        "WHEN MATCHED THEN DELETE is not supported",
+        "SELECT id, name, value FROM paimon.test_db.target ORDER BY id",
     )
     .await;
+    assert_eq!(rows, vec![(2, "bob".to_string(), 20)]);
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/deletion_vector/core.rs 
b/crates/paimon/src/deletion_vector/core.rs
index 831f1d0..11c2d06 100644
--- a/crates/paimon/src/deletion_vector/core.rs
+++ b/crates/paimon/src/deletion_vector/core.rs
@@ -49,6 +49,16 @@ impl DeletionVector {
         }
     }
 
+    /// Clone the underlying bitmap for mutation by writers.
+    pub(crate) fn to_bitmap(&self) -> RoaringBitmap {
+        (*self.bitmap).clone()
+    }
+
+    /// Number of deleted positions in this vector.
+    pub fn cardinality(&self) -> u64 {
+        self.bitmap.len()
+    }
+
     /// Returns an iterator over deleted positions that supports 
[DeletionVectorIterator::advance_to].
     /// Required for efficient row selection building when skipping row groups 
(avoid re-scanning
     /// deletes in skipped ranges).
@@ -66,6 +76,36 @@ impl DeletionVector {
         self.bitmap.is_empty()
     }
 
+    /// Serialize using Java `BitmapDeletionVector` format:
+    /// `i32 bitmapLength | i32 magic | roaring bitmap bytes | i32 crc`.
+    pub(crate) fn serialize_to_bytes(&self) -> crate::Result<Vec<u8>> {
+        let mut bitmap_bytes = Vec::new();
+        self.bitmap
+            .serialize_into(&mut bitmap_bytes)
+            .map_err(|e| crate::Error::DataInvalid {
+                message: format!("Failed to serialize RoaringBitmap: {e}"),
+                source: Some(Box::new(e)),
+            })?;
+
+        let bitmap_length =
+            i32::try_from(MAGIC_NUMBER_SIZE_BYTES + 
bitmap_bytes.len()).map_err(|_| {
+                crate::Error::DataInvalid {
+                    message: "Deletion vector bitmap is too large to 
serialize".to_string(),
+                    source: None,
+                }
+            })?;
+
+        let mut payload = Vec::with_capacity(8 + bitmap_bytes.len() + 4);
+        payload.extend_from_slice(&bitmap_length.to_be_bytes());
+        payload.extend_from_slice(&(MAGIC_NUMBER as i32).to_be_bytes());
+        payload.extend_from_slice(&bitmap_bytes);
+
+        let mut crc = crc32fast::Hasher::new();
+        crc.update(&payload[4..]);
+        payload.extend_from_slice(&(crc.finalize() as i32).to_be_bytes());
+        Ok(payload)
+    }
+
     /// Get the underlying bitmap (read-only)
     #[cfg(test)]
     fn bitmap(&self) -> &RoaringBitmap {
diff --git a/crates/paimon/src/deletion_vector/factory.rs 
b/crates/paimon/src/deletion_vector/factory.rs
index 927d92f..cb5a47e 100644
--- a/crates/paimon/src/deletion_vector/factory.rs
+++ b/crates/paimon/src/deletion_vector/factory.rs
@@ -63,7 +63,7 @@ impl DeletionVectorFactory {
 
     /// Read a single DeletionVector from storage using DeletionFile 
(path/offset/length).
     /// Same as Java's DeletionVector.read(FileIO, DeletionFile).
-    async fn read(file_io: &FileIO, df: &crate::DeletionFile) -> 
Result<DeletionVector> {
+    pub(crate) async fn read(file_io: &FileIO, df: &crate::DeletionFile) -> 
Result<DeletionVector> {
         let input = file_io.new_input(df.path())?;
         let reader = input.reader().await?;
         let offset = df.offset() as u64;
diff --git a/crates/paimon/src/lib.rs b/crates/paimon/src/lib.rs
index a697218..8c26fe6 100644
--- a/crates/paimon/src/lib.rs
+++ b/crates/paimon/src/lib.rs
@@ -45,8 +45,8 @@ pub use catalog::CatalogFactory;
 pub use catalog::FileSystemCatalog;
 
 pub use table::{
-    CommitMessage, DataEvolutionWriter, DataSplit, DataSplitBuilder, 
DeletionFile, PartitionBucket,
-    Plan, RESTEnv, RESTSnapshotCommit, ReadBuilder, RenamingSnapshotCommit, 
RowRange, ScanTrace,
-    SnapshotCommit, SnapshotManager, Table, TableCommit, TableRead, TableScan, 
TableUpdate,
-    TableWrite, TagManager, WriteBuilder,
+    CommitMessage, DataEvolutionDeleteWriter, DataEvolutionWriter, DataSplit, 
DataSplitBuilder,
+    DeletionFile, PartitionBucket, Plan, RESTEnv, RESTSnapshotCommit, 
ReadBuilder,
+    RenamingSnapshotCommit, RowRange, ScanTrace, SnapshotCommit, 
SnapshotManager, Table,
+    TableCommit, TableRead, TableScan, TableUpdate, TableWrite, TagManager, 
WriteBuilder,
 };
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 8f4a5cb..e016240 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -20,6 +20,7 @@ use super::data_file_reader::{
     DataFileReader,
 };
 use crate::arrow::build_target_arrow_schema;
+use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
 use crate::io::FileIO;
 use crate::spec::{DataField, DataFileMeta, DataType, ROW_ID_FIELD_NAME};
 use crate::table::blob_file_writer::is_blob_file_name;
@@ -30,6 +31,7 @@ use crate::{DataSplit, Error};
 use arrow_array::{Array, Int64Array, RecordBatch};
 use async_stream::try_stream;
 use futures::StreamExt;
+use roaring::RoaringBitmap;
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
@@ -140,6 +142,12 @@ impl DataEvolutionReader {
 
                 if is_raw_convertible(split.data_files()) {
                     for file_meta in split.data_files().to_vec() {
+                        let deletion_vector = read_file_deletion_vector(
+                            &self.file_io,
+                            &split,
+                            &file_meta,
+                        )
+                        .await?;
                         let data_fields: Option<Vec<DataField>> =
                             if file_meta.schema_id != self.table_schema_id {
                                 let data_schema =
@@ -153,11 +161,17 @@ impl DataEvolutionReader {
                         let effective_row_ranges = if has_row_id { 
row_ranges.clone() } else { None };
 
                         let selected_row_ids = if self.row_id_index.is_some() 
&& has_row_id {
-                            effective_row_ranges.as_ref().map(|ranges| {
+                            selected_absolute_row_ranges_for_file(
+                                file_meta.first_row_id.unwrap(),
+                                file_meta.row_count,
+                                effective_row_ranges.as_deref(),
+                                deletion_vector.as_deref(),
+                            )?
+                            .map(|ranges| {
                                 expand_selected_row_ids(
                                     file_meta.first_row_id.unwrap(),
                                     file_meta.row_count,
-                                    ranges,
+                                    &ranges,
                                 )
                             })
                         } else {
@@ -171,7 +185,7 @@ impl DataEvolutionReader {
                             &split,
                             file_meta,
                             data_fields,
-                            None,
+                            deletion_vector,
                             effective_row_ranges,
                         )?;
                         while let Some(batch) = stream.next().await {
@@ -200,15 +214,28 @@ impl DataEvolutionReader {
                     }
                 } else {
                     let prepared_group = 
PreparedMergeGroup::new(split.data_files())?;
+                    let anchor_deletion_vector = read_anchor_deletion_vector(
+                        &self.file_io,
+                        &split,
+                        &prepared_group.files,
+                    )
+                    .await?;
                     let effective_row_ranges = row_ranges.clone();
-                    let expected_output_rows = count_selected_rows(
+                    let selected_ranges = 
selected_absolute_row_ranges_for_file(
                         prepared_group.first_row_id,
                         prepared_group.logical_row_count,
                         effective_row_ranges.as_deref(),
+                        anchor_deletion_vector
+                            .as_ref()
+                            .map(|ctx| ctx.deletion_vector.as_ref()),
                     )?;
+                    let expected_output_rows = match selected_ranges.as_ref() {
+                        Some(ranges) => ranges.iter().map(|r| r.count() as 
usize).sum(),
+                        None => prepared_group.logical_row_count as usize,
+                    };
 
                     let selected_row_ids = if self.row_id_index.is_some() {
-                        effective_row_ranges.as_ref().map(|ranges| {
+                        selected_ranges.as_ref().map(|ranges| {
                             expand_selected_row_ids(
                                 prepared_group.first_row_id,
                                 prepared_group.logical_row_count,
@@ -226,6 +253,7 @@ impl DataEvolutionReader {
                         &prepared_group,
                         effective_row_ranges,
                         expected_output_rows,
+                        anchor_deletion_vector,
                     )?;
                     while let Some(batch) = merge_stream.next().await {
                         let batch = batch?;
@@ -260,6 +288,7 @@ impl DataEvolutionReader {
         prepared_group: &PreparedMergeGroup,
         row_ranges: Option<Vec<RowRange>>,
         expected_output_rows: usize,
+        anchor_deletion_vector: Option<DeletionVectorContext>,
     ) -> crate::Result<ArrowRecordBatchStream> {
         if prepared_group.files.is_empty() {
             return Ok(futures::stream::empty().boxed());
@@ -274,6 +303,7 @@ impl DataEvolutionReader {
         let table_fields = self.table_fields.clone();
         let blob_descriptor_fields = self.blob_descriptor_fields.clone();
         let blob_as_descriptor = self.blob_as_descriptor;
+        let anchor_deletion_vector = anchor_deletion_vector.clone();
         // Batch size for column-merge output. Matches the default Parquet 
reader batch size.
         const MERGE_BATCH_SIZE: usize = 1024;
         let target_schema = build_target_arrow_schema(&read_type)?;
@@ -340,6 +370,7 @@ impl DataEvolutionReader {
                             table_schema_id,
                             table_fields.clone(),
                             blob_as_descriptor,
+                            anchor_deletion_vector.as_ref(),
                         )
                         .map(Some)
                     }
@@ -501,6 +532,7 @@ fn open_source_stream(
     table_schema_id: i64,
     table_fields: Vec<DataField>,
     blob_as_descriptor: bool,
+    anchor_deletion_vector: Option<&DeletionVectorContext>,
 ) -> crate::Result<ArrowRecordBatchStream> {
     let file_reader = DataFileReader::new(
         file_io,
@@ -515,13 +547,16 @@ fn open_source_stream(
     match source {
         FieldSource::DataFile {
             file, data_fields, ..
-        } => file_reader.read_single_file_stream(
-            split,
-            file.as_ref().clone(),
-            data_fields.clone(),
-            None,
-            row_ranges,
-        ),
+        } => {
+            let deletion_vector = shifted_deletion_vector_for_file(file, 
anchor_deletion_vector)?;
+            file_reader.read_single_file_stream(
+                split,
+                file.as_ref().clone(),
+                data_fields.clone(),
+                deletion_vector,
+                row_ranges,
+            )
+        }
         FieldSource::BlobBunch {
             bunch, data_fields, ..
         } => read_bunch_files_stream(
@@ -530,6 +565,7 @@ fn open_source_stream(
             bunch.files.clone(),
             data_fields.clone(),
             row_ranges,
+            anchor_deletion_vector.cloned(),
         ),
         FieldSource::VectorBunch {
             bunch, data_fields, ..
@@ -539,6 +575,7 @@ fn open_source_stream(
             bunch.files.clone(),
             data_fields.clone(),
             row_ranges,
+            anchor_deletion_vector.cloned(),
         ),
     }
 }
@@ -549,15 +586,18 @@ fn read_bunch_files_stream(
     files: Vec<DataFileMeta>,
     data_fields: Option<Vec<DataField>>,
     row_ranges: Option<Vec<RowRange>>,
+    anchor_deletion_vector: Option<DeletionVectorContext>,
 ) -> crate::Result<ArrowRecordBatchStream> {
     let split = split.clone();
     Ok(try_stream! {
         for file in files {
+            let deletion_vector =
+                shifted_deletion_vector_for_file(&file, 
anchor_deletion_vector.as_ref())?;
             let mut stream = file_reader.read_single_file_stream(
                 &split,
                 file,
                 data_fields.clone(),
-                None,
+                deletion_vector,
                 row_ranges.clone(),
             )?;
             while let Some(batch) = stream.next().await {
@@ -568,6 +608,165 @@ fn read_bunch_files_stream(
     .boxed())
 }
 
+#[derive(Debug, Clone)]
+struct DeletionVectorContext {
+    first_row_id: i64,
+    deletion_vector: Arc<DeletionVector>,
+}
+
+async fn read_file_deletion_vector(
+    file_io: &FileIO,
+    split: &DataSplit,
+    file: &DataFileMeta,
+) -> crate::Result<Option<Arc<DeletionVector>>> {
+    let Some(deletion_file) = split.deletion_file_for_data_file(file) else {
+        return Ok(None);
+    };
+    Ok(Some(Arc::new(
+        DeletionVectorFactory::read(file_io, deletion_file).await?,
+    )))
+}
+
+async fn read_anchor_deletion_vector(
+    file_io: &FileIO,
+    split: &DataSplit,
+    files: &[DataFileMeta],
+) -> crate::Result<Option<DeletionVectorContext>> {
+    let anchor = crate::table::source::data_evolution_anchor_file(files)?;
+    let Some(deletion_file) = split.deletion_file_for_data_file(anchor) else {
+        return Ok(None);
+    };
+    let first_row_id = anchor.first_row_id.ok_or_else(|| Error::DataInvalid {
+        message: format!(
+            "Data-evolution anchor file '{}' is missing first_row_id",
+            anchor.file_name
+        ),
+        source: None,
+    })?;
+    Ok(Some(DeletionVectorContext {
+        first_row_id,
+        deletion_vector: Arc::new(DeletionVectorFactory::read(file_io, 
deletion_file).await?),
+    }))
+}
+
+fn shifted_deletion_vector_for_file(
+    file: &DataFileMeta,
+    context: Option<&DeletionVectorContext>,
+) -> crate::Result<Option<Arc<DeletionVector>>> {
+    let Some(context) = context else {
+        return Ok(None);
+    };
+    let Some(file_first_row_id) = file.first_row_id else {
+        return Ok(None);
+    };
+
+    if file_first_row_id == context.first_row_id {
+        return Ok(Some(context.deletion_vector.clone()));
+    }
+
+    let file_end = file_first_row_id + file.row_count - 1;
+    let mut bitmap = RoaringBitmap::new();
+    for deleted in context.deletion_vector.iter() {
+        let row_id = context.first_row_id + deleted as i64;
+        if row_id < file_first_row_id || row_id > file_end {
+            continue;
+        }
+        let local = u32::try_from(row_id - file_first_row_id).map_err(|_| 
Error::DataInvalid {
+            message: format!(
+                "Deleted row id {row_id} cannot be represented as a local 
deletion-vector position for file '{}'",
+                file.file_name
+            ),
+            source: None,
+        })?;
+        bitmap.insert(local);
+    }
+
+    if bitmap.is_empty() {
+        Ok(None)
+    } else {
+        Ok(Some(Arc::new(DeletionVector::from_bitmap(bitmap))))
+    }
+}
+
+fn selected_absolute_row_ranges_for_file(
+    first_row_id: i64,
+    row_count: i64,
+    row_ranges: Option<&[RowRange]>,
+    deletion_vector: Option<&DeletionVector>,
+) -> crate::Result<Option<Vec<RowRange>>> {
+    let has_ranges = row_ranges.is_some();
+    let has_deletion_vector = deletion_vector.is_some_and(|dv| !dv.is_empty());
+    if !has_ranges && !has_deletion_vector {
+        return Ok(None);
+    }
+    if row_count == 0 {
+        return Ok(Some(Vec::new()));
+    }
+
+    let mut local_ranges = if let Some(dv) = deletion_vector {
+        non_deleted_local_ranges(row_count, dv)
+    } else {
+        vec![RowRange::new(0, row_count - 1)]
+    };
+
+    if let Some(ranges) = row_ranges {
+        let selected = ranges
+            .iter()
+            .filter_map(|range| {
+                range
+                    .intersect_inclusive(first_row_id, first_row_id + 
row_count - 1)
+                    .map(|range| {
+                        RowRange::new(range.from() - first_row_id, range.to() 
- first_row_id)
+                    })
+            })
+            .collect::<Vec<_>>();
+        local_ranges = intersect_local_ranges(&local_ranges, &selected);
+    }
+
+    let absolute = local_ranges
+        .into_iter()
+        .map(|range| RowRange::new(first_row_id + range.from(), first_row_id + 
range.to()))
+        .collect::<Vec<_>>();
+    Ok(Some(absolute))
+}
+
+fn non_deleted_local_ranges(row_count: i64, deletion_vector: &DeletionVector) 
-> Vec<RowRange> {
+    let mut ranges = Vec::new();
+    let mut cursor = 0i64;
+    for deleted in deletion_vector.iter() {
+        let deleted = deleted as i64;
+        if deleted >= row_count {
+            break;
+        }
+        if deleted > cursor {
+            ranges.push(RowRange::new(cursor, deleted - 1));
+        }
+        cursor = deleted + 1;
+    }
+    if cursor < row_count {
+        ranges.push(RowRange::new(cursor, row_count - 1));
+    }
+    ranges
+}
+
+fn intersect_local_ranges(left: &[RowRange], right: &[RowRange]) -> 
Vec<RowRange> {
+    let mut result = Vec::new();
+    let (mut i, mut j) = (0usize, 0usize);
+    while i < left.len() && j < right.len() {
+        let from = left[i].from().max(right[j].from());
+        let to = left[i].to().min(right[j].to());
+        if from <= to {
+            result.push(RowRange::new(from, to));
+        }
+        if left[i].to() < right[j].to() {
+            i += 1;
+        } else {
+            j += 1;
+        }
+    }
+    result
+}
+
 #[derive(Debug, Clone)]
 struct PreparedMergeGroup {
     files: Vec<DataFileMeta>,
@@ -1317,20 +1516,6 @@ fn normalize_merge_group(files: Vec<DataFileMeta>) -> 
crate::Result<Vec<DataFile
     Ok(out)
 }
 
-fn count_selected_rows(
-    first_row_id: i64,
-    row_count: i64,
-    row_ranges: Option<&[RowRange]>,
-) -> crate::Result<usize> {
-    match row_ranges {
-        Some(ranges) => Ok(expand_selected_row_ids(first_row_id, row_count, 
ranges).len()),
-        None => usize::try_from(row_count).map_err(|e| Error::DataInvalid {
-            message: format!("Invalid logical row count {row_count}"),
-            source: Some(Box::new(e)),
-        }),
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/crates/paimon/src/table/data_evolution_writer.rs 
b/crates/paimon/src/table/data_evolution_writer.rs
index 3e12316..2c51f92 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -26,19 +26,34 @@
 //! This separation allows callers to compose multiple operations into a 
single commit,
 //! similar to Iceberg's Transaction/Action pattern.
 
+use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
 use crate::io::FileIO;
-use crate::spec::{BinaryRow, CoreOptions, DataField, DataFileMeta, 
PartitionComputer};
+use crate::spec::{
+    BinaryRow, CoreOptions, DataField, DataFileMeta, DeletionVectorMeta, 
FileKind, IndexFileMeta,
+    IndexManifest, PartitionComputer,
+};
 use crate::table::commit_message::CommitMessage;
 use crate::table::data_file_writer::DataFileWriter;
+use crate::table::source::data_evolution_anchor_file;
 use crate::table::stats_filter::group_by_overlapping_row_id;
 use crate::table::DataSplitBuilder;
+use crate::table::SnapshotManager;
 use crate::table::Table;
 use crate::Result;
 use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch};
 use arrow_select::concat::concat_batches;
 use arrow_select::interleave::interleave;
+use bytes::Bytes;
 use futures::TryStreamExt;
+use indexmap::IndexMap;
+use roaring::RoaringBitmap;
 use std::collections::{HashMap, HashSet};
+use uuid::Uuid;
+
+const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
+const DELETION_VECTORS_INDEX_VERSION_V1: u8 = 1;
+const INDEX_DIR: &str = "index";
+const MANIFEST_DIR: &str = "manifest";
 
 /// Engine-agnostic writer for partial-column updates via `_ROW_ID`.
 ///
@@ -369,6 +384,371 @@ impl DataEvolutionWriter {
     }
 }
 
+/// Engine-agnostic DELETE writer for data evolution tables.
+///
+/// DELETE is represented by a deletion-vector index file keyed by the normal
+/// anchor file of each data-evolution row-id group. The data files themselves
+/// are not rewritten.
+#[must_use = "writer must be used to call prepare_commit()"]
+pub struct DataEvolutionDeleteWriter {
+    table: Table,
+    row_ids: Vec<i64>,
+}
+
+impl DataEvolutionDeleteWriter {
+    pub fn new(table: &Table) -> Result<Self> {
+        let schema = table.schema();
+        let core_options = CoreOptions::new(schema.options());
+
+        if !core_options.data_evolution_enabled() {
+            return Err(crate::Error::Unsupported {
+                message:
+                    "DELETE is only supported for tables with 
'data-evolution.enabled' = 'true'"
+                        .to_string(),
+            });
+        }
+        if !core_options.row_tracking_enabled() {
+            return Err(crate::Error::Unsupported {
+                message: "DELETE requires 'row-tracking.enabled' = 
'true'".to_string(),
+            });
+        }
+        if !core_options.deletion_vectors_enabled() {
+            return Err(crate::Error::Unsupported {
+                message:
+                    "DELETE on data evolution tables requires 
'deletion-vectors.enabled' = 'true'"
+                        .to_string(),
+            });
+        }
+        if !schema.trimmed_primary_keys().is_empty() {
+            return Err(crate::Error::Unsupported {
+                message: "DELETE on data evolution tables does not support 
primary keys"
+                    .to_string(),
+            });
+        }
+
+        Ok(Self {
+            table: table.clone(),
+            row_ids: Vec::new(),
+        })
+    }
+
+    pub fn add_row_ids<I>(&mut self, row_ids: I) -> Result<()>
+    where
+        I: IntoIterator<Item = i64>,
+    {
+        self.row_ids.extend(row_ids);
+        Ok(())
+    }
+
+    pub fn add_matched_batch(&mut self, batch: RecordBatch) -> Result<()> {
+        if batch.num_rows() == 0 {
+            return Ok(());
+        }
+
+        let row_id_col = row_id_column(&batch)?;
+        validate_row_id_not_null(row_id_col)?;
+        self.row_ids
+            .extend((0..row_id_col.len()).map(|row_idx| 
row_id_col.value(row_idx)));
+        Ok(())
+    }
+
+    #[must_use = "commit messages must be passed to TableCommit"]
+    pub async fn prepare_commit(mut self) -> Result<Vec<CommitMessage>> {
+        dedup_i64_in_place(&mut self.row_ids);
+        if self.row_ids.is_empty() {
+            return Ok(Vec::new());
+        }
+
+        let scan = self
+            .table
+            .new_read_builder()
+            .new_scan()
+            .with_scan_all_files();
+        let plan = scan.plan().await?;
+        let mut file_index = Vec::new();
+
+        for split in plan.splits() {
+            let partition = split.partition().to_serialized_bytes();
+            let bucket = split.bucket();
+            let snapshot_id = split.snapshot_id();
+            let files = split
+                .data_files()
+                .iter()
+                .filter(|file| file.first_row_id.is_some())
+                .cloned()
+                .collect::<Vec<_>>();
+
+            for group in group_by_overlapping_row_id(files) {
+                let anchor = data_evolution_anchor_file(&group)?;
+                let first_row_id =
+                    anchor
+                        .first_row_id
+                        .ok_or_else(|| crate::Error::DataInvalid {
+                            message: format!(
+                                "Data-evolution anchor file '{}' is missing 
first_row_id",
+                                anchor.file_name
+                            ),
+                            source: None,
+                        })?;
+                let last_row_id = first_row_id + anchor.row_count - 1;
+                file_index.push(DeleteFileRowRange {
+                    first_row_id,
+                    last_row_id,
+                    partition: partition.clone(),
+                    bucket,
+                    snapshot_id,
+                    anchor_file_name: anchor.file_name.clone(),
+                });
+            }
+        }
+        file_index.sort_by_key(|range| range.first_row_id);
+
+        if file_index.is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: "No files with row tracking found in target 
table".to_string(),
+                source: None,
+            });
+        }
+
+        let mut deletes_by_bucket: HashMap<(Vec<u8>, i32), BucketDeletePlan> = 
HashMap::new();
+        for row_id in &self.row_ids {
+            let (file_pos, file_range) =
+                find_delete_owning_file(&file_index, *row_id).ok_or_else(|| {
+                    crate::Error::DataInvalid {
+                        message: format!("No file found for _ROW_ID {row_id}"),
+                        source: None,
+                    }
+                })?;
+            let local_position = u32::try_from(row_id - 
file_range.first_row_id).map_err(|_| {
+                crate::Error::DataInvalid {
+                    message: format!(
+                        "_ROW_ID {row_id} is too large to encode in a deletion 
vector"
+                    ),
+                    source: None,
+                }
+            })?;
+
+            let key = (file_range.partition.clone(), file_range.bucket);
+            let entry = deletes_by_bucket
+                .entry(key)
+                .or_insert_with(|| BucketDeletePlan {
+                    check_from_snapshot: file_range.snapshot_id,
+                    deletes_by_anchor: HashMap::new(),
+                });
+            entry.check_from_snapshot = 
entry.check_from_snapshot.min(file_range.snapshot_id);
+            entry
+                .deletes_by_anchor
+                .entry(file_index[file_pos].anchor_file_name.clone())
+                .or_default()
+                .insert(local_position);
+        }
+
+        let mut messages = Vec::new();
+        for ((partition, bucket), delete_plan) in deletes_by_bucket {
+            if let Some(message) = self
+                .prepare_bucket_delete_message(partition, bucket, delete_plan)
+                .await?
+            {
+                messages.push(message);
+            }
+        }
+
+        Ok(messages)
+    }
+
+    async fn prepare_bucket_delete_message(
+        &self,
+        partition: Vec<u8>,
+        bucket: i32,
+        delete_plan: BucketDeletePlan,
+    ) -> Result<Option<CommitMessage>> {
+        let (mut bitmaps, deleted_index_files) = self
+            .read_existing_bucket_deletion_vectors(
+                &partition,
+                bucket,
+                delete_plan.check_from_snapshot,
+            )
+            .await?;
+        let mut changed = false;
+
+        for (anchor_file_name, positions) in delete_plan.deletes_by_anchor {
+            let bitmap = bitmaps.entry(anchor_file_name).or_default();
+            for position in positions {
+                changed |= bitmap.insert(position);
+            }
+        }
+
+        if !changed {
+            return Ok(None);
+        }
+
+        let new_index_file = 
self.write_deletion_vector_index_file(bitmaps).await?;
+        let mut message = CommitMessage::new(partition, bucket, vec![]);
+        message.check_from_snapshot = Some(delete_plan.check_from_snapshot);
+        message.new_index_files = vec![new_index_file];
+        message.deleted_index_files = deleted_index_files;
+        Ok(Some(message))
+    }
+
+    async fn read_existing_bucket_deletion_vectors(
+        &self,
+        partition: &[u8],
+        bucket: i32,
+        snapshot_id: i64,
+    ) -> Result<(IndexMap<String, RoaringBitmap>, Vec<IndexFileMeta>)> {
+        let snapshot_manager = SnapshotManager::new(
+            self.table.file_io().clone(),
+            self.table.location().to_string(),
+        );
+        let snapshot = snapshot_manager.get_snapshot(snapshot_id).await?;
+        let Some(index_manifest_name) = snapshot.index_manifest() else {
+            return Ok((IndexMap::new(), Vec::new()));
+        };
+
+        let manifest_path = format!(
+            "{}/{MANIFEST_DIR}/{}",
+            self.table.location().trim_end_matches('/'),
+            index_manifest_name
+        );
+        let index_entries = IndexManifest::read(self.table.file_io(), 
&manifest_path).await?;
+        let mut bitmaps = IndexMap::new();
+        let mut deleted_index_files = Vec::new();
+
+        for entry in index_entries {
+            if entry.kind != FileKind::Add
+                || entry.bucket != bucket
+                || entry.partition != partition
+                || entry.index_file.index_type != DELETION_VECTORS_INDEX_TYPE
+            {
+                continue;
+            }
+            deleted_index_files.push(entry.index_file.clone());
+            let Some(ranges) = 
entry.index_file.deletion_vectors_ranges.as_ref() else {
+                continue;
+            };
+            let index_path = format!(
+                "{}/{INDEX_DIR}/{}",
+                self.table.location().trim_end_matches('/'),
+                entry.index_file.file_name
+            );
+            for (data_file_name, meta) in ranges {
+                let deletion_file = crate::DeletionFile::new(
+                    index_path.clone(),
+                    meta.offset as i64,
+                    meta.length as i64,
+                    meta.cardinality,
+                );
+                let bitmap = DeletionVectorFactory::read(self.table.file_io(), 
&deletion_file)
+                    .await?
+                    .to_bitmap();
+                bitmaps.insert(data_file_name.clone(), bitmap);
+            }
+        }
+
+        Ok((bitmaps, deleted_index_files))
+    }
+
+    async fn write_deletion_vector_index_file(
+        &self,
+        mut bitmaps: IndexMap<String, RoaringBitmap>,
+    ) -> Result<IndexFileMeta> {
+        bitmaps.sort_keys();
+
+        let file_name = format!("index-{}-1", Uuid::new_v4());
+        let table_path = self.table.location().trim_end_matches('/');
+        let index_dir = format!("{table_path}/{INDEX_DIR}");
+        self.table.file_io().mkdirs(&index_dir).await?;
+        let path = format!("{index_dir}/{file_name}");
+
+        let mut bytes = vec![DELETION_VECTORS_INDEX_VERSION_V1];
+        let mut ranges = IndexMap::new();
+        for (data_file_name, bitmap) in bitmaps {
+            if bitmap.is_empty() {
+                continue;
+            }
+            let offset = i32::try_from(bytes.len()).map_err(|_| 
crate::Error::DataInvalid {
+                message: "Deletion-vector index file is too large".to_string(),
+                source: None,
+            })?;
+            let deletion_vector = DeletionVector::from_bitmap(bitmap);
+            let serialized = deletion_vector.serialize_to_bytes()?;
+            let length = i32::from_be_bytes(
+                serialized[0..4]
+                    .try_into()
+                    .expect("serialized deletion vector has length prefix"),
+            );
+            ranges.insert(
+                data_file_name,
+                DeletionVectorMeta {
+                    offset,
+                    length,
+                    cardinality: Some(deletion_vector.cardinality() as i64),
+                },
+            );
+            bytes.extend_from_slice(&serialized);
+        }
+
+        let file_size = i32::try_from(bytes.len()).map_err(|_| 
crate::Error::DataInvalid {
+            message: "Deletion-vector index file is too large".to_string(),
+            source: None,
+        })?;
+        let row_count = i32::try_from(ranges.len()).map_err(|_| 
crate::Error::DataInvalid {
+            message: "Deletion-vector index file has too many 
entries".to_string(),
+            source: None,
+        })?;
+        self.table
+            .file_io()
+            .new_output(&path)?
+            .write(Bytes::from(bytes))
+            .await?;
+
+        Ok(IndexFileMeta {
+            index_type: DELETION_VECTORS_INDEX_TYPE.to_string(),
+            file_name,
+            file_size,
+            row_count,
+            deletion_vectors_ranges: Some(ranges),
+            global_index_meta: None,
+        })
+    }
+}
+
+struct DeleteFileRowRange {
+    first_row_id: i64,
+    last_row_id: i64,
+    partition: Vec<u8>,
+    bucket: i32,
+    snapshot_id: i64,
+    anchor_file_name: String,
+}
+
+struct BucketDeletePlan {
+    check_from_snapshot: i64,
+    deletes_by_anchor: HashMap<String, HashSet<u32>>,
+}
+
+fn find_delete_owning_file(
+    file_index: &[DeleteFileRowRange],
+    row_id: i64,
+) -> Option<(usize, &DeleteFileRowRange)> {
+    let pos = file_index.partition_point(|f| f.first_row_id <= row_id);
+    if pos == 0 {
+        return None;
+    }
+    let idx = pos - 1;
+    let candidate = &file_index[idx];
+    if row_id <= candidate.last_row_id {
+        Some((idx, candidate))
+    } else {
+        None
+    }
+}
+
+fn dedup_i64_in_place(values: &mut Vec<i64>) {
+    let mut seen = HashSet::new();
+    values.retain(|value| seen.insert(*value));
+}
+
 /// Binary search for the file that owns a given row_id.
 fn find_owning_file(file_index: &[FileRowRange], row_id: i64) -> 
Option<(usize, &FileRowRange)> {
     let pos = file_index.partition_point(|f| f.first_row_id <= row_id);
diff --git a/crates/paimon/src/table/full_text_search_builder.rs 
b/crates/paimon/src/table/full_text_search_builder.rs
index 04ffe21..b24e82e 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -24,7 +24,10 @@ use crate::spec::{
     CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta, 
IndexManifest,
     IndexManifestEntry, ROW_ID_FIELD_NAME,
 };
-use 
crate::table::global_index_scanner::unindexed_ranges_for_global_index_entries;
+use crate::table::global_index_scanner::{
+    deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
+    unindexed_ranges_for_global_index_entries, RowRangeIndex,
+};
 use crate::table::snapshot_manager::SnapshotManager;
 use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
 use crate::tantivy::full_text_search::{FullTextSearch, SearchResult};
@@ -165,7 +168,8 @@ async fn evaluate_full_text_search(
     search: &FullTextSearch,
 ) -> crate::Result<Vec<RowRange>> {
     let table_path = evaluation.table_path.trim_end_matches('/');
-    let search_mode = 
CoreOptions::new(evaluation.table_options).global_index_search_mode()?;
+    let core_options = CoreOptions::new(evaluation.table_options);
+    let search_mode = core_options.global_index_search_mode()?;
 
     let field_id = match find_field_id_by_name(evaluation.schema_fields, 
&search.field_name) {
         Some(id) => id,
@@ -189,6 +193,19 @@ async fn evaluate_full_text_search(
         return Ok(Vec::new());
     }
 
+    let deleted_row_index = if core_options.data_evolution_enabled() {
+        match evaluation.table {
+            Some(table) => {
+                let ranges =
+                    deleted_row_ranges_for_data_evolution_dvs(table, 
index_entries).await?;
+                (!ranges.is_empty()).then(|| RowRangeIndex::create(ranges))
+            }
+            None => None,
+        }
+    } else {
+        None
+    };
+
     let mut merged = SearchResult::empty();
     if !fulltext_entries.is_empty() {
         let futures: Vec<_> = fulltext_entries
@@ -198,8 +215,14 @@ async fn evaluate_full_text_search(
                 let path = format!("{table_path}/{INDEX_DIR}/{}", 
entry.index_file.file_name);
                 let file_name = entry.index_file.file_name.clone();
                 let query_text = search.query_text.clone();
-                let limit = search.limit;
                 let row_range_start = global_meta.row_range_start;
+                let row_range_end = global_meta.row_range_end;
+                let limit = search_limit_with_deleted_rows(
+                    search.limit,
+                    row_range_start,
+                    row_range_end,
+                    deleted_row_index.as_ref(),
+                );
                 let input = evaluation.file_io.new_input(&path);
                 async move {
                     let input = input?;
@@ -253,7 +276,10 @@ async fn evaluate_full_text_search(
         }
     }
 
-    Ok(merged.top_k(search.limit).to_row_ranges())
+    Ok(merged
+        .without_deleted_row_ranges(deleted_row_index.as_ref())?
+        .top_k(search.limit)
+        .to_row_ranges())
 }
 
 fn is_tantivy_fulltext_index_file(index_file: &IndexFileMeta) -> bool {
diff --git a/crates/paimon/src/table/global_index_scanner.rs 
b/crates/paimon/src/table/global_index_scanner.rs
index 8ed18a9..f604d7c 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -22,12 +22,13 @@
 
 use crate::btree::query::{extract_between, IndexQuery};
 use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexMeta, 
BTreeIndexReader};
+use crate::deletion_vector::DeletionVectorFactory;
 use crate::io::FileIO;
 use crate::spec::{
     DataField, DataType, Datum, FileKind, GlobalIndexSearchMode, 
IndexFileMeta, IndexManifestEntry,
     Predicate, PredicateOperator,
 };
-use crate::table::RowRange;
+use crate::table::{DeletionFile, RowRange, Table};
 use crate::Result;
 use roaring::RoaringTreemap;
 use std::cmp::Ordering;
@@ -42,6 +43,7 @@ type EvaluateFuture<'a> =
 type PredicateTuple<'a> = (PredicateOperator, &'a [Datum], &'a DataType);
 
 const BTREE_INDEX_TYPE: &str = "btree";
+const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
 const INDEX_DIR: &str = "index";
 
 /// Evaluates global index predicates and returns matching row ranges.
@@ -663,6 +665,103 @@ pub(crate) fn unindexed_ranges_for_global_index_entries(
     )
 }
 
+/// Resolve live deletion-vector index entries into global row-id ranges.
+///
+/// Data-evolution DV entries are keyed by the normal anchor data file. The DV
+/// bitmap positions are local to that anchor file's `first_row_id`, so this
+/// helper joins index metadata with live data-file metadata before converting
+/// deleted positions to global row IDs.
+pub(crate) async fn deleted_row_ranges_for_data_evolution_dvs(
+    table: &Table,
+    index_entries: &[IndexManifestEntry],
+) -> Result<Vec<RowRange>> {
+    if !index_entries.iter().any(|entry| {
+        entry.kind == FileKind::Add && entry.index_file.index_type == 
DELETION_VECTORS_INDEX_TYPE
+    }) {
+        return Ok(Vec::new());
+    }
+
+    let plan = table
+        .new_read_builder()
+        .new_scan()
+        .with_scan_all_files()
+        .plan()
+        .await?;
+
+    let mut first_row_ids: HashMap<(Vec<u8>, i32, String), i64> = 
HashMap::new();
+    for split in plan.splits() {
+        let partition = split.partition().to_serialized_bytes();
+        let bucket = split.bucket();
+        for file in split.data_files() {
+            if let Some(first_row_id) = file.first_row_id {
+                first_row_ids.insert(
+                    (partition.clone(), bucket, file.file_name.clone()),
+                    first_row_id,
+                );
+            }
+        }
+    }
+
+    let mut ranges = Vec::new();
+    let table_path = table.location().trim_end_matches('/');
+    for entry in index_entries {
+        if entry.kind != FileKind::Add || entry.index_file.index_type != 
DELETION_VECTORS_INDEX_TYPE
+        {
+            continue;
+        }
+        let Some(dv_ranges) = 
entry.index_file.deletion_vectors_ranges.as_ref() else {
+            continue;
+        };
+        let index_path = format!("{table_path}/{INDEX_DIR}/{}", 
entry.index_file.file_name);
+        for (data_file_name, meta) in dv_ranges {
+            let key = (
+                entry.partition.clone(),
+                entry.bucket,
+                data_file_name.clone(),
+            );
+            let first_row_id = first_row_ids.get(&key).copied().ok_or_else(|| {
+                crate::Error::DataInvalid {
+                    message: format!(
+                        "Deletion vector references data file '{}' but no live 
row-tracked file was found",
+                        data_file_name
+                    ),
+                    source: None,
+                }
+            })?;
+            let deletion_file = DeletionFile::new(
+                index_path.clone(),
+                meta.offset as i64,
+                meta.length as i64,
+                meta.cardinality,
+            );
+            let deletion_vector =
+                DeletionVectorFactory::read(table.file_io(), 
&deletion_file).await?;
+            for deleted in deletion_vector.iter() {
+                let deleted = i64::try_from(deleted).map_err(|_| 
crate::Error::DataInvalid {
+                    message: format!(
+                        "Deleted position {deleted} for data file '{}' exceeds 
i64::MAX",
+                        data_file_name
+                    ),
+                    source: None,
+                })?;
+                let row_id =
+                    first_row_id
+                        .checked_add(deleted)
+                        .ok_or_else(|| crate::Error::DataInvalid {
+                            message: format!(
+                                "Deleted row id overflows i64 for data file 
'{}'",
+                                data_file_name
+                            ),
+                            source: None,
+                        })?;
+                ranges.push(RowRange::new(row_id, row_id));
+            }
+        }
+    }
+
+    Ok(super::merge_row_ranges(ranges))
+}
+
 /// Index for row ranges. Stores sorted, non-overlapping ranges and supports
 /// efficient intersection queries via binary search.
 ///
@@ -695,12 +794,24 @@ impl RowRangeIndex {
     }
 
     /// Returns true if the index has any range that intersects `[start, end]`.
-    #[cfg(test)]
     pub fn intersects(&self, start: i64, end: i64) -> bool {
         let candidate = lower_bound(&self.ends, start);
         candidate < self.starts.len() && self.starts[candidate] <= end
     }
 
+    /// Counts rows in this index that intersect `[start, end]`.
+    pub fn intersection_row_count(&self, start: i64, end: i64) -> usize {
+        if start > end {
+            return 0;
+        }
+        self.intersected_ranges(start, end)
+            .into_iter()
+            .fold(0usize, |total, range| {
+                let len = 
range.to().saturating_sub(range.from()).saturating_add(1);
+                
total.saturating_add(usize::try_from(len).unwrap_or(usize::MAX))
+            })
+    }
+
     /// Returns the sub-ranges of this index that intersect `[start, end]`,
     /// clipped to the intersection boundaries.
     pub fn intersected_ranges(&self, start: i64, end: i64) -> Vec<RowRange> {
@@ -739,6 +850,27 @@ impl RowRangeIndex {
     }
 }
 
+pub(crate) fn search_limit_with_deleted_rows(
+    limit: usize,
+    row_range_start: i64,
+    row_range_end: i64,
+    deleted_rows: Option<&RowRangeIndex>,
+) -> usize {
+    let Some(range_len) = row_range_end
+        .checked_sub(row_range_start)
+        .and_then(|len| len.checked_add(1))
+        .and_then(|len| usize::try_from(len).ok())
+    else {
+        return limit;
+    };
+
+    let deleted_count = deleted_rows
+        .map(|index| index.intersection_row_count(row_range_start, 
row_range_end))
+        .unwrap_or(0)
+        .min(range_len);
+    limit.saturating_add(deleted_count).min(range_len)
+}
+
 /// Binary search: find the first index where `sorted[index] >= target`.
 fn lower_bound(sorted: &[i64], target: i64) -> usize {
     let mut left = 0;
@@ -904,6 +1036,28 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_row_range_index_intersection_row_count() {
+        let idx = RowRangeIndex::create(vec![
+            RowRange::new(10, 20),
+            RowRange::new(30, 40),
+            RowRange::new(50, 60),
+        ]);
+
+        assert_eq!(idx.intersection_row_count(15, 55), 23);
+        assert_eq!(idx.intersection_row_count(21, 29), 0);
+        assert_eq!(idx.intersection_row_count(55, 15), 0);
+    }
+
+    #[test]
+    fn test_search_limit_with_deleted_rows_expands_and_caps() {
+        let idx = RowRangeIndex::create(vec![RowRange::new(2, 4), 
RowRange::new(8, 10)]);
+
+        assert_eq!(search_limit_with_deleted_rows(5, 0, 19, Some(&idx)), 11);
+        assert_eq!(search_limit_with_deleted_rows(18, 0, 19, Some(&idx)), 20);
+        assert_eq!(search_limit_with_deleted_rows(5, 0, 19, None), 5);
+    }
+
     #[test]
     fn test_row_range_index_empty() {
         let idx = RowRangeIndex::create(Vec::new());
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 1e1a18d..19e9f00 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -70,7 +70,7 @@ use arrow_array::RecordBatch;
 pub use branch_manager::BranchManager;
 pub use commit_message::CommitMessage;
 pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
-pub use data_evolution_writer::DataEvolutionWriter;
+pub use data_evolution_writer::{DataEvolutionDeleteWriter, 
DataEvolutionWriter};
 #[cfg(feature = "fulltext")]
 pub use full_text_search_builder::FullTextSearchBuilder;
 use futures::stream::BoxStream;
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 229afa0..7c85d6d 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -22,6 +22,26 @@
 use crate::spec::{BinaryRow, DataFileMeta};
 use crate::table::stats_filter::group_by_overlapping_row_id;
 use serde::{Deserialize, Serialize};
+
+fn is_vector_store_file_name(file_name: &str) -> bool {
+    file_name.to_ascii_lowercase().contains(".vector.")
+}
+
+pub(crate) fn is_data_evolution_normal_file(file: &DataFileMeta) -> bool {
+    !crate::table::blob_file_writer::is_blob_file_name(&file.file_name)
+        && !is_vector_store_file_name(&file.file_name)
+}
+
+pub(crate) fn data_evolution_anchor_file(files: &[DataFileMeta]) -> 
crate::Result<&DataFileMeta> {
+    files
+        .iter()
+        .filter(|file| is_data_evolution_normal_file(file))
+        .min_by_key(|file| (file.max_sequence_number, file.file_name.as_str()))
+        .ok_or_else(|| crate::Error::DataInvalid {
+            message: "Data-evolution deletion vectors require a normal anchor 
file in each row range group.".to_string(),
+            source: None,
+        })
+}
 // ======================= RowRange ===============================
 
 /// An inclusive row ID range `[from, to]` for filtering reads in data 
evolution mode.
@@ -560,10 +580,16 @@ impl DataSplit {
 
         // Merge overlapping row ID ranges and compute max row_count per group
         let groups = group_by_overlapping_row_id(self.data_files.to_vec());
-        let sum: i64 = groups
+        let mut sum: i64 = groups
             .iter()
             .map(|group| group.iter().map(|f| f.row_count).max().unwrap_or(0))
             .sum();
+        if let Some(deletion_files) = &self.data_deletion_files {
+            for deletion_file in deletion_files.iter().flatten() {
+                sum -= deletion_file.cardinality()?;
+            }
+        }
+
         Some(sum)
     }
 
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 055dcd5..872bea1 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -43,6 +43,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
 const BATCH_COMMIT_IDENTIFIER: i64 = i64::MAX;
 /// Java RollingFileWriter.CHECK_ROLLING_RECORD_CNT.
 const CHECK_ROLLING_RECORD_COUNT: usize = 1000;
+const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
 
 type PartitionBucketKey = (Vec<u8>, i32);
 type RowIdRange = (i64, i64);
@@ -1098,6 +1099,12 @@ impl TableCommit {
                 };
                 let detect_conflicts = has_delete || 
check_from_snapshot.is_some();
                 let base_data_files = if detect_conflicts {
+                    self.check_deletion_vector_index_only_conflict(
+                        latest_snapshot.as_ref(),
+                        entries,
+                        new_index_entries,
+                        *check_from_snapshot,
+                    )?;
                     self.detect_commit_conflicts(
                         latest_snapshot,
                         retry_state,
@@ -1709,6 +1716,43 @@ impl TableCommit {
             .await
     }
 
+    fn check_deletion_vector_index_only_conflict(
+        &self,
+        latest_snapshot: Option<&Snapshot>,
+        data_entries: &[ManifestEntry],
+        index_entries: &[IndexManifestEntry],
+        check_from_snapshot: Option<i64>,
+    ) -> Result<()> {
+        if !self.data_evolution_enabled || !data_entries.is_empty() {
+            return Ok(());
+        }
+        let Some(check_from_snapshot) = check_from_snapshot else {
+            return Ok(());
+        };
+        let has_deletion_vector_index_change = index_entries
+            .iter()
+            .any(|entry| entry.index_file.index_type == 
DELETION_VECTORS_INDEX_TYPE);
+        if !has_deletion_vector_index_change {
+            return Ok(());
+        }
+        let Some(latest_snapshot) = latest_snapshot else {
+            return Ok(());
+        };
+        if latest_snapshot.id() <= check_from_snapshot {
+            return Ok(());
+        }
+
+        Err(crate::Error::DataInvalid {
+            message: format!(
+                "Row ID conflict: deletion-vector DELETE was prepared from 
snapshot \
+                 {check_from_snapshot}, but latest snapshot is {}. Retry with 
the latest \
+                 deletion vectors.",
+                latest_snapshot.id()
+            ),
+            source: None,
+        })
+    }
+
     fn check_delete_entries_against_base(
         &self,
         base_entries: &[ManifestEntry],
@@ -2705,7 +2749,8 @@ mod tests {
     use crate::io::FileIOBuilder;
     use crate::spec::stats::BinaryTableStats;
     use crate::spec::{
-        BinaryRowBuilder, DataFileMeta, GlobalIndexMeta, IndexFileMeta, 
ManifestList, TableSchema,
+        BinaryRowBuilder, DataFileMeta, DeletionVectorMeta, GlobalIndexMeta, 
IndexFileMeta,
+        ManifestList, TableSchema,
     };
     use chrono::{DateTime, Utc};
 
@@ -2834,6 +2879,24 @@ mod tests {
         file
     }
 
+    fn test_deletion_vector_index_file(name: &str, data_file_name: &str) -> 
IndexFileMeta {
+        IndexFileMeta {
+            index_type: DELETION_VECTORS_INDEX_TYPE.to_string(),
+            file_name: name.to_string(),
+            file_size: 128,
+            row_count: 1,
+            deletion_vectors_ranges: Some(indexmap::IndexMap::from([(
+                data_file_name.to_string(),
+                DeletionVectorMeta {
+                    offset: 1,
+                    length: 16,
+                    cardinality: Some(1),
+                },
+            )])),
+            global_index_meta: None,
+        }
+    }
+
     fn setup_commit(file_io: &FileIO, table_path: &str) -> TableCommit {
         let table = test_table(file_io, table_path);
         TableCommit::new(table, "test-user".to_string())
@@ -3143,6 +3206,52 @@ mod tests {
         assert!(snapshot.index_manifest().is_none());
     }
 
+    #[tokio::test]
+    async fn test_deletion_vector_index_only_commit_rejects_stale_snapshot() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_dv_index_only_stale_snapshot";
+        setup_dirs(&file_io, table_path).await;
+
+        let commit = setup_data_evolution_commit(&file_io, table_path);
+        let partition = EMPTY_SERIALIZED_ROW.clone();
+        let mut data_file = test_data_file("data-0.parquet", 10);
+        data_file.file_source = Some(0);
+        commit
+            .commit(vec![CommitMessage::new(
+                partition.clone(),
+                0,
+                vec![data_file],
+            )])
+            .await
+            .unwrap();
+
+        let mut first_delete = CommitMessage::new(partition.clone(), 0, 
vec![]);
+        first_delete.check_from_snapshot = Some(1);
+        first_delete.new_index_files = vec![test_deletion_vector_index_file(
+            "dv-0.index",
+            "data-0.parquet",
+        )];
+        commit.commit(vec![first_delete]).await.unwrap();
+
+        let mut stale_delete = CommitMessage::new(partition, 0, vec![]);
+        stale_delete.check_from_snapshot = Some(1);
+        stale_delete.new_index_files = vec![test_deletion_vector_index_file(
+            "dv-1.index",
+            "data-0.parquet",
+        )];
+        let result = commit.commit(vec![stale_delete]).await;
+
+        assert!(result.is_err());
+        let err_msg = result.unwrap_err().to_string();
+        assert!(
+            err_msg.contains("Row ID conflict"),
+            "expected row-id conflict for stale DV commit, got: {err_msg}"
+        );
+
+        let snapshot = latest_snapshot(&file_io, table_path).await.unwrap();
+        assert_eq!(snapshot.id(), 2);
+    }
+
     #[tokio::test]
     async fn test_global_index_overlap_rejected_on_commit() {
         let file_io = test_file_io();
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index f7df42e..2ca9993 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -22,7 +22,10 @@ use crate::spec::{
     CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta, 
IndexManifest,
     IndexManifestEntry, ROW_ID_FIELD_NAME,
 };
-use 
crate::table::global_index_scanner::unindexed_ranges_for_global_index_entries;
+use crate::table::global_index_scanner::{
+    deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
+    unindexed_ranges_for_global_index_entries, RowRangeIndex,
+};
 use crate::table::snapshot_manager::SnapshotManager;
 use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
 use crate::vector_search::{GlobalIndexIOMeta, SearchResult, VectorSearch};
@@ -264,7 +267,8 @@ async fn evaluate_batch_vector_search(
     }
 
     let table_path = evaluation.table_path.trim_end_matches('/');
-    let search_mode = 
CoreOptions::new(evaluation.table_options).global_index_search_mode()?;
+    let core_options = CoreOptions::new(evaluation.table_options);
+    let search_mode = core_options.global_index_search_mode()?;
     let field_name = &vector_searches[0].field_name;
     if vector_searches
         .iter()
@@ -298,6 +302,19 @@ async fn evaluate_batch_vector_search(
         return Ok(vec![SearchResult::empty(); vector_searches.len()]);
     }
 
+    let deleted_row_index = if core_options.data_evolution_enabled() {
+        match evaluation.table {
+            Some(table) => {
+                let ranges =
+                    deleted_row_ranges_for_data_evolution_dvs(table, 
index_entries).await?;
+                (!ranges.is_empty()).then(|| RowRangeIndex::create(ranges))
+            }
+            None => None,
+        }
+    } else {
+        None
+    };
+
     let mut merged = vec![SearchResult::empty(); vector_searches.len()];
     if !vector_entries.is_empty() {
         let futures: Vec<_> = vector_entries
@@ -311,7 +328,22 @@ async fn evaluate_batch_vector_search(
                 let file_size = entry.index_file.file_size as u64;
                 let index_meta_bytes = 
global_meta.index_meta.clone().unwrap_or_default();
                 let row_range_start = global_meta.row_range_start;
-                let vector_searches = vector_searches.to_vec();
+                let row_range_end = global_meta.row_range_end;
+                let max_limit = vector_searches
+                    .iter()
+                    .map(|vector_search| vector_search.limit)
+                    .max()
+                    .unwrap_or(0);
+                let index_limit = search_limit_with_deleted_rows(
+                    max_limit,
+                    row_range_start,
+                    row_range_end,
+                    deleted_row_index.as_ref(),
+                );
+                let mut vector_searches = vector_searches.to_vec();
+                for vector_search in &mut vector_searches {
+                    vector_search.limit = index_limit;
+                }
                 let options = evaluation.table_options.clone();
                 let input = evaluation.file_io.new_input(&path);
                 async move {
@@ -417,11 +449,15 @@ async fn evaluate_batch_vector_search(
         }
     }
 
-    Ok(merged
+    merged
         .into_iter()
         .zip(vector_searches)
-        .map(|(result, vector_search)| result.top_k(vector_search.limit))
-        .collect())
+        .map(|(result, vector_search)| {
+            Ok(result
+                .without_deleted_row_ranges(deleted_row_index.as_ref())?
+                .top_k(vector_search.limit))
+        })
+        .collect()
 }
 
 fn is_vector_global_index_file(index_file: &IndexFileMeta) -> bool {
diff --git a/crates/paimon/src/table/write_builder.rs 
b/crates/paimon/src/table/write_builder.rs
index 34ae27d..703fd2c 100644
--- a/crates/paimon/src/table/write_builder.rs
+++ b/crates/paimon/src/table/write_builder.rs
@@ -19,7 +19,7 @@
 //!
 //! Reference: [pypaimon 
WriteBuilder](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/write/write_builder.py)
 
-use crate::table::{Table, TableCommit, TableUpdate, TableWrite};
+use crate::table::{DataEvolutionDeleteWriter, Table, TableCommit, TableUpdate, 
TableWrite};
 use uuid::Uuid;
 
 /// Builder for creating table writers and committers.
@@ -109,6 +109,11 @@ impl<'a> WriteBuilder<'a> {
     pub fn new_update(&self, update_columns: Vec<String>) -> 
crate::Result<TableUpdate> {
         TableUpdate::new(self.table, update_columns)
     }
+
+    /// Create a new writer for data-evolution row-id deletes.
+    pub fn new_delete(&self) -> crate::Result<DataEvolutionDeleteWriter> {
+        DataEvolutionDeleteWriter::new(self.table)
+    }
 }
 
 fn validate_commit_user(commit_user: &str) -> crate::Result<()> {
diff --git a/crates/paimon/src/tantivy/full_text_search.rs 
b/crates/paimon/src/tantivy/full_text_search.rs
index 77bcd03..2f57ef8 100644
--- a/crates/paimon/src/tantivy/full_text_search.rs
+++ b/crates/paimon/src/tantivy/full_text_search.rs
@@ -130,6 +130,31 @@ impl SearchResult {
         Self { row_ids, scores }
     }
 
+    pub(crate) fn without_deleted_row_ranges(
+        &self,
+        deleted_rows: 
Option<&crate::table::global_index_scanner::RowRangeIndex>,
+    ) -> crate::Result<Self> {
+        let Some(deleted_rows) = deleted_rows else {
+            return Ok(self.clone());
+        };
+
+        let mut row_ids = Vec::with_capacity(self.row_ids.len());
+        let mut scores = Vec::with_capacity(self.scores.len());
+        for (&row_id, &score) in self.row_ids.iter().zip(&self.scores) {
+            let row_id_i64 = i64::try_from(row_id).map_err(|_| 
crate::Error::DataInvalid {
+                message: format!(
+                    "Full-text search row id {row_id} exceeds i64::MAX and 
cannot be checked against deletion vectors"
+                ),
+                source: None,
+            })?;
+            if !deleted_rows.intersects(row_id_i64, row_id_i64) {
+                row_ids.push(row_id);
+                scores.push(score);
+            }
+        }
+        Ok(Self { row_ids, scores })
+    }
+
     /// Convert to sorted, merged row ranges.
     pub fn to_row_ranges(&self) -> Vec<crate::table::RowRange> {
         if self.row_ids.is_empty() {
@@ -173,4 +198,19 @@ mod tests {
         let result = FullTextSearch::new("".into(), 10, "text".into());
         assert!(result.is_err());
     }
+
+    #[test]
+    fn test_search_result_filters_deleted_row_ranges() {
+        let result = SearchResult::new(vec![1, 2, 3, 4], vec![0.1, 0.9, 0.8, 
0.2]);
+        let deleted = 
crate::table::global_index_scanner::RowRangeIndex::create(vec![
+            crate::table::RowRange::new(2, 3),
+        ]);
+
+        let filtered = result
+            .without_deleted_row_ranges(Some(&deleted))
+            .unwrap()
+            .top_k(10);
+        assert_eq!(filtered.row_ids, vec![1, 4]);
+        assert_eq!(filtered.scores, vec![0.1, 0.2]);
+    }
 }
diff --git a/crates/paimon/src/vector_search.rs 
b/crates/paimon/src/vector_search.rs
index 491b65f..28b6500 100644
--- a/crates/paimon/src/vector_search.rs
+++ b/crates/paimon/src/vector_search.rs
@@ -167,6 +167,31 @@ impl SearchResult {
         Self { row_ids, scores }
     }
 
+    pub(crate) fn without_deleted_row_ranges(
+        &self,
+        deleted_rows: 
Option<&crate::table::global_index_scanner::RowRangeIndex>,
+    ) -> crate::Result<Self> {
+        let Some(deleted_rows) = deleted_rows else {
+            return Ok(self.clone());
+        };
+
+        let mut row_ids = Vec::with_capacity(self.row_ids.len());
+        let mut scores = Vec::with_capacity(self.scores.len());
+        for (&row_id, &score) in self.row_ids.iter().zip(&self.scores) {
+            let row_id_i64 = i64::try_from(row_id).map_err(|_| 
crate::Error::DataInvalid {
+                message: format!(
+                    "Vector search row id {row_id} exceeds i64::MAX and cannot 
be checked against deletion vectors"
+                ),
+                source: None,
+            })?;
+            if !deleted_rows.intersects(row_id_i64, row_id_i64) {
+                row_ids.push(row_id);
+                scores.push(score);
+            }
+        }
+        Ok(Self { row_ids, scores })
+    }
+
     pub fn to_row_ranges(&self) -> crate::Result<Vec<crate::table::RowRange>> {
         if self.row_ids.is_empty() {
             return Ok(Vec::new());
@@ -244,6 +269,21 @@ mod tests {
         assert!(top.row_ids.contains(&4));
     }
 
+    #[test]
+    fn test_search_result_filters_deleted_row_ranges() {
+        let result = SearchResult::new(vec![1, 2, 3, 4], vec![0.1, 0.9, 0.8, 
0.2]);
+        let deleted = 
crate::table::global_index_scanner::RowRangeIndex::create(vec![
+            crate::table::RowRange::new(2, 3),
+        ]);
+
+        let filtered = result
+            .without_deleted_row_ranges(Some(&deleted))
+            .unwrap()
+            .top_k(10);
+        assert_eq!(filtered.row_ids, vec![1, 4]);
+        assert_eq!(filtered.scores, vec![0.1, 0.2]);
+    }
+
     #[test]
     fn test_search_result_offset() {
         let result = SearchResult::new(vec![0, 1], vec![0.5, 0.6]);

Reply via email to