This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 077788e12a [core][python] filter deleted rows from global index search 
(#8459)
077788e12a is described below

commit 077788e12aaafe266eccc4f3e04abda81c1fe69a
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 4 21:51:32 2026 +0800

    [core][python] filter deleted rows from global index search (#8459)
    
    Filter deletion-vector deleted rows out of global-index based vector and
    full-text search results without rebuilding existing index files. The
    search path now derives live row ids from the current snapshot and
    applies them as include-row filters so deleted rows cannot affect topK
    results.
---
 .../globalindex/OffsetGlobalIndexReader.java       |   2 +-
 .../apache/paimon/predicate/FullTextSearch.java    |  24 ++
 .../TestFullTextGlobalIndexReader.java             |   4 +
 .../deletionvectors/Bitmap64DeletionVector.java    |   6 +
 .../deletionvectors/BitmapDeletionVector.java      |  10 +
 .../paimon/deletionvectors/DeletionVector.java     |   4 +
 .../paimon/table/source/AbstractVectorRead.java    |  59 ++--
 .../paimon/table/source/FullTextReadImpl.java      |  74 +++--
 .../table/source/GlobalIndexLiveRowFilter.java     | 113 ++++++++
 .../table/source/DeletionVectorTestUtils.java      | 118 ++++++++
 .../table/source/FullTextSearchBuilderTest.java    |  35 +++
 .../table/source/VectorSearchBuilderTest.java      |  58 ++++
 .../pypaimon/globalindex/full_text_search.py       |  25 ++
 .../globalindex/offset_global_index_reader.py      |   3 +-
 .../tantivy_full_text_global_index_reader.py       |  32 +-
 .../pypaimon/table/source/full_text_read.py        |  57 ++--
 .../table/source/full_text_search_builder.py       |   6 +-
 .../table/source/global_index_live_row_filter.py   |  85 ++++++
 .../pypaimon/table/source/vector_search_read.py    |  55 +++-
 .../pypaimon/tests/vector_search_filter_test.py    | 321 ++++++++++++++++++++-
 .../index/TantivyFullTextGlobalIndexReader.java    |  24 +-
 .../index/TantivyFullTextGlobalIndexTest.java      |  30 ++
 paimon-tantivy/paimon-tantivy-jni/rust/src/lib.rs  |  11 +
 .../org/apache/paimon/tantivy/TantivySearcher.java |   7 +
 .../org/apache/paimon/tantivy/TantivyJniTest.java  |   2 +
 25 files changed, 1075 insertions(+), 90 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
index f740223d5a..646e1f6ca2 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
@@ -143,7 +143,7 @@ public class OffsetGlobalIndexReader implements 
GlobalIndexReader {
     @Override
     public CompletableFuture<Optional<ScoredGlobalIndexResult>> 
visitFullTextSearch(
             FullTextSearch fullTextSearch) {
-        return wrapped.visitFullTextSearch(fullTextSearch)
+        return 
wrapped.visitFullTextSearch(fullTextSearch.offsetRange(this.offset, this.to))
                 .thenApply(opt -> opt.map(r -> r.offset(offset)));
     }
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/predicate/FullTextSearch.java 
b/paimon-common/src/main/java/org/apache/paimon/predicate/FullTextSearch.java
index b9731434c2..1f9b11fe1e 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/predicate/FullTextSearch.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/predicate/FullTextSearch.java
@@ -18,6 +18,10 @@
 
 package org.apache.paimon.predicate;
 
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import javax.annotation.Nullable;
+
 import java.io.Serializable;
 import java.util.List;
 
@@ -29,6 +33,8 @@ public class FullTextSearch implements Serializable {
     private final FullTextQuery query;
     private final int limit;
 
+    @Nullable private RoaringNavigableMap64 includeRowIds;
+
     public FullTextSearch(FullTextQuery query, int limit) {
         if (query == null) {
             throw new IllegalArgumentException("Query cannot be null");
@@ -56,6 +62,24 @@ public class FullTextSearch implements Serializable {
         return query;
     }
 
+    public RoaringNavigableMap64 includeRowIds() {
+        return includeRowIds;
+    }
+
+    public FullTextSearch withIncludeRowIds(RoaringNavigableMap64 
includeRowIds) {
+        this.includeRowIds = includeRowIds;
+        return this;
+    }
+
+    public FullTextSearch offsetRange(long from, long to) {
+        if (includeRowIds != null) {
+            FullTextSearch target = new FullTextSearch(query, limit);
+            
target.withIncludeRowIds(VectorSearchUtils.offsetRowIds(includeRowIds, from, 
to));
+            return target;
+        }
+        return this;
+    }
+
     public String queryJson() {
         return query.toJson();
     }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexReader.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexReader.java
index 1f6240f529..b68bc6a4f0 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexReader.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexReader.java
@@ -82,8 +82,12 @@ public class TestFullTextGlobalIndexReader implements 
GlobalIndexReader {
         // Min-heap: smallest score at head, so we evict the weakest candidate.
         PriorityQueue<ScoredRow> topK =
                 new PriorityQueue<>(effectiveK + 1, 
Comparator.comparingDouble(s -> s.score));
+        RoaringNavigableMap64 includeRowIds = fullTextSearch.includeRowIds();
 
         for (int i = 0; i < count; i++) {
+            if (includeRowIds != null && !includeRowIds.contains(rowIds[i])) {
+                continue;
+            }
             float score = computeScore(documents[i], fullTextSearch.query());
             if (score <= 0) {
                 continue;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/Bitmap64DeletionVector.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/Bitmap64DeletionVector.java
index fdd6955638..ee06d7232b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/Bitmap64DeletionVector.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/Bitmap64DeletionVector.java
@@ -27,6 +27,7 @@ import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.util.Objects;
+import java.util.function.LongConsumer;
 import java.util.zip.CRC32;
 
 /**
@@ -89,6 +90,11 @@ public class Bitmap64DeletionVector implements 
DeletionVector {
         return roaringBitmap.cardinality();
     }
 
+    @Override
+    public void forEachDeletedPosition(LongConsumer consumer) {
+        roaringBitmap.forEach(consumer);
+    }
+
     @Override
     public int serializeTo(DataOutputStream out) throws IOException {
         roaringBitmap.runLengthEncode(); // run-length encode the bitmap 
before serializing
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/BitmapDeletionVector.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/BitmapDeletionVector.java
index 40681a7772..191f314283 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/BitmapDeletionVector.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/BitmapDeletionVector.java
@@ -24,7 +24,9 @@ import java.io.ByteArrayOutputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
 import java.nio.ByteBuffer;
+import java.util.Iterator;
 import java.util.Objects;
+import java.util.function.LongConsumer;
 import java.util.zip.CRC32;
 
 /**
@@ -83,6 +85,14 @@ public class BitmapDeletionVector implements DeletionVector {
         return roaringBitmap.getCardinality();
     }
 
+    @Override
+    public void forEachDeletedPosition(LongConsumer consumer) {
+        Iterator<Integer> iterator = roaringBitmap.iterator();
+        while (iterator.hasNext()) {
+            consumer.accept(iterator.next());
+        }
+    }
+
     @Override
     public int serializeTo(DataOutputStream out) {
         try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/DeletionVector.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/DeletionVector.java
index e06dc767a9..3526ae2a1d 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/DeletionVector.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/DeletionVector.java
@@ -34,6 +34,7 @@ import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.List;
 import java.util.Optional;
+import java.util.function.LongConsumer;
 
 import static 
org.apache.paimon.deletionvectors.Bitmap64DeletionVector.toLittleEndianInt;
 
@@ -82,6 +83,9 @@ public interface DeletionVector extends DeletionVectorJudger {
     /** @return the number of distinct integers added to the DeletionVector. */
     long getCardinality();
 
+    /** Iterates over all deleted positions in this deletion vector. */
+    void forEachDeletedPosition(LongConsumer consumer);
+
     /** Serializes the deletion vector. */
     int serializeTo(DataOutputStream out) throws IOException;
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractVectorRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractVectorRead.java
index a1996f9203..0e3adf3b6a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractVectorRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractVectorRead.java
@@ -120,8 +120,38 @@ public abstract class AbstractVectorRead implements 
Serializable {
     }
 
     protected List<RoaringNavigableMap64> 
preFilters(List<IndexVectorSearchSplit> splits) {
+        RoaringNavigableMap64 liveRows = 
GlobalIndexLiveRowFilter.liveRows(table, partitionFilter);
+        RoaringNavigableMap64 matchedRows = scalarMatchedRows(splits);
+
+        List<RoaringNavigableMap64> includeRowIds = new 
ArrayList<>(splits.size());
+        boolean hasFilter = false;
+        for (IndexVectorSearchSplit split : splits) {
+            Range splitRange = new Range(split.rowRangeStart(), 
split.rowRangeEnd());
+            RoaringNavigableMap64 splitRows = bitmapOf(splitRange);
+
+            RoaringNavigableMap64 include = new RoaringNavigableMap64();
+            include.or(splitRows);
+            if (liveRows != null) {
+                include.and(liveRows);
+            }
+            if (matchedRows != null) {
+                include.and(matchedRows);
+            }
+
+            if (include.getLongCardinality() == splitRange.count()) {
+                includeRowIds.add(null);
+            } else {
+                includeRowIds.add(include);
+                hasFilter = true;
+            }
+        }
+        return hasFilter ? includeRowIds : Collections.emptyList();
+    }
+
+    @Nullable
+    private RoaringNavigableMap64 
scalarMatchedRows(List<IndexVectorSearchSplit> splits) {
         if (filter == null) {
-            return Collections.emptyList();
+            return null;
         }
 
         Set<IndexFileMeta> scalarIndexFiles =
@@ -133,39 +163,18 @@ public abstract class AbstractVectorRead implements 
Serializable {
         Optional<GlobalIndexScanner> optionalScanner =
                 GlobalIndexScanner.create(table, partitionFilter, 
scalarIndexFiles);
         if (!optionalScanner.isPresent()) {
-            return emptyPreFilters(splits.size());
+            return new RoaringNavigableMap64();
         }
 
-        RoaringNavigableMap64 matchedRows;
         try (GlobalIndexScanner scanner = optionalScanner.get()) {
             Optional<GlobalIndexResult> result = scanner.scan(filter);
             if (!result.isPresent()) {
-                return emptyPreFilters(splits.size());
+                return new RoaringNavigableMap64();
             }
-            matchedRows = result.get().results();
+            return result.get().results();
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
-
-        List<RoaringNavigableMap64> includeRowIds = new 
ArrayList<>(splits.size());
-        for (IndexVectorSearchSplit split : splits) {
-            Range splitRange = new Range(split.rowRangeStart(), 
split.rowRangeEnd());
-            RoaringNavigableMap64 splitRows = bitmapOf(splitRange);
-
-            RoaringNavigableMap64 include = new RoaringNavigableMap64();
-            include.or(matchedRows);
-            include.and(splitRows);
-            includeRowIds.add(include);
-        }
-        return includeRowIds;
-    }
-
-    private List<RoaringNavigableMap64> emptyPreFilters(int size) {
-        List<RoaringNavigableMap64> preFilters = new ArrayList<>(size);
-        for (int i = 0; i < size; i++) {
-            preFilters.add(new RoaringNavigableMap64());
-        }
-        return preFilters;
     }
 
     @Nullable
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
index 97609ba564..58cfb75e95 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
@@ -115,6 +115,7 @@ public class FullTextReadImpl implements FullTextRead {
         }
 
         GlobalIndexFileReader indexFileReader = m -> 
table.fileIO().newInputStream(m.filePath());
+        RoaringNavigableMap64 liveRows = 
GlobalIndexLiveRowFilter.liveRows(table, partitionFilter);
         ScoredGlobalIndexResult result =
                 evalQuery(
                         query,
@@ -122,7 +123,8 @@ public class FullTextReadImpl implements FullTextRead {
                         splitsByColumn,
                         indexPathFactory,
                         indexFileReader,
-                        executor);
+                        executor,
+                        liveRows);
         if (!rawRowRanges.isEmpty()) {
             result =
                     new RawFullTextReadImpl(table, partitionFilter, limit, 
query, this::evalQuery)
@@ -139,6 +141,24 @@ public class FullTextReadImpl implements FullTextRead {
             IndexPathFactory indexPathFactory,
             GlobalIndexFileReader indexFileReader,
             ExecutorService executor) {
+        return evalQuery(
+                query,
+                fieldsByName,
+                splitsByColumn,
+                indexPathFactory,
+                indexFileReader,
+                executor,
+                null);
+    }
+
+    private ScoredGlobalIndexResult evalQuery(
+            FullTextQuery query,
+            Map<String, DataField> fieldsByName,
+            Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
+            IndexPathFactory indexPathFactory,
+            GlobalIndexFileReader indexFileReader,
+            ExecutorService executor,
+            @Nullable RoaringNavigableMap64 liveRows) {
         if (query instanceof FullTextQuery.Match) {
             return evalColumnQuery(
                     query,
@@ -147,7 +167,8 @@ public class FullTextReadImpl implements FullTextRead {
                     splitsByColumn,
                     indexPathFactory,
                     indexFileReader,
-                    executor);
+                    executor,
+                    liveRows);
         }
         if (query instanceof FullTextQuery.Phrase) {
             return evalColumnQuery(
@@ -157,7 +178,8 @@ public class FullTextReadImpl implements FullTextRead {
                     splitsByColumn,
                     indexPathFactory,
                     indexFileReader,
-                    executor);
+                    executor,
+                    liveRows);
         }
         if (query instanceof FullTextQuery.MultiMatch) {
             return evalMultiMatch(
@@ -166,7 +188,8 @@ public class FullTextReadImpl implements FullTextRead {
                     splitsByColumn,
                     indexPathFactory,
                     indexFileReader,
-                    executor);
+                    executor,
+                    liveRows);
         }
         if (query instanceof FullTextQuery.Boost) {
             FullTextQuery.Boost boost = (FullTextQuery.Boost) query;
@@ -177,14 +200,16 @@ public class FullTextReadImpl implements FullTextRead {
                             splitsByColumn,
                             indexPathFactory,
                             indexFileReader,
-                            executor),
+                            executor,
+                            liveRows),
                     evalQuery(
                             boost.negative(),
                             fieldsByName,
                             splitsByColumn,
                             indexPathFactory,
                             indexFileReader,
-                            executor),
+                            executor,
+                            liveRows),
                     boost.negativeBoost());
         }
         if (query instanceof FullTextQuery.BooleanQuery) {
@@ -194,7 +219,8 @@ public class FullTextReadImpl implements FullTextRead {
                     splitsByColumn,
                     indexPathFactory,
                     indexFileReader,
-                    executor);
+                    executor,
+                    liveRows);
         }
         throw new IllegalArgumentException("Unsupported full-text query: " + 
query);
     }
@@ -205,7 +231,8 @@ public class FullTextReadImpl implements FullTextRead {
             Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
             IndexPathFactory indexPathFactory,
             GlobalIndexFileReader indexFileReader,
-            ExecutorService executor) {
+            ExecutorService executor,
+            @Nullable RoaringNavigableMap64 liveRows) {
         List<String> columns = query.columns();
         List<Float> boosts = query.boosts();
         List<ScoredGlobalIndexResult> results = new 
ArrayList<>(columns.size());
@@ -227,7 +254,8 @@ public class FullTextReadImpl implements FullTextRead {
                             splitsByColumn,
                             indexPathFactory,
                             indexFileReader,
-                            executor));
+                            executor,
+                            liveRows));
         }
         return or(results).topK(limit);
     }
@@ -238,7 +266,8 @@ public class FullTextReadImpl implements FullTextRead {
             Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
             IndexPathFactory indexPathFactory,
             GlobalIndexFileReader indexFileReader,
-            ExecutorService executor) {
+            ExecutorService executor,
+            @Nullable RoaringNavigableMap64 liveRows) {
         ScoredGlobalIndexResult result = null;
         for (FullTextQuery child : query.must()) {
             ScoredGlobalIndexResult childResult =
@@ -248,7 +277,8 @@ public class FullTextReadImpl implements FullTextRead {
                             splitsByColumn,
                             indexPathFactory,
                             indexFileReader,
-                            executor);
+                            executor,
+                            liveRows);
             result = result == null ? childResult : and(result, childResult);
         }
 
@@ -261,7 +291,8 @@ public class FullTextReadImpl implements FullTextRead {
                             splitsByColumn,
                             indexPathFactory,
                             indexFileReader,
-                            executor));
+                            executor,
+                            liveRows));
         }
         if (!shouldResults.isEmpty()) {
             ScoredGlobalIndexResult shouldResult = or(shouldResults);
@@ -279,7 +310,8 @@ public class FullTextReadImpl implements FullTextRead {
                             splitsByColumn,
                             indexPathFactory,
                             indexFileReader,
-                            executor);
+                            executor,
+                            liveRows);
             result = andNot(result, childResult);
         }
         return result.topK(limit);
@@ -292,7 +324,8 @@ public class FullTextReadImpl implements FullTextRead {
             Map<String, List<IndexFullTextSearchSplit>> splitsByColumn,
             IndexPathFactory indexPathFactory,
             GlobalIndexFileReader indexFileReader,
-            ExecutorService executor) {
+            ExecutorService executor,
+            @Nullable RoaringNavigableMap64 liveRows) {
         List<IndexFullTextSearchSplit> columnSplits = 
splitsByColumn.get(column);
         if (columnSplits == null || columnSplits.isEmpty()) {
             return ScoredGlobalIndexResult.createEmpty();
@@ -328,7 +361,9 @@ public class FullTextReadImpl implements FullTextRead {
                             split.fullTextIndexFiles(),
                             query,
                             indexFileReader,
-                            executor));
+                            executor,
+                            GlobalIndexLiveRowFilter.forRange(
+                                    liveRows, split.rowRangeStart(), 
split.rowRangeEnd())));
         }
 
         CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).join();
@@ -352,7 +387,11 @@ public class FullTextReadImpl implements FullTextRead {
             List<IndexFileMeta> fullTextIndexFiles,
             FullTextQuery query,
             GlobalIndexFileReader indexFileReader,
-            ExecutorService executor) {
+            ExecutorService executor,
+            @Nullable RoaringNavigableMap64 includeRowIds) {
+        if (includeRowIds != null && includeRowIds.isEmpty()) {
+            return CompletableFuture.completedFuture(Optional.empty());
+        }
         List<GlobalIndexIOMeta> indexIOMetaList = new ArrayList<>();
         for (IndexFileMeta indexFile : fullTextIndexFiles) {
             GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
@@ -365,7 +404,8 @@ public class FullTextReadImpl implements FullTextRead {
         GlobalIndexReader reader =
                 globalIndexer.createReader(indexFileReader, indexIOMetaList, 
executor);
         FullTextSearch fullTextSearch =
-                new FullTextSearch(query, candidateLimit(rowRangeStart, 
rowRangeEnd));
+                new FullTextSearch(query, candidateLimit(rowRangeStart, 
rowRangeEnd))
+                        .withIncludeRowIds(includeRowIds);
         return new OffsetGlobalIndexReader(reader, rowRangeStart, rowRangeEnd)
                 .visitFullTextSearch(fullTextSearch)
                 .whenComplete((r, t) -> IOUtils.closeQuietly(reader));
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/GlobalIndexLiveRowFilter.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/GlobalIndexLiveRowFilter.java
new file mode 100644
index 0000000000..63a33479ad
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/GlobalIndexLiveRowFilter.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+/** Live-row filtering shared by global-index based search readers. */
+class GlobalIndexLiveRowFilter {
+
+    @Nullable
+    static RoaringNavigableMap64 liveRows(
+            @Nullable FileStoreTable table, @Nullable PartitionPredicate 
partitionFilter) {
+        if (table == null || !table.coreOptions().deletionVectorsEnabled()) {
+            return null;
+        }
+
+        @Nullable Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest(table);
+        if (snapshot == null) {
+            return null;
+        }
+
+        RoaringNavigableMap64 liveRows = new RoaringNavigableMap64();
+        for (Split split :
+                table.newSnapshotReader()
+                        .withPartitionFilter(partitionFilter)
+                        .withMode(ScanMode.ALL)
+                        .withSnapshot(snapshot)
+                        .read()
+                        .splits()) {
+            if (split instanceof DataSplit) {
+                addLiveRows(table, liveRows, (DataSplit) split);
+            }
+        }
+        return liveRows;
+    }
+
+    @Nullable
+    static RoaringNavigableMap64 forRange(
+            @Nullable RoaringNavigableMap64 liveRows, long from, long to) {
+        if (liveRows == null) {
+            return null;
+        }
+
+        Range range = new Range(from, to);
+        RoaringNavigableMap64 includeRows = new RoaringNavigableMap64();
+        includeRows.addRange(range);
+        includeRows.and(liveRows);
+        return includeRows.getLongCardinality() == range.count() ? null : 
includeRows;
+    }
+
+    private static void addLiveRows(
+            FileStoreTable table, RoaringNavigableMap64 liveRows, DataSplit 
split) {
+        List<DataFileMeta> files = split.dataFiles();
+        List<DeletionFile> deletionFiles = split.deletionFiles().orElse(null);
+        DeletionVector.Factory deletionVectorFactory =
+                DeletionVector.factory(table.fileIO(), files, deletionFiles);
+        for (DataFileMeta file : files) {
+            if (file.firstRowId() == null) {
+                continue;
+            }
+            long firstRowId = file.nonNullFirstRowId();
+            liveRows.addRange(file.nonNullRowIdRange());
+
+            Optional<DeletionVector> deletionVector;
+            try {
+                deletionVector = deletionVectorFactory.create(file.fileName());
+            } catch (IOException e) {
+                throw new RuntimeException(
+                        "Failed to read deletion vector for file " + 
file.fileName(), e);
+            }
+            if (!deletionVector.isPresent() || deletionVector.get().isEmpty()) 
{
+                continue;
+            }
+
+            RoaringNavigableMap64 deletedRows = new RoaringNavigableMap64();
+            deletionVector
+                    .get()
+                    .forEachDeletedPosition(position -> 
deletedRows.add(firstRowId + position));
+            liveRows.andNot(deletedRows);
+        }
+    }
+
+    private GlobalIndexLiveRowFilter() {}
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/DeletionVectorTestUtils.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/DeletionVectorTestUtils.java
new file mode 100644
index 0000000000..55ab396c77
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/DeletionVectorTestUtils.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.utils.Range;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
+
+/** Test utilities for committing deletion vectors. */
+class DeletionVectorTestUtils {
+
+    static void commitDeletionVectors(FileStoreTable table, long... 
deletedRowIds)
+            throws Exception {
+        Map<String, DeletionVector> deletionVectors = new HashMap<>();
+        List<DataFileMeta> dataFiles = dataFiles(table);
+        for (long rowId : deletedRowIds) {
+            DataFileMeta file = dataFileContaining(dataFiles, rowId);
+            deletionVectors
+                    .computeIfAbsent(file.fileName(), ignored -> new 
BitmapDeletionVector())
+                    .delete(rowId - file.nonNullFirstRowId());
+        }
+
+        BaseAppendDeleteFileMaintainer maintainer =
+                BaseAppendDeleteFileMaintainer.forUnawareAppend(
+                        table.store().newIndexFileHandler(),
+                        table.latestSnapshot().get(),
+                        BinaryRow.EMPTY_ROW);
+        for (Map.Entry<String, DeletionVector> entry : 
deletionVectors.entrySet()) {
+            maintainer.notifyNewDeletionVector(entry.getKey(), 
entry.getValue());
+        }
+
+        List<IndexFileMeta> newIndexFiles = new ArrayList<>();
+        List<IndexFileMeta> deletedIndexFiles = new ArrayList<>();
+        for (IndexManifestEntry entry : maintainer.persist()) {
+            if (entry.kind() == FileKind.ADD) {
+                newIndexFiles.add(entry.indexFile());
+            } else if (entry.kind() == FileKind.DELETE) {
+                deletedIndexFiles.add(entry.indexFile());
+            }
+        }
+
+        CommitMessage message =
+                new CommitMessageImpl(
+                        BinaryRow.EMPTY_ROW,
+                        UNAWARE_BUCKET,
+                        null,
+                        new DataIncrement(
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                newIndexFiles,
+                                deletedIndexFiles),
+                        CompactIncrement.emptyIncrement());
+        try (BatchTableCommit commit = 
table.newBatchWriteBuilder().newCommit()) {
+            commit.commit(Collections.singletonList(message));
+        }
+    }
+
+    private static List<DataFileMeta> dataFiles(FileStoreTable table) {
+        List<DataFileMeta> dataFiles = new ArrayList<>();
+        for (ManifestEntry entry : table.store().newScan().plan().files()) {
+            dataFiles.add(entry.file());
+        }
+        return dataFiles;
+    }
+
+    private static DataFileMeta dataFileContaining(List<DataFileMeta> 
dataFiles, long rowId) {
+        for (DataFileMeta file : dataFiles) {
+            if (file.firstRowId() == null) {
+                continue;
+            }
+            Range range = file.nonNullRowIdRange();
+            if (range.from <= rowId && rowId <= range.to) {
+                return file;
+            }
+        }
+        throw new IllegalArgumentException("No data file contains row id " + 
rowId);
+    }
+
+    private DeletionVectorTestUtils() {}
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
index 5f61bc5de6..a9d8e2475b 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
@@ -65,6 +65,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
+import static 
org.apache.paimon.table.source.DeletionVectorTestUtils.commitDeletionVectors;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -125,6 +126,40 @@ public class FullTextSearchBuilderTest extends 
TableTestBase {
         assertThat(ids).containsAnyOf(0, 1, 3);
     }
 
+    @Test
+    public void testFullTextSearchExcludesDeletedIndexedRows() throws 
Exception {
+        Identifier identifier = identifier("full_text_deleted_indexed_rows");
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column(TEXT_FIELD_NAME, DataTypes.STRING())
+                        .option(CoreOptions.BUCKET.key(), "-1")
+                        .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+                        .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), 
"true")
+                        .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true")
+                        .build();
+        catalog.createTable(identifier, schema, false);
+        FileStoreTable table = getTable(identifier);
+
+        String[] documents = {
+            "paimon keyword", "paimon keyword", "paimon keyword", "paimon 
keyword"
+        };
+        writeDocuments(table, documents);
+        buildAndCommitIndex(table, documents);
+        commitDeletionVectors(table, 0L, 1L);
+
+        GlobalIndexResult result =
+                table.newFullTextSearchBuilder()
+                        .withQuery(FullTextQuery.match("keyword", 
TEXT_FIELD_NAME))
+                        .withLimit(2)
+                        .executeLocal();
+
+        assertThat(result.results().getLongCardinality()).isEqualTo(2);
+        assertThat(result.results()).contains(2L, 3L);
+        assertThat(result.results()).doesNotContain(0L, 1L);
+        assertThat(readIds(table, result)).containsExactlyInAnyOrder(2, 3);
+    }
+
     @Test
     public void testFullTextSearchNonFastModesScanUnindexedData() throws 
Exception {
         createTableDefault();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
index b937efe223..fdac8eef8a 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
@@ -71,6 +71,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
+import static 
org.apache.paimon.table.source.DeletionVectorTestUtils.commitDeletionVectors;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -186,6 +187,63 @@ public class VectorSearchBuilderTest extends TableTestBase 
{
         assertThat(ids).contains(0);
     }
 
+    @Test
+    public void testVectorSearchExcludesDeletedIndexedRows() throws Exception {
+        catalog.createTable(
+                identifier("vector_search_deleted_indexed_rows"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("vector_search_deleted_indexed_rows"));
+
+        float[][] vectors = {{0.0f, 0.0f}, {1.0f, 0.0f}, {2.0f, 0.0f}, {3.0f, 
0.0f}};
+        writeVectors(table, vectors);
+        buildAndCommitIndex(table, vectors);
+        commitDeletionVectors(table, 0L, 1L);
+
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeLocal();
+
+        assertThat(result.results().getLongCardinality()).isEqualTo(2);
+        assertThat(result.results()).contains(2L, 3L);
+        assertThat(result.results()).doesNotContain(0L, 1L);
+        assertThat(readIds(table, result)).containsExactly(2, 3);
+    }
+
+    @Test
+    public void testBatchVectorSearchExcludesDeletedIndexedRows() throws 
Exception {
+        catalog.createTable(
+                identifier("batch_vector_search_deleted_indexed_rows"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("batch_vector_search_deleted_indexed_rows"));
+
+        float[][] vectors = {{0.0f, 0.0f}, {1.0f, 0.0f}, {2.0f, 0.0f}, {3.0f, 
0.0f}};
+        writeVectors(table, vectors);
+        buildAndCommitIndex(table, vectors);
+        commitDeletionVectors(table, 0L, 3L);
+
+        List<GlobalIndexResult> results =
+                table.newBatchVectorSearchBuilder()
+                        .withVectors(new float[][] {{0.0f, 0.0f}, {3.0f, 
0.0f}})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeBatchLocal();
+
+        assertThat(results).hasSize(2);
+        assertThat(results.get(0).results()).contains(1L);
+        assertThat(results.get(0).results()).doesNotContain(0L);
+        assertThat(results.get(1).results()).contains(2L);
+        assertThat(results.get(1).results()).doesNotContain(3L);
+    }
+
     @Test
     public void testVectorSearchWithCosineMetric() throws Exception {
         // Create a table with cosine metric
diff --git a/paimon-python/pypaimon/globalindex/full_text_search.py 
b/paimon-python/pypaimon/globalindex/full_text_search.py
index d1511df2e3..dc6a3dd02d 100644
--- a/paimon-python/pypaimon/globalindex/full_text_search.py
+++ b/paimon-python/pypaimon/globalindex/full_text_search.py
@@ -32,10 +32,12 @@ class FullTextSearch:
     Attributes:
         query: The structured full-text query
         limit: Maximum number of results to return
+        include_row_ids: Optional bitmap of row IDs to include in search
     """
 
     query: FullTextQuery
     limit: int
+    include_row_ids: Optional['RoaringBitmap64'] = None
 
     def __post_init__(self):
         if self.query is None:
@@ -54,6 +56,29 @@ class FullTextSearch:
     def query_json(self) -> str:
         return self.query.to_json()
 
+    def with_include_row_ids(self, include_row_ids: 'RoaringBitmap64') -> 
'FullTextSearch':
+        """Return a new FullTextSearch with the specified include_row_ids."""
+        return FullTextSearch(
+            query=self.query,
+            limit=self.limit,
+            include_row_ids=include_row_ids,
+        )
+
+    def offset_range(self, from_: int, to: int) -> 'FullTextSearch':
+        """Offset include_row_ids into the given range."""
+        if self.include_row_ids is None:
+            return self
+
+        from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+        range_bitmap = RoaringBitmap64()
+        range_bitmap.add_range(from_, to)
+        and_result = RoaringBitmap64.and_(range_bitmap, self.include_row_ids)
+        offset_bitmap = RoaringBitmap64()
+        for row_id in and_result:
+            offset_bitmap.add(row_id - from_)
+        return self.with_include_row_ids(offset_bitmap)
+
     def visit(self, visitor: 'GlobalIndexReader') -> 
'Future[Optional[ScoredGlobalIndexResult]]':
         """Visit the global index reader with this full-text search."""
         return visitor.visit_full_text_search(self)
diff --git a/paimon-python/pypaimon/globalindex/offset_global_index_reader.py 
b/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
index f13145ff50..ce370f1336 100644
--- a/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
@@ -61,7 +61,8 @@ class OffsetGlobalIndexReader(GlobalIndexReader):
 
     def visit_full_text_search(self, full_text_search) -> 
'Future[Optional[GlobalIndexResult]]':
         return self._apply_offset_future(
-            self._wrapped.visit_full_text_search(full_text_search))
+            self._wrapped.visit_full_text_search(
+                full_text_search.offset_range(self._offset, self._to)))
 
     def visit_equal(self, field_ref: FieldRef, literal: object) -> 
'Future[Optional[GlobalIndexResult]]':
         return self._apply_offset_future(self._wrapped.visit_equal(field_ref, 
literal))
diff --git 
a/paimon-python/pypaimon/globalindex/tantivy/tantivy_full_text_global_index_reader.py
 
b/paimon-python/pypaimon/globalindex/tantivy/tantivy_full_text_global_index_reader.py
index 441420a872..8e3d22c7be 100644
--- 
a/paimon-python/pypaimon/globalindex/tantivy/tantivy_full_text_global_index_reader.py
+++ 
b/paimon-python/pypaimon/globalindex/tantivy/tantivy_full_text_global_index_reader.py
@@ -346,8 +346,21 @@ class TantivyFullTextGlobalIndexReader(GlobalIndexReader):
 
         import tantivy
 
+        include_row_ids = full_text_search.include_row_ids
+        search_limit = limit
+        if include_row_ids is not None:
+            if include_row_ids.is_empty():
+                return _completed_future(DictBasedScoredIndexResult({}))
+            search_limit = self._required_shard_doc_count()
+
         id_to_scores = self._search_full_text_query(
-            tantivy, full_text_search.query, limit)
+            tantivy, full_text_search.query, search_limit)
+        if include_row_ids is not None:
+            id_to_scores = {
+                row_id: score
+                for row_id, score in id_to_scores.items()
+                if include_row_ids.contains(row_id)
+            }
         return _completed_future(
             DictBasedScoredIndexResult(id_to_scores).top_k(limit))
 
@@ -377,11 +390,26 @@ class TantivyFullTextGlobalIndexReader(GlobalIndexReader):
             self._parse_structured_query(tantivy, query), limit)
 
     def _child_query_limit(self, limit):
-        num_docs = getattr(self._searcher, "num_docs", None)
+        num_docs = self._shard_doc_count()
         if num_docs is None:
             return limit
         return max(limit, int(num_docs))
 
+    def _required_shard_doc_count(self):
+        num_docs = self._shard_doc_count()
+        if num_docs is None:
+            raise RuntimeError(
+                "PyPaimon Tantivy full-text search with include_row_ids "
+                "requires a tantivy-py Searcher with num_docs support."
+            )
+        return max(0, int(num_docs))
+
+    def _shard_doc_count(self):
+        num_docs = getattr(self._searcher, "num_docs", None)
+        if callable(num_docs):
+            num_docs = num_docs()
+        return None if num_docs is None else int(num_docs)
+
     def _search_boolean_query(self, tantivy, query, limit):
         from pypaimon.globalindex.full_text_query import Occur
 
diff --git a/paimon-python/pypaimon/table/source/full_text_read.py 
b/paimon-python/pypaimon/table/source/full_text_read.py
index e35b081d9f..87fa4d5362 100644
--- a/paimon-python/pypaimon/table/source/full_text_read.py
+++ b/paimon-python/pypaimon/table/source/full_text_read.py
@@ -37,6 +37,7 @@ from pypaimon.globalindex.vector_search_result import (
     DictBasedScoredIndexResult,
     ScoredGlobalIndexResult,
 )
+from pypaimon.table.source import global_index_live_row_filter
 from pypaimon.table.source.full_text_search_split import FullTextSearchSplit
 from pypaimon.table.source.full_text_scan import FullTextScanPlan
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
@@ -61,12 +62,14 @@ class FullTextReadImpl(FullTextRead):
         table: 'FileStoreTable',
         limit: int,
         text_column,
-        query: FullTextQuery
+        query: FullTextQuery,
+        partition_filter=None,
     ):
         self._table = table
         self._limit = limit
         self._text_columns = text_column if isinstance(text_column, list) else 
[text_column]
         self._query = query
+        self._partition_filter = partition_filter
 
     def read(self, splits: List[FullTextSearchSplit]) -> GlobalIndexResult:
         if not splits:
@@ -75,38 +78,45 @@ class FullTextReadImpl(FullTextRead):
         splits_by_column: Dict[str, List[FullTextSearchSplit]] = {}
         for split in splits:
             splits_by_column.setdefault(split.column_name, []).append(split)
-        return self._eval_query(self._query, 
splits_by_column).top_k(self._limit)
+        live_rows = global_index_live_row_filter.live_rows(
+            self._table, self._partition_filter)
+        return self._eval_query(
+            self._query, splits_by_column, live_rows).top_k(self._limit)
 
     def _eval_query(
             self,
             query: FullTextQuery,
-            splits_by_column: Dict[str, List[FullTextSearchSplit]]
+            splits_by_column: Dict[str, List[FullTextSearchSplit]],
+            live_rows
     ) -> ScoredGlobalIndexResult:
         if isinstance(query, MatchQuery):
-            return self._eval_column_query(query, query.column, 
splits_by_column)
+            return self._eval_column_query(
+                query, query.column, splits_by_column, live_rows)
         if isinstance(query, PhraseQuery):
-            return self._eval_column_query(query, query.column, 
splits_by_column)
+            return self._eval_column_query(
+                query, query.column, splits_by_column, live_rows)
         if isinstance(query, MultiMatchQuery):
             results = []
             for column, boost in zip(query.columns, query.boosts):
                 match = MatchQuery(
                     query.query, column, boost=boost, operator=query.operator)
                 results.append(
-                    self._eval_column_query(match, column, splits_by_column))
+                    self._eval_column_query(match, column, splits_by_column, 
live_rows))
             return _or(results).top_k(self._limit)
         if isinstance(query, BoostQuery):
-            positive = self._eval_query(query.positive, splits_by_column)
-            negative = self._eval_query(query.negative, splits_by_column)
+            positive = self._eval_query(query.positive, splits_by_column, 
live_rows)
+            negative = self._eval_query(query.negative, splits_by_column, 
live_rows)
             return _boost(positive, negative, query.negative_boost)
         if isinstance(query, BooleanQuery):
             result = None
             for child in query.must():
-                child_result = self._eval_query(child, splits_by_column)
+                child_result = self._eval_query(child, splits_by_column, 
live_rows)
                 result = child_result if result is None else _and(result, 
child_result)
 
             should_results = []
             for child in query.should():
-                should_results.append(self._eval_query(child, 
splits_by_column))
+                should_results.append(
+                    self._eval_query(child, splits_by_column, live_rows))
             if should_results:
                 should_result = _or(should_results)
                 result = should_result if result is None else _and_with_bonus(
@@ -115,7 +125,8 @@ class FullTextReadImpl(FullTextRead):
             if result is None:
                 return ScoredGlobalIndexResult.create_empty()
             for child in query.must_not():
-                result = _and_not(result, self._eval_query(child, 
splits_by_column))
+                result = _and_not(
+                    result, self._eval_query(child, splits_by_column, 
live_rows))
             return result
         raise ValueError("Unsupported full-text query type: %s" % 
type(query).__name__)
 
@@ -123,19 +134,26 @@ class FullTextReadImpl(FullTextRead):
             self,
             query: FullTextQuery,
             column: str,
-            splits_by_column: Dict[str, List[FullTextSearchSplit]]
+            splits_by_column: Dict[str, List[FullTextSearchSplit]],
+            live_rows
     ) -> ScoredGlobalIndexResult:
         splits = splits_by_column.get(column, [])
         if not splits:
             return ScoredGlobalIndexResult.create_empty()
-        futures = [
-            self._eval(
+        futures = []
+        for split in splits:
+            include_row_ids = global_index_live_row_filter.for_range(
+                live_rows, split.row_range_start, split.row_range_end)
+            if include_row_ids is not None and include_row_ids.is_empty():
+                continue
+            futures.append(self._eval(
                 split.row_range_start, split.row_range_end,
                 split.full_text_index_files,
                 query,
-            )
-            for split in splits
-        ]
+                include_row_ids,
+            ))
+        if not futures:
+            return ScoredGlobalIndexResult.create_empty()
 
         wait(futures)
 
@@ -150,7 +168,8 @@ class FullTextReadImpl(FullTextRead):
 
         return DictBasedScoredIndexResult(merged_scores).top_k(self._limit)
 
-    def _eval(self, row_range_start, row_range_end, full_text_index_files, 
query):
+    def _eval(self, row_range_start, row_range_end, full_text_index_files,
+              query, include_row_ids):
         index_io_meta_list = []
         for index_file in full_text_index_files:
             meta = index_file.global_index_meta
@@ -177,6 +196,8 @@ class FullTextReadImpl(FullTextRead):
             query=query,
             limit=_candidate_limit(row_range_start, row_range_end),
         )
+        if include_row_ids is not None:
+            full_text_search = 
full_text_search.with_include_row_ids(include_row_ids)
 
         offset_reader = OffsetGlobalIndexReader(reader, row_range_start, 
row_range_end)
         future = offset_reader.visit_full_text_search(full_text_search)
diff --git a/paimon-python/pypaimon/table/source/full_text_search_builder.py 
b/paimon-python/pypaimon/table/source/full_text_search_builder.py
index 49393d63da..dcc252c54b 100644
--- a/paimon-python/pypaimon/table/source/full_text_search_builder.py
+++ b/paimon-python/pypaimon/table/source/full_text_search_builder.py
@@ -123,7 +123,11 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
         if self._limit <= 0:
             raise ValueError("Limit must be positive, set via with_limit()")
         return FullTextReadImpl(
-            self._table, self._limit, self._text_columns(), self._query
+            self._table,
+            self._limit,
+            self._text_columns(),
+            self._query,
+            partition_filter=self._partition_filter,
         )
 
     def _text_columns(self):
diff --git 
a/paimon-python/pypaimon/table/source/global_index_live_row_filter.py 
b/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
new file mode 100644
index 0000000000..68b08a80d0
--- /dev/null
+++ b/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
@@ -0,0 +1,85 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Live-row filtering shared by global-index based search readers."""
+
+from typing import Optional
+
+from pypaimon.deletionvectors.deletion_vector import DeletionVector
+from pypaimon.read.split import DataSplit
+from pypaimon.utils.range import Range
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+def live_rows(table, partition_filter=None) -> Optional[RoaringBitmap64]:
+    """Return current live global row ids for deletion-vector tables.
+
+    ``None`` means no live-row filter is needed. This keeps tables without
+    deletion vectors on the old zero-overhead path.
+    """
+    options = getattr(table, "options", None)
+    deletion_vectors_enabled = getattr(options, "deletion_vectors_enabled", 
None)
+    if (table is None
+            or not callable(deletion_vectors_enabled)
+            or not deletion_vectors_enabled(False)):
+        return None
+
+    read_builder = table.new_read_builder()
+    if partition_filter is not None:
+        read_builder = read_builder.with_partition_filter(partition_filter)
+
+    rows = RoaringBitmap64()
+    for split in read_builder.new_scan().plan().splits():
+        if isinstance(split, DataSplit):
+            rows = _add_live_rows(table, rows, split)
+    return rows
+
+
+def for_range(live_row_ids: Optional[RoaringBitmap64],
+              from_: int, to: int) -> Optional[RoaringBitmap64]:
+    if live_row_ids is None:
+        return None
+
+    row_range = Range(from_, to)
+    include = RoaringBitmap64()
+    include.add_range(row_range.from_, row_range.to)
+    include = RoaringBitmap64.and_(include, live_row_ids)
+    return None if include.cardinality() == row_range.count() else include
+
+
+def _add_live_rows(table, rows: RoaringBitmap64, split: DataSplit) -> 
RoaringBitmap64:
+    deletion_files = split.data_deletion_files or []
+    for i, data_file in enumerate(split.files):
+        row_id_range = data_file.row_id_range()
+        if row_id_range is None:
+            continue
+
+        rows.add_range(row_id_range.from_, row_id_range.to)
+        deletion_file = deletion_files[i] if i < len(deletion_files) else None
+        if deletion_file is None or deletion_file.cardinality == 0:
+            continue
+
+        deletion_vector = DeletionVector.read(table.file_io, deletion_file)
+        if deletion_vector.is_empty():
+            continue
+
+        deleted_rows = RoaringBitmap64()
+        first_row_id = data_file.first_row_id
+        for position in deletion_vector.bit_map():
+            deleted_rows.add(first_row_id + position)
+        rows = RoaringBitmap64.remove_all(rows, deleted_rows)
+    return rows
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index b0a229f043..bcb142e4f1 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -30,6 +30,7 @@ from pypaimon.table.source.vector_search_split import (
     IndexVectorSearchSplit,
     RawVectorSearchSplit,
 )
+from pypaimon.table.source import global_index_live_row_filter
 from pypaimon.utils.range import Range
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
@@ -81,10 +82,38 @@ class AbstractVectorSearchReadImpl:
 
     def _pre_filters(self, splits):
         # type: (list) -> List[RoaringBitmap64]
-        """Evaluate scalar indexes and return one include bitmap per index 
split."""
-        if self._filter is None:
+        """Evaluate live-row/scalar filters and return one bitmap per index 
split."""
+        if not splits:
+            return []
+
+        live_rows = global_index_live_row_filter.live_rows(
+            self._table, self._partition_filter)
+        matched_rows = self._scalar_matched_rows(splits)
+        if live_rows is None and matched_rows is None:
             return []
 
+        include_row_ids = []
+        has_filter = False
+        for split in splits:
+            split_range = Range(split.row_range_start, split.row_range_end)
+            include = _bitmap_of_range(split_range)
+            if live_rows is not None:
+                include = RoaringBitmap64.and_(include, live_rows)
+            if matched_rows is not None:
+                include = RoaringBitmap64.and_(include, matched_rows)
+
+            if include.cardinality() == split_range.count():
+                include_row_ids.append(None)
+            else:
+                include_row_ids.append(include)
+                has_filter = True
+        return include_row_ids if has_filter else []
+
+    def _scalar_matched_rows(self, splits):
+        """Evaluate scalar indexes and return matching global row ids."""
+        if self._filter is None:
+            return None
+
         # Collect scalar index files across splits, deduplicated by file name.
         seen = set()
         scalar_files = []
@@ -96,7 +125,7 @@ class AbstractVectorSearchReadImpl:
                 scalar_files.append(index_file)
 
         if not scalar_files:
-            return _empty_bitmaps(len(splits))
+            return RoaringBitmap64()
 
         from pypaimon.globalindex.global_index_scanner import 
GlobalIndexScanner
         scanner = GlobalIndexScanner.create(
@@ -105,30 +134,26 @@ class AbstractVectorSearchReadImpl:
             partition_filter=self._partition_filter,
         )
         if scanner is None:
-            return _empty_bitmaps(len(splits))
+            return RoaringBitmap64()
         try:
             result = scanner.scan(self._filter)
             if result is None:
-                return _empty_bitmaps(len(splits))
-            matched_rows = result.results()
+                return RoaringBitmap64()
+            return result.results()
         finally:
             scanner.close()
 
-        include_row_ids = []
-        for split in splits:
-            split_rows = _bitmap_of_range(
-                Range(split.row_range_start, split.row_range_end))
-            include_row_ids.append(RoaringBitmap64.and_(matched_rows, 
split_rows))
-        return include_row_ids
-
     def _pre_filter(self, splits):
         # Backwards-compatible helper used by older tests/callers.
         pre_filters = self._pre_filters(splits)
         if not pre_filters:
             return None
         merged = RoaringBitmap64()
-        for bitmap in pre_filters:
-            merged = RoaringBitmap64.or_(merged, bitmap)
+        for split, bitmap in zip(splits, pre_filters):
+            if bitmap is None:
+                merged.add_range(split.row_range_start, split.row_range_end)
+            else:
+                merged = RoaringBitmap64.or_(merged, bitmap)
         return merged
 
     def _raw_pre_filter(self, splits):
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 47bebcd21f..b74a9f3d64 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -121,6 +121,13 @@ def _entry(partition_row, field_id, index_type, file_name,
                               index_file=index_file)
 
 
+def _bitmap(*row_ids):
+    bitmap = RoaringBitmap64()
+    for row_id in row_ids:
+        bitmap.add(row_id)
+    return bitmap
+
+
 def _install_raw_vector_read_builder(table, vector_column_name, 
row_id_to_vector,
                                      calls=None):
     """Install a fake raw read builder which honors GlobalIndexResult 
ranges."""
@@ -386,15 +393,25 @@ class _FakeSearcher:
     def __init__(self):
         self.query = None
         self.queries = []
+        self.num_docs = 3
 
     def search(self, query, limit):
         self.query = query
         self.queries.append(query)
         query_text = _fake_query_text(query)
         if query_text == "positive":
-            return _FakeSearchResults([(10.0, ("addr", 1)), (5.0, ("addr", 
2))])
+            hits = [(10.0, ("addr", 1)), (5.0, ("addr", 2))]
+            return _FakeSearchResults(hits[:limit])
         if query_text == "negative":
-            return _FakeSearchResults([(7.0, ("addr", 2))])
+            hits = [(7.0, ("addr", 2))]
+            return _FakeSearchResults(hits[:limit])
+        if query_text == "ranked":
+            hits = [
+                (10.0, ("addr", 1)),
+                (9.0, ("addr", 2)),
+                (8.0, ("addr", 3)),
+            ]
+            return _FakeSearchResults(hits[:limit])
         return _FakeSearchResults()
 
     def fast_field_values(self, name, addresses):
@@ -462,6 +479,99 @@ class _FakeTantivy(types.SimpleNamespace):
 # ----------------------------- tests ---------------------------------------
 
 
+class GlobalIndexLiveRowFilterTest(unittest.TestCase):
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+    def test_live_rows_noops_without_deletion_vectors(self):
+        from pypaimon.table.source import global_index_live_row_filter
+
+        class _Options:
+            def deletion_vectors_enabled(self_inner, default=False):
+                return False
+
+        class _Table:
+            options = _Options()
+
+            def new_read_builder(self_inner):
+                raise AssertionError("non-DV table must not be scanned")
+
+        self.assertIsNone(global_index_live_row_filter.live_rows(_Table()))
+
+    def test_live_rows_subtracts_deletion_vector_positions(self):
+        from pypaimon.read.split import DataSplit
+        from pypaimon.table.source import global_index_live_row_filter
+        from pypaimon.table.source.deletion_file import DeletionFile
+
+        calls = {}
+
+        class _Options:
+            def deletion_vectors_enabled(self_inner, default=False):
+                return True
+
+        class _File:
+            first_row_id = 10
+            row_count = 5
+
+            def row_id_range(self_inner):
+                return Range(10, 14)
+
+        deletion_file = DeletionFile("dv", 0, 1, cardinality=2)
+        split = DataSplit(
+            files=[_File()],
+            partition=None,
+            bucket=0,
+            data_deletion_files=[deletion_file],
+        )
+
+        class _Plan:
+            def splits(self_inner):
+                return [split]
+
+        class _Scan:
+            def plan(self_inner):
+                return _Plan()
+
+        class _Builder:
+            def with_partition_filter(self_inner, predicate):
+                calls["partition_filter"] = predicate
+                return self_inner
+
+            def new_scan(self_inner):
+                calls["new_scan"] = True
+                return _Scan()
+
+        class _Table:
+            options = _Options()
+            file_io = object()
+
+            def new_read_builder(self_inner):
+                calls["new_read_builder"] = True
+                return _Builder()
+
+        class _DeletionVector:
+            def is_empty(self_inner):
+                return False
+
+            def bit_map(self_inner):
+                return [1, 3]
+
+        partition_filter = object()
+        with mock.patch(
+                "pypaimon.table.source.global_index_live_row_filter."
+                "DeletionVector.read",
+                return_value=_DeletionVector()) as read:
+            rows = global_index_live_row_filter.live_rows(
+                _Table(), partition_filter)
+
+        read.assert_called_once_with(_Table.file_io, deletion_file)
+        self.assertIs(partition_filter, calls["partition_filter"])
+        self.assertTrue(calls["new_read_builder"])
+        self.assertTrue(calls["new_scan"])
+        self.assertEqual([10, 12, 14], rows.to_list())
+
+
 class VectorReaderFactoryTest(unittest.TestCase):
     """Vector reader factory compatibility."""
 
@@ -900,6 +1010,36 @@ class TantivyFullTextIndexOptionsTest(unittest.TestCase):
             [_fake_query_text(q) for q in 
tantivy.last_index.searcher_instance.queries],
         )
 
+    def test_reader_applies_include_row_ids_before_top_k(self):
+        from pypaimon.globalindex.full_text_search import FullTextSearch
+        from 
pypaimon.globalindex.tantivy.tantivy_full_text_global_index_reader import (
+            TantivyFullTextGlobalIndexReader,
+        )
+
+        tantivy = _FakeTantivy()
+        old_tantivy = sys.modules.get("tantivy")
+        sys.modules["tantivy"] = tantivy
+        try:
+            reader = TantivyFullTextGlobalIndexReader(
+                _FakeFileIO(),
+                "/unused",
+                [GlobalIndexIOMeta(file_name="ft.index", file_size=1)])
+            try:
+                search = FullTextSearch(
+                    MatchQuery("ranked", "content"), 1
+                ).with_include_row_ids(_bitmap(3))
+                result = reader.visit_full_text_search(search).result()
+            finally:
+                reader.close()
+        finally:
+            if old_tantivy is None:
+                sys.modules.pop("tantivy", None)
+            else:
+                sys.modules["tantivy"] = old_tantivy
+
+        self.assertEqual([3], sorted(list(result.results())))
+        self.assertEqual(8.0, result.score_getter()(3))
+
     def test_reader_rejects_java_unsupported_match_options(self):
         from pypaimon.globalindex.full_text_search import FullTextSearch
         from 
pypaimon.globalindex.tantivy.tantivy_full_text_global_index_reader import (
@@ -1519,11 +1659,14 @@ class VectorSearchFilterTest(unittest.TestCase):
         self.assertEqual(1, scanner.scan.call_count)
         self.assertIs(filter_pred, scanner.scan.call_args[0][0])
 
-        # [0,4] sees empty local bitmap; [5,9] sees {0..4}.
-        self.assertEqual(
-            [0, 5],
-            sorted(vs.include_row_ids.cardinality()
-                   for vs in captured_searches))
+        # [0,4] sees empty local bitmap; [5,9] is fully included and stays 
None.
+        include_summary = sorted(
+            ("all", None)
+            if vs.include_row_ids is None
+            else ("bitmap", vs.include_row_ids.cardinality())
+            for vs in captured_searches
+        )
+        self.assertEqual([("all", None), ("bitmap", 0)], include_summary)
 
         # Vector reader io_meta carries external_path from IndexFileMeta.
         seen_paths = {meta.external_path
@@ -1533,6 +1676,112 @@ class VectorSearchFilterTest(unittest.TestCase):
             {"oss://bucket/vec-0.index", "oss://bucket/vec-1.index"},
             seen_paths)
 
+    def test_indexed_vector_search_filters_deleted_rows(self):
+        from pypaimon.globalindex.vector_search_result import (
+            DictBasedScoredIndexResult,
+        )
+        from pypaimon.table.source.vector_search_read import 
VectorSearchReadImpl
+        from pypaimon.table.source.vector_search_split import 
IndexVectorSearchSplit
+
+        entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+                       file_name="vec.index", row_range_start=10,
+                       row_range_end=14)
+        table = _StubTable(fields=[self.embedding_field], entries=[entry])
+        split = IndexVectorSearchSplit(
+            row_range_start=10,
+            row_range_end=14,
+            vector_index_files=[entry.index_file],
+        )
+        live_rows = _bitmap(10, 12, 13, 14)
+        captured = []
+
+        def _fake_create(index_type, file_io, index_path,
+                         index_io_meta_list, options=None):
+            class _FakeReader:
+                def visit_vector_search(self_inner, vs):
+                    captured.append(vs.include_row_ids)
+                    return _completed_future(
+                        DictBasedScoredIndexResult({2: 1.0}))
+
+                def close(self_inner):
+                    pass
+
+            return _FakeReader()
+
+        with mock.patch(
+                "pypaimon.table.source.vector_search_read."
+                "global_index_live_row_filter.live_rows",
+                return_value=live_rows), \
+             mock.patch(
+                
"pypaimon.table.source.vector_search_read._create_vector_reader",
+                side_effect=_fake_create):
+            result = VectorSearchReadImpl(
+                table,
+                limit=3,
+                vector_column=self.embedding_field,
+                query_vector=[1.0],
+            ).read([split])
+
+        self.assertEqual([[0, 2, 3, 4]], [b.to_list() for b in captured])
+        self.assertEqual([12], sorted(list(result.results())))
+
+    def test_batch_indexed_vector_search_filters_deleted_rows(self):
+        from pypaimon.globalindex.global_index_reader import GlobalIndexReader
+        from pypaimon.globalindex.vector_search_result import (
+            DictBasedScoredIndexResult,
+        )
+        from pypaimon.table.source.vector_search_read import (
+            BatchVectorSearchReadImpl,
+        )
+        from pypaimon.table.source.vector_search_split import 
IndexVectorSearchSplit
+
+        entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+                       file_name="vec.index", row_range_start=10,
+                       row_range_end=14)
+        table = _StubTable(fields=[self.embedding_field], entries=[entry])
+        split = IndexVectorSearchSplit(
+            row_range_start=10,
+            row_range_end=14,
+            vector_index_files=[entry.index_file],
+        )
+        live_rows = _bitmap(10, 12, 13, 14)
+        captured = []
+
+        def _fake_create(index_type, file_io, index_path,
+                         index_io_meta_list, options=None):
+            class _FakeReader(GlobalIndexReader):
+                def visit_batch_vector_search(self_inner, bvs):
+                    captured.append(bvs.include_row_ids)
+                    return _completed_future([
+                        DictBasedScoredIndexResult({2: 1.0})
+                        for _ in range(bvs.vector_count)
+                    ])
+
+                def close(self_inner):
+                    pass
+
+            return _FakeReader()
+
+        with mock.patch(
+                "pypaimon.table.source.vector_search_read."
+                "global_index_live_row_filter.live_rows",
+                return_value=live_rows), \
+             mock.patch(
+                
"pypaimon.table.source.vector_search_read._create_vector_reader",
+                side_effect=_fake_create):
+            results = BatchVectorSearchReadImpl(
+                table,
+                limit=3,
+                vector_column=self.embedding_field,
+                query_vectors=[[1.0], [2.0]],
+            ).read_batch([split])
+
+        self.assertEqual([[0, 2, 3, 4]], [b.to_list() for b in captured])
+        self.assertEqual([[12], [12]], [
+            sorted(list(result.results()))
+            for result in results
+        ])
+
     def test_read_threads_options_to_vector_search(self):
         scan_plan = self._builder().new_vector_search_scan().scan()
 
@@ -2953,6 +3202,64 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         self.assertEqual("oss://bucket/ft.index",
                          captured_io_metas[0][0].external_path)
 
+    def test_full_text_read_filters_deleted_rows(self):
+        from pypaimon.globalindex.vector_search_result import (
+            DictBasedScoredIndexResult,
+        )
+        from pypaimon.table.source.full_text_read import FullTextReadImpl
+        from pypaimon.table.source.full_text_search_split import (
+            FullTextSearchSplit,
+        )
+
+        text_field = _field(1, "content", "STRING")
+        entry = _entry(None, field_id=1, index_type="tantivy-fulltext",
+                       file_name="ft.index", row_range_start=10,
+                       row_range_end=14)
+        table = _StubTable(fields=[text_field], entries=[entry])
+        split = FullTextSearchSplit(
+            column_name="content",
+            row_range_start=10,
+            row_range_end=14,
+            full_text_index_files=[entry.index_file],
+        )
+        live_rows = _bitmap(10, 12, 13, 14)
+        partition_filter = object()
+        captured = []
+
+        def _fake_create(index_type, file_io, index_path, index_io_meta_list):
+            class _FakeReader:
+                def visit_full_text_search(self_inner, fts):
+                    captured.append(fts.include_row_ids)
+                    return _completed_future(
+                        DictBasedScoredIndexResult({
+                            row_id: float(row_id)
+                            for row_id in fts.include_row_ids
+                        }))
+
+                def close(self_inner):
+                    pass
+
+            return _FakeReader()
+
+        with mock.patch(
+                "pypaimon.table.source.full_text_read."
+                "global_index_live_row_filter.live_rows",
+                return_value=live_rows) as live_rows_fn, \
+             mock.patch(
+                
"pypaimon.table.source.full_text_read._create_full_text_reader",
+                side_effect=_fake_create):
+            result = FullTextReadImpl(
+                table,
+                limit=10,
+                text_column=text_field,
+                query=MatchQuery("test", "content"),
+                partition_filter=partition_filter,
+            ).read([split])
+
+        live_rows_fn.assert_called_once_with(table, partition_filter)
+        self.assertEqual([[0, 2, 3, 4]], [b.to_list() for b in captured])
+        self.assertEqual([10, 12, 13, 14], sorted(list(result.results())))
+
     def test_full_text_search_with_many_splits(self):
         from pypaimon.globalindex.vector_search_result import (
             DictBasedScoredIndexResult,
diff --git 
a/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexReader.java
 
b/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexReader.java
index 4c45df6111..1dd9d81fe9 100644
--- 
a/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexReader.java
+++ 
b/paimon-tantivy/paimon-tantivy-index/src/main/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexReader.java
@@ -31,6 +31,8 @@ import org.apache.paimon.tantivy.StreamFileInput;
 import org.apache.paimon.tantivy.TantivySearcher;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
@@ -93,10 +95,22 @@ public class TantivyFullTextGlobalIndexReader implements 
GlobalIndexReader {
                 () -> {
                     try {
                         ensureLoaded();
+                        RoaringNavigableMap64 includeRowIds = 
fullTextSearch.includeRowIds();
+                        if (includeRowIds != null && includeRowIds.isEmpty()) {
+                            return 
Optional.of(ScoredGlobalIndexResult.createEmpty());
+                        }
+                        int searchLimit =
+                                includeRowIds == null
+                                        ? fullTextSearch.limit()
+                                        : borrowed.searcher.numDocs();
+                        if (searchLimit <= 0) {
+                            return 
Optional.of(ScoredGlobalIndexResult.createEmpty());
+                        }
                         SearchResult result =
                                 borrowed.searcher.searchJson(
-                                        fullTextSearch.queryJson(), 
fullTextSearch.limit());
-                        return Optional.of(toScoredResult(result));
+                                        fullTextSearch.queryJson(), 
searchLimit);
+                        ScoredGlobalIndexResult scored = 
toScoredResult(result, includeRowIds);
+                        return 
Optional.of(scored.topK(fullTextSearch.limit()));
                     } catch (IOException e) {
                         throw new RuntimeException("Failed to search Tantivy 
full-text index", e);
                     }
@@ -104,11 +118,15 @@ public class TantivyFullTextGlobalIndexReader implements 
GlobalIndexReader {
                 executor);
     }
 
-    private ScoredGlobalIndexResult toScoredResult(SearchResult result) {
+    private ScoredGlobalIndexResult toScoredResult(
+            SearchResult result, @Nullable RoaringNavigableMap64 
includeRowIds) {
         RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
         HashMap<Long, Float> id2scores = new HashMap<>(result.size());
         for (int i = 0; i < result.size(); i++) {
             long rowId = result.getRowIds()[i];
+            if (includeRowIds != null && !includeRowIds.contains(rowId)) {
+                continue;
+            }
             bitmap.add(rowId);
             id2scores.put(rowId, result.getScores()[i]);
         }
diff --git 
a/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexTest.java
 
b/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexTest.java
index f259646557..a2f4068297 100644
--- 
a/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexTest.java
+++ 
b/paimon-tantivy/paimon-tantivy-index/src/test/java/org/apache/paimon/tantivy/index/TantivyFullTextGlobalIndexTest.java
@@ -345,6 +345,36 @@ public class TantivyFullTextGlobalIndexTest {
         }
     }
 
+    @Test
+    public void testIncludeRowIdsSearchesFullShardBeforeTopK() throws 
IOException {
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        TantivyFullTextGlobalIndexWriter writer = new 
TantivyFullTextGlobalIndexWriter(fileWriter);
+
+        writer.write(BinaryString.fromString("paimon paimon paimon paimon"), 
0);
+        writer.write(BinaryString.fromString("paimon paimon"), 1);
+        writer.write(BinaryString.fromString("paimon"), 2);
+
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+        GlobalIndexFileReader fileReader = createFileReader();
+
+        RoaringNavigableMap64 includeRowIds = new RoaringNavigableMap64();
+        includeRowIds.add(2L);
+        FullTextSearch search =
+                new FullTextSearch(FullTextQuery.match("paimon", "text"), 1)
+                        .withIncludeRowIds(includeRowIds);
+
+        try (TantivyFullTextGlobalIndexReader reader = 
createReader(fileReader, metas)) {
+            Optional<ScoredGlobalIndexResult> searchResult =
+                    reader.visitFullTextSearch(search).join();
+            assertThat(searchResult).isPresent();
+
+            RoaringNavigableMap64 rowIds = searchResult.get().results();
+            assertThat(rowIds.getLongCardinality()).isEqualTo(1);
+            assertThat(rowIds.contains(2L)).isTrue();
+        }
+    }
+
     @Test
     public void testPoolReuse() throws IOException {
         GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
diff --git a/paimon-tantivy/paimon-tantivy-jni/rust/src/lib.rs 
b/paimon-tantivy/paimon-tantivy-jni/rust/src/lib.rs
index 7f111efcf8..d6dacbca92 100644
--- a/paimon-tantivy/paimon-tantivy-jni/rust/src/lib.rs
+++ b/paimon-tantivy/paimon-tantivy-jni/rust/src/lib.rs
@@ -1280,6 +1280,17 @@ pub extern "system" fn 
Java_org_apache_paimon_tantivy_TantivySearcher_searchInde
     search_with_query(&mut env, handle, query, limit)
 }
 
+#[no_mangle]
+pub extern "system" fn Java_org_apache_paimon_tantivy_TantivySearcher_numDocs(
+    _env: JNIEnv,
+    _class: JClass,
+    searcher_ptr: jlong,
+) -> jint {
+    let handle = unsafe { &*(searcher_ptr as *const TantivySearcherHandle) };
+    let num_docs = handle.reader.searcher().num_docs() as u64;
+    std::cmp::min(num_docs, i32::MAX as u64) as jint
+}
+
 #[no_mangle]
 pub extern "system" fn 
Java_org_apache_paimon_tantivy_TantivySearcher_freeSearcher(
     _env: JNIEnv,
diff --git 
a/paimon-tantivy/paimon-tantivy-jni/src/main/java/org/apache/paimon/tantivy/TantivySearcher.java
 
b/paimon-tantivy/paimon-tantivy-jni/src/main/java/org/apache/paimon/tantivy/TantivySearcher.java
index 8962e5b99b..85421f536f 100644
--- 
a/paimon-tantivy/paimon-tantivy-jni/src/main/java/org/apache/paimon/tantivy/TantivySearcher.java
+++ 
b/paimon-tantivy/paimon-tantivy-jni/src/main/java/org/apache/paimon/tantivy/TantivySearcher.java
@@ -126,6 +126,11 @@ public class TantivySearcher implements AutoCloseable {
         return searchIndexJson(searcherPtr, queryJson, limit);
     }
 
+    public int numDocs() {
+        checkNotClosed();
+        return numDocs(searcherPtr);
+    }
+
     @Override
     public void close() {
         if (!closed) {
@@ -185,6 +190,8 @@ public class TantivySearcher implements AutoCloseable {
 
     static native SearchResult searchIndexJson(long searcherPtr, String 
queryJson, int limit);
 
+    static native int numDocs(long searcherPtr);
+
     static native void freeSearcher(long searcherPtr);
 
 }
diff --git 
a/paimon-tantivy/paimon-tantivy-jni/src/test/java/org/apache/paimon/tantivy/TantivyJniTest.java
 
b/paimon-tantivy/paimon-tantivy-jni/src/test/java/org/apache/paimon/tantivy/TantivyJniTest.java
index 80300c24a8..8e45a133c7 100644
--- 
a/paimon-tantivy/paimon-tantivy-jni/src/test/java/org/apache/paimon/tantivy/TantivyJniTest.java
+++ 
b/paimon-tantivy/paimon-tantivy-jni/src/test/java/org/apache/paimon/tantivy/TantivyJniTest.java
@@ -55,6 +55,8 @@ public class TantivyJniTest {
         }
 
         try (TantivySearcher searcher = new TantivySearcher(indexPath)) {
+            assertEquals(3, searcher.numDocs());
+
             SearchResult result = searcher.search("paimon", 10);
             assertTrue(result.size() > 0, "Should find at least one result");
             assertEquals(result.getRowIds().length, result.getScores().length);

Reply via email to