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 b15bda5e32 [core][python] Fix global index coverage for residual 
predicates (#9050)
b15bda5e32 is described below

commit b15bda5e3279e74edf26bf9e0f8f3ff21ab0cce4
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Aug 6 09:03:45 2026 +0800

    [core][python] Fix global index coverage for residual predicates (#9050)
---
 .../paimon/globalindex/GlobalIndexEvaluator.java   |  85 +++--
 .../globalindex/GlobalIndexEvaluatorTest.java      |  51 +++
 .../paimon/globalindex/DataEvolutionBatchScan.java |   9 +-
 .../DataEvolutionGlobalIndexScanner.java           |  13 +
 .../source/AbstractDataEvolutionVectorRead.java    |   9 +-
 .../paimon/table/BtreeGlobalIndexTableTest.java    |  26 ++
 .../data_evolution_global_index_scanner.py         |  83 +++--
 .../pypaimon/globalindex/global_index_evaluator.py |  67 ++--
 .../pypaimon/read/scanner/file_scanner.py          |  83 ++++-
 paimon-python/pypaimon/read/table_scan.py          |  10 +-
 .../pypaimon/table/source/full_text_read.py        |   3 +-
 .../pypaimon/table/source/vector_search_read.py    |  36 +-
 .../pypaimon/table/source/vector_search_scan.py    |  13 +-
 .../pypaimon/tests/global_index_evaluator_test.py  |  58 +++
 .../tests/global_index_scalar_search_mode_test.py  |  19 +-
 paimon-python/pypaimon/tests/global_index_test.py  |  87 ++++-
 paimon-python/pypaimon/tests/native_plan_test.py   |   6 +-
 .../scanner/chunk_shuffle_split_generator_test.py  |   8 +
 .../pypaimon/tests/vector_search_filter_test.py    | 394 +++++++++++++++++----
 19 files changed, 850 insertions(+), 210 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
index 49958cc3be..a23bd2c76a 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
@@ -39,6 +39,7 @@ import java.io.Closeable;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Deque;
 import java.util.HashSet;
 import java.util.List;
@@ -71,7 +72,15 @@ public class GlobalIndexEvaluator implements Closeable {
         if (predicate == null) {
             return Optional.empty();
         }
-        return awaitGlobalIndexResult(visitAsync(predicate));
+        return await(visitAsync(predicate)).map(Evaluation::result);
+    }
+
+    /** Evaluate the predicate and return the fields whose supported indexes 
contributed. */
+    public Optional<Evaluation> evaluateWithContributingFields(@Nullable 
Predicate predicate) {
+        if (predicate == null) {
+            return Optional.empty();
+        }
+        return await(visitAsync(predicate));
     }
 
     public Optional<GlobalIndexResult> evaluateTopN(TopN topN) {
@@ -84,11 +93,10 @@ public class GlobalIndexEvaluator implements Closeable {
             return Optional.empty();
         }
         checkArgument(readers.size() == 1, "TopN expects one aggregated global 
index reader.");
-        return 
awaitGlobalIndexResult(readers.iterator().next().visitTopN(topN));
+        return await(readers.iterator().next().visitTopN(topN));
     }
 
-    private Optional<GlobalIndexResult> awaitGlobalIndexResult(
-            CompletableFuture<Optional<GlobalIndexResult>> future) {
+    private <T> T await(CompletableFuture<T> future) {
         try {
             return future.get();
         } catch (InterruptedException e) {
@@ -105,14 +113,14 @@ public class GlobalIndexEvaluator implements Closeable {
         }
     }
 
-    private CompletableFuture<Optional<GlobalIndexResult>> 
visitAsync(Predicate predicate) {
+    private CompletableFuture<Optional<Evaluation>> visitAsync(Predicate 
predicate) {
         if (predicate instanceof LeafPredicate) {
             return visitLeafAsync((LeafPredicate) predicate);
         }
         return visitCompoundAsync((CompoundPredicate) predicate);
     }
 
-    private CompletableFuture<Optional<GlobalIndexResult>> 
visitLeafAsync(LeafPredicate predicate) {
+    private CompletableFuture<Optional<Evaluation>> 
visitLeafAsync(LeafPredicate predicate) {
         Optional<FieldRef> fieldRefOptional = predicate.fieldRefOptional();
         if (!fieldRefOptional.isPresent()) {
             return CompletableFuture.completedFuture(Optional.empty());
@@ -145,18 +153,20 @@ public class GlobalIndexEvaluator implements Closeable {
                                     compoundResult = childResult;
                                 }
                                 if (compoundResult.get().results().isEmpty()) {
-                                    return compoundResult;
+                                    break;
                                 }
                             }
-                            return compoundResult;
+                            return compoundResult.map(
+                                    result ->
+                                            new Evaluation(result, 
Collections.singleton(fieldId)));
                         });
     }
 
-    private CompletableFuture<Optional<GlobalIndexResult>> visitCompoundAsync(
+    private CompletableFuture<Optional<Evaluation>> visitCompoundAsync(
             CompoundPredicate predicate) {
         List<Predicate> children =
                 pruneRedundantIsNotNullForAnd(flattenChildren(predicate), 
predicate);
-        List<CompletableFuture<Optional<GlobalIndexResult>>> childFutures =
+        List<CompletableFuture<Optional<Evaluation>>> childFutures =
                 new ArrayList<>(children.size());
         for (Predicate child : children) {
             childFutures.add(visitAsync(child));
@@ -165,40 +175,67 @@ public class GlobalIndexEvaluator implements Closeable {
         return CompletableFuture.allOf(childFutures.toArray(new 
CompletableFuture[0]))
                 .thenApply(
                         v -> {
-                            List<Optional<GlobalIndexResult>> results = new 
ArrayList<>();
-                            for 
(CompletableFuture<Optional<GlobalIndexResult>> f : childFutures) {
+                            List<Optional<Evaluation>> results = new 
ArrayList<>();
+                            for (CompletableFuture<Optional<Evaluation>> f : 
childFutures) {
                                 results.add(f.join());
                             }
                             return combineResults(results, predicate);
                         });
     }
 
-    private Optional<GlobalIndexResult> combineResults(
-            List<Optional<GlobalIndexResult>> results, CompoundPredicate 
predicate) {
+    private Optional<Evaluation> combineResults(
+            List<Optional<Evaluation>> results, CompoundPredicate predicate) {
+        Set<Integer> contributingFieldIds = new HashSet<>();
         if (predicate.function() instanceof Or) {
             GlobalIndexResult compoundResult = GlobalIndexResult.createEmpty();
-            for (Optional<GlobalIndexResult> childResult : results) {
-                if (!childResult.isPresent()) {
+            for (Optional<Evaluation> child : results) {
+                if (!child.isPresent()) {
                     return Optional.empty();
                 }
-                compoundResult = compoundResult.or(childResult.get());
+                compoundResult = compoundResult.or(child.get().result());
+                
contributingFieldIds.addAll(child.get().contributingFieldIds());
             }
-            return Optional.of(compoundResult);
+            return Optional.of(new Evaluation(compoundResult, 
contributingFieldIds));
         } else {
             Optional<GlobalIndexResult> compoundResult = Optional.empty();
-            for (Optional<GlobalIndexResult> childResult : results) {
-                if (childResult.isPresent()) {
+            for (Optional<Evaluation> child : results) {
+                if (child.isPresent()) {
                     if (compoundResult.isPresent()) {
-                        compoundResult = 
Optional.of(compoundResult.get().and(childResult.get()));
+                        compoundResult =
+                                
Optional.of(compoundResult.get().and(child.get().result()));
                     } else {
-                        compoundResult = childResult;
+                        compoundResult = Optional.of(child.get().result());
                     }
+                    
contributingFieldIds.addAll(child.get().contributingFieldIds());
                 }
                 if (compoundResult.isPresent() && 
compoundResult.get().results().isEmpty()) {
-                    return compoundResult;
+                    break;
                 }
             }
-            return compoundResult;
+            return compoundResult.map(result -> new Evaluation(result, 
contributingFieldIds));
+        }
+    }
+
+    /**
+     * Matches and fields whose supported index results contributed; discarded 
branches excluded.
+     */
+    public static final class Evaluation {
+
+        private final GlobalIndexResult result;
+        private final Set<Integer> contributingFieldIds;
+
+        private Evaluation(GlobalIndexResult result, Collection<Integer> 
contributingFieldIds) {
+            this.result = result;
+            this.contributingFieldIds =
+                    Collections.unmodifiableSet(new 
HashSet<>(contributingFieldIds));
+        }
+
+        public GlobalIndexResult result() {
+            return result;
+        }
+
+        public Set<Integer> contributingFieldIds() {
+            return contributingFieldIds;
         }
     }
 
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
index 42ccc570e2..126ff3682c 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
@@ -225,6 +225,57 @@ class GlobalIndexEvaluatorTest {
         evaluator.close();
     }
 
+    @Test
+    void testAndTracksOnlyEvaluatedFields() {
+        executor = Executors.newFixedThreadPool(2);
+        RowType rowType = rowType();
+
+        GlobalIndexEvaluator evaluator =
+                new GlobalIndexEvaluator(
+                        rowType,
+                        fieldId ->
+                                fieldId == 0
+                                        ? 
Collections.singletonList(readerReturning(resultOf(42)))
+                                        : Collections.emptyList());
+        PredicateBuilder builder = new PredicateBuilder(rowType);
+        Predicate predicate = PredicateBuilder.and(builder.equal(0, 42), 
builder.equal(1, 99));
+
+        Optional<GlobalIndexEvaluator.Evaluation> evaluation =
+                evaluator.evaluateWithContributingFields(predicate);
+
+        assertThat(evaluation).isPresent();
+        assertThat(evaluation.get().contributingFieldIds()).containsExactly(0);
+        assertBitmapContainsExactly(evaluation.get().result().results(), 42L);
+        evaluator.close();
+    }
+
+    @Test
+    void testDiscardedOrBranchDoesNotContributeFields() {
+        executor = Executors.newFixedThreadPool(2);
+        RowType rowType = rowType();
+
+        GlobalIndexEvaluator evaluator =
+                new GlobalIndexEvaluator(
+                        rowType,
+                        fieldId ->
+                                fieldId == 0 || fieldId == 2
+                                        ? 
Collections.singletonList(readerReturning(resultOf(42)))
+                                        : Collections.emptyList());
+        PredicateBuilder builder = new PredicateBuilder(rowType);
+        Predicate predicate =
+                PredicateBuilder.and(
+                        PredicateBuilder.or(builder.equal(0, 42), 
builder.equal(1, 99)),
+                        builder.equal(2, 42));
+
+        Optional<GlobalIndexEvaluator.Evaluation> evaluation =
+                evaluator.evaluateWithContributingFields(predicate);
+
+        assertThat(evaluation).isPresent();
+        assertThat(evaluation.get().contributingFieldIds()).containsExactly(2);
+        assertBitmapContainsExactly(evaluation.get().result().results(), 42L);
+        evaluator.close();
+    }
+
     @Test
     void testAndWithEmptyResultShortCircuits() {
         executor = Executors.newFixedThreadPool(2);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
index 059d661608..8929ef4c7b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionBatchScan.java
@@ -318,12 +318,17 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
 
         try (DataEvolutionGlobalIndexScanner scanner = optionalScanner.get()) {
             long lookupStart = System.nanoTime();
-            Optional<GlobalIndexResult> result = 
scanner.scan(globalIndexFilter);
+            Optional<GlobalIndexEvaluator.Evaluation> result =
+                    scanner.scanWithCoverage(globalIndexFilter);
             long lookupDuration = System.nanoTime() - lookupStart;
             if (result.isPresent()) {
                 long coverageStart = System.nanoTime();
                 GlobalIndexResult finalResult =
-                        
result.get().or(scanner.unindexedRows(globalIndexFilter));
+                        result.get()
+                                .result()
+                                .or(
+                                        
scanner.unindexedRowsForContributingFields(
+                                                
result.get().contributingFieldIds()));
                 long coverageDuration = System.nanoTime() - coverageStart;
                 long totalDuration = System.nanoTime() - totalStart;
                 LOG.info(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
index 498130a11d..9d6acaedde 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexScanner.java
@@ -372,6 +372,10 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
         return globalIndexEvaluator.evaluate(predicate);
     }
 
+    public Optional<GlobalIndexEvaluator.Evaluation> 
scanWithCoverage(Predicate predicate) {
+        return globalIndexEvaluator.evaluateWithContributingFields(predicate);
+    }
+
     public Optional<GlobalIndexResult> scan(TopN topN) {
         if (!isSupportedTopN(topN)) {
             return Optional.empty();
@@ -394,6 +398,15 @@ public class DataEvolutionGlobalIndexScanner implements 
Closeable {
         return GlobalIndexResult.create(rows);
     }
 
+    public GlobalIndexResult unindexedRowsForContributingFields(
+            Collection<Integer> contributingFieldIds) {
+        RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+        for (Range range : coverage.unindexedRanges(contributingFieldIds)) {
+            rows.addRange(range);
+        }
+        return GlobalIndexResult.create(rows);
+    }
+
     public GlobalIndexResult unindexedRows(TopN topN) {
         String fieldName = topN.orders().get(0).field().name();
         RoaringNavigableMap64 rows = new RoaringNavigableMap64();
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
index 75fbb2b5b7..b35a1eb45c 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataEvolutionVectorRead.java
@@ -25,6 +25,7 @@ import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.InternalVector;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.globalindex.DataEvolutionGlobalIndexScanner;
+import org.apache.paimon.globalindex.GlobalIndexEvaluator;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexResult;
@@ -221,12 +222,14 @@ public abstract class AbstractDataEvolutionVectorRead 
implements Serializable {
 
         RoaringNavigableMap64 include = new RoaringNavigableMap64();
         try (DataEvolutionGlobalIndexScanner scanner = optionalScanner.get()) {
-            Optional<GlobalIndexResult> result = scanner.scan(filter);
+            Optional<GlobalIndexEvaluator.Evaluation> result = 
scanner.scanWithCoverage(filter);
             if (!result.isPresent()) {
                 return null;
             }
-            include.or(result.get().results());
-            include.or(scanner.unindexedRows(filter).results());
+            include.or(result.get().result().results());
+            include.or(
+                    
scanner.unindexedRowsForContributingFields(result.get().contributingFieldIds())
+                            .results());
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
index 375cac3989..9d85570130 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/BtreeGlobalIndexTableTest.java
@@ -151,6 +151,32 @@ public class BtreeGlobalIndexTableTest extends 
DataEvolutionTestBase {
         assertThat(readF1).containsExactly("a200", "a300", "a400", "a56789");
     }
 
+    @Test
+    public void testFullSearchIgnoresUnindexedAndResidualForCoverage() throws 
Exception {
+        write(100L);
+        createIndex("f1");
+
+        FileStoreTable table =
+                tableWithSearchMode((FileStoreTable) 
catalog.getTable(identifier()), "full");
+        PredicateBuilder builder = new PredicateBuilder(table.rowType());
+        Predicate predicate =
+                PredicateBuilder.and(
+                        builder.equal(1, BinaryString.fromString("a42")),
+                        builder.equal(2, BinaryString.fromString("b42")));
+        ReadBuilder readBuilder = table.newReadBuilder().withFilter(predicate);
+
+        TableScan.Plan plan = readBuilder.newScan().plan();
+
+        assertThat(plan.splits()).allMatch(IndexedSplit.class::isInstance);
+        assertThat(
+                        plan.splits().stream()
+                                .map(IndexedSplit.class::cast)
+                                .flatMap(split -> split.rowRanges().stream())
+                                .collect(Collectors.toList()))
+                .containsExactly(new Range(42, 42));
+        assertThat(readF1(readBuilder, plan)).containsExactly("a42");
+    }
+
     @Test
     public void testBTreeGlobalIndexTopNCandidatesAcrossRanges() throws 
Exception {
         write(100L);
diff --git 
a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py 
b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
index 192c2e99bb..dde7f24f57 100644
--- a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
+++ b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
@@ -18,9 +18,12 @@
 """Scanner for shard-based global indexes on data-evolution tables."""
 
 from concurrent.futures import ThreadPoolExecutor
-from typing import Collection, Optional
+from typing import Collection, List, Optional
 
-from pypaimon.globalindex.global_index_evaluator import GlobalIndexEvaluator
+from pypaimon.globalindex.global_index_evaluator import (
+    GlobalIndexEvaluation,
+    GlobalIndexEvaluator,
+)
 from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
 from pypaimon.globalindex.global_index_reader import GlobalIndexReader, 
_map_future
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
@@ -33,6 +36,9 @@ from pypaimon.schema.data_types import DataField
 from pypaimon.utils.range import Range
 
 
+_SUPPORTED_SCALAR_INDEX_TYPES = frozenset(('btree', 'bitmap'))
+
+
 class DataEvolutionGlobalIndexScanner:
     """Scanner for shard-based global indexes."""
 
@@ -48,6 +54,7 @@ class DataEvolutionGlobalIndexScanner:
         snapshot=None,
         partition_filter=None,
     ):
+        index_files = _supported_scalar_index_files(index_files)
         self._options = options or CoreOptions(Options.from_none())
         self._executor = ThreadPoolExecutor(
             max_workers=thread_num or 32
@@ -103,24 +110,33 @@ class DataEvolutionGlobalIndexScanner:
         options = self._options
 
         def readers_function(field: DataField) -> 
Collection[GlobalIndexReader]:
+            groups = []
             group = index_metas.get(field.id)
             if group is not None:
-                return _create_readers(
-                    file_io, index_path, group.metas, field, executor, options)
+                groups.append(group)
 
             extra_groups = extra_index_metas.get(field.id)
-            if not extra_groups:
+            if extra_groups:
+                groups.extend(
+                    extra_group
+                    for extra_group in extra_groups
+                    if extra_group not in groups
+                )
+            if not groups:
                 return []
+            if len(groups) == 1:
+                return _create_readers(
+                    file_io, index_path, groups[0].metas, field, executor, 
options)
             union_coverage = Range.sort_and_merge_overlap(
                 [
                     range_key
-                    for group in extra_groups
+                    for group in groups
                     for range_key in group.coverage_ranges
                 ],
                 True,
             )
             readers = []
-            for group in extra_groups:
+            for group in groups:
                 pad_ranges = _exclude_ranges(union_coverage, 
group.coverage_ranges)
                 readers.extend(
                     _create_readers(
@@ -145,6 +161,7 @@ class DataEvolutionGlobalIndexScanner:
         from pypaimon.index.index_file_handler import IndexFileHandler
 
         if index_files is not None:
+            index_files = _supported_scalar_index_files(index_files)
             if len(index_files) == 0:
                 return None
             core_options = _core_options(table)
@@ -172,6 +189,8 @@ class DataEvolutionGlobalIndexScanner:
             if partition_filter is not None:
                 if not partition_filter.test(entry.partition):
                     return False
+            if not is_supported_scalar_index(entry.index_file):
+                return False
             global_index_meta = entry.index_file.global_index_meta
             if global_index_meta is None:
                 return False
@@ -209,14 +228,32 @@ class DataEvolutionGlobalIndexScanner:
         """Scan the global index with the given predicate."""
         return self._evaluator.evaluate(predicate)
 
+    def scan_with_coverage(
+        self, predicate: Optional[Predicate]
+    ) -> Optional[GlobalIndexEvaluation]:
+        return self._evaluator.evaluate_with_contributing_fields(predicate)
+
     def unindexed_rows(self, predicate: Optional[Predicate],
-                       search_mode=None) -> GlobalIndexResult:
+                       search_mode=None,
+                       contributing_field_ids=None) -> GlobalIndexResult:
         """Return coarse row ids not covered by global indexes."""
+        return GlobalIndexResult.from_ranges(self.unindexed_ranges(
+            predicate,
+            search_mode=search_mode,
+            contributing_field_ids=contributing_field_ids,
+        ))
+
+    def unindexed_ranges(self, predicate: Optional[Predicate],
+                         search_mode=None,
+                         contributing_field_ids=None) -> List[Range]:
+        """Return row ranges not covered by global indexes."""
         if self._coverage is None:
-            return GlobalIndexResult.create_empty()
-        return GlobalIndexResult.from_ranges(
-            self._coverage.unindexed_ranges(
-                self._fields, predicate, search_mode=search_mode))
+            return []
+        if contributing_field_ids is not None:
+            return self._coverage.unindexed_ranges(
+                contributing_field_ids, search_mode=search_mode)
+        return self._coverage.unindexed_ranges(
+            self._fields, predicate, search_mode=search_mode)
 
     def close(self):
         """Close the scanner and release resources."""
@@ -330,6 +367,18 @@ def _resolve_snapshot(table, snapshot):
     return snapshot_manager.get_latest_snapshot()
 
 
+def is_supported_scalar_index(index_file):
+    return (
+        index_file.global_index_meta is not None
+        and index_file.index_type in _SUPPORTED_SCALAR_INDEX_TYPES
+    )
+
+
+def _supported_scalar_index_files(index_files):
+    return [index_file for index_file in index_files
+            if is_supported_scalar_index(index_file)]
+
+
 def _core_options(table):
     options = getattr(table, "options", None)
     if options is None:
@@ -413,15 +462,5 @@ def _create_inner_readers(
             
fallback_scan_max_size=core_options.bitmap_index_fallback_scan_max_size(),
         )]
 
-    from pypaimon.globalindex.full_text import (
-        FULL_TEXT_IDENTIFIER,
-        NativeFullTextGlobalIndexReader,
-    )
-    if index_type == FULL_TEXT_IDENTIFIER:
-        return [
-            NativeFullTextGlobalIndexReader(file_io, index_path, [io_meta])
-            for io_meta in io_metas
-        ]
-
     raise ValueError(
         "Unsupported global-index type in scanner: '%s'" % index_type)
diff --git a/paimon-python/pypaimon/globalindex/global_index_evaluator.py 
b/paimon-python/pypaimon/globalindex/global_index_evaluator.py
index 9fdf64a607..b0c8a0ed2c 100644
--- a/paimon-python/pypaimon/globalindex/global_index_evaluator.py
+++ b/paimon-python/pypaimon/globalindex/global_index_evaluator.py
@@ -20,7 +20,7 @@
 import threading
 from collections import deque
 from concurrent.futures import Future
-from typing import Callable, Collection, Dict, List, Optional
+from typing import Callable, Collection, Dict, FrozenSet, List, NamedTuple, 
Optional
 
 from pypaimon.globalindex.global_index_reader import GlobalIndexReader, 
FieldRef
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
@@ -28,6 +28,13 @@ from pypaimon.common.predicate import Predicate
 from pypaimon.schema.data_types import DataField
 
 
+class GlobalIndexEvaluation(NamedTuple):
+    """Matches and fields whose supported indexes contributed."""
+
+    result: GlobalIndexResult
+    contributing_field_ids: FrozenSet[int]
+
+
 class GlobalIndexEvaluator:
     """Predicate evaluator for filtering data using global indexes.
 
@@ -51,8 +58,17 @@ class GlobalIndexEvaluator:
     ) -> Optional[GlobalIndexResult]:
         if predicate is None:
             return None
-        future = self._visit_async(predicate)
-        return future.result()
+        evaluation = self._visit_async(predicate).result()
+        return evaluation.result if evaluation is not None else None
+
+    def evaluate_with_contributing_fields(
+        self,
+        predicate: Optional[Predicate]
+    ) -> Optional[GlobalIndexEvaluation]:
+        """Return matches and fields whose supported indexes contributed."""
+        if predicate is None:
+            return None
+        return self._visit_async(predicate).result()
 
     def _visit_async(self, predicate) -> Future:
         if isinstance(predicate, Predicate) and predicate.method in ('and', 
'or'):
@@ -94,7 +110,8 @@ class GlobalIndexEvaluator:
                 if remaining[0] == 0:
                     try:
                         all_done.set_result(
-                            self._combine_reader_results(reader_futures)
+                            self._combine_reader_results(
+                                reader_futures, field_id)
                         )
                     except Exception as e:
                         all_done.set_exception(e)
@@ -105,8 +122,8 @@ class GlobalIndexEvaluator:
         return all_done
 
     def _combine_reader_results(
-        self, reader_futures: List[Future]
-    ) -> Optional[GlobalIndexResult]:
+        self, reader_futures: List[Future], field_id: int,
+    ) -> Optional[GlobalIndexEvaluation]:
         compound_result: Optional[GlobalIndexResult] = None
         for f in reader_futures:
             child_result = f.result()
@@ -117,8 +134,10 @@ class GlobalIndexEvaluator:
             else:
                 compound_result = child_result
             if compound_result.is_empty():
-                return compound_result
-        return compound_result
+                break
+        if compound_result is None:
+            return None
+        return GlobalIndexEvaluation(compound_result, frozenset([field_id]))
 
     def _visit_compound_async(self, predicate: Predicate) -> Future:
         children = self._flatten_children(predicate.method, predicate.literals)
@@ -150,26 +169,34 @@ class GlobalIndexEvaluator:
         return all_done
 
     def _combine_results(
-        self, results: List[Optional[GlobalIndexResult]], method: str
-    ) -> Optional[GlobalIndexResult]:
+        self, results: List[Optional[GlobalIndexEvaluation]], method: str
+    ) -> Optional[GlobalIndexEvaluation]:
+        contributing_field_ids = set()
         if method == 'or':
             compound_result = GlobalIndexResult.create_empty()
-            for child_result in results:
-                if child_result is None:
+            for child in results:
+                if child is None:
                     return None
-                compound_result = compound_result.or_(child_result)
-            return compound_result
+                compound_result = compound_result.or_(child.result)
+                contributing_field_ids.update(child.contributing_field_ids)
+            return GlobalIndexEvaluation(compound_result,
+                                         frozenset(contributing_field_ids))
         else:
             compound_result: Optional[GlobalIndexResult] = None
-            for child_result in results:
-                if child_result is not None:
+            for child in results:
+                if child is not None:
                     if compound_result is not None:
-                        compound_result = compound_result.and_(child_result)
+                        compound_result = compound_result.and_(child.result)
                     else:
-                        compound_result = child_result
+                        compound_result = child.result
+                    contributing_field_ids.update(
+                        child.contributing_field_ids)
                 if compound_result is not None and compound_result.is_empty():
-                    return compound_result
-            return compound_result
+                    break
+            if compound_result is None:
+                return None
+            return GlobalIndexEvaluation(compound_result,
+                                         frozenset(contributing_field_ids))
 
     def _flatten_children(self, method: str, children) -> list:
         result = []
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index 3283a51bf9..e92205405d 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -18,12 +18,13 @@
 import logging
 import os
 import time
-from typing import Callable, Dict, List, Optional, Set, Tuple
+from typing import Callable, Dict, List, NamedTuple, Optional, Set, Tuple
 
 logger = logging.getLogger(__name__)
 
 from pypaimon.common.predicate import Predicate
 from pypaimon.globalindex import ScoredGlobalIndexResult
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.manifest.index_manifest_file import IndexManifestFile
 from pypaimon.manifest.manifest_file_manager import ManifestFileManager
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
@@ -56,11 +57,16 @@ from pypaimon.snapshot.snapshot import Snapshot
 from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.table.source.deletion_file import DeletionFile
+from pypaimon.utils.range import Range
+
+
+class _GlobalIndexPlanningResult(NamedTuple):
+    indexed_result: GlobalIndexResult
+    unindexed_ranges: List[Range]
 
 
 def _row_ranges_from_predicate(predicate: Optional[Predicate]) -> 
Optional[List]:
     from pypaimon.table.special_fields import SpecialFields
-    from pypaimon.utils.range import Range
 
     if predicate is None:
         return None
@@ -119,8 +125,6 @@ def _build_early_row_range_filter(row_ranges):
     if row_ranges is None or not row_ranges:
         return None
 
-    from pypaimon.utils.range import Range
-
     def _filter(record):
         file_dict = record.get('_FILE')
         if file_dict is None:
@@ -156,8 +160,6 @@ def _filter_manifest_files_by_row_ranges(
     Returns:
         Filtered list of manifest files
     """
-    from pypaimon.utils.range import Range
-
     filtered_files = []
     for manifest in manifest_files:
         min_row_id = manifest.min_row_id
@@ -270,6 +272,7 @@ class FileScanner:
         self.data_evolution = options.data_evolution_enabled()
         self.deletion_vectors_enabled = options.deletion_vectors_enabled()
         self._global_index_result = None
+        self._row_ranges = None
         self._scanned_snapshot = None
         self._scanned_snapshot_id = None
         # Opt-in scan-plan tracking. Stays ``None`` for the read hot path;
@@ -430,7 +433,7 @@ class FileScanner:
         return list(PrimaryKeySortedIndexResult(evaluated).splits)
 
     def _create_data_evolution_split_generator(self):
-        row_ranges = None
+        row_ranges = getattr(self, '_row_ranges', None)
         score_getter = None
         # Fetch snapshot once and share with global index evaluation to avoid
         # a duplicate /snapshot REST round-trip (#7513).
@@ -438,15 +441,36 @@ class FileScanner:
         self._scanned_snapshot = snapshot
         self._scanned_snapshot_id = snapshot.id if snapshot else None
 
-        global_index_result = self._global_index_result if 
self._global_index_result is not None \
-            else self._eval_global_index(snapshot)
-        if global_index_result is not None:
-            row_ranges = global_index_result.results().to_range_list()
-            if isinstance(global_index_result, ScoredGlobalIndexResult):
-                score_getter = global_index_result.score_getter()
+        if row_ranges is None:
+            global_index_plan = self._global_index_result \
+                if self._global_index_result is not None \
+                else self._eval_global_index(snapshot)
+            if global_index_plan is not None:
+                if isinstance(global_index_plan, _GlobalIndexPlanningResult):
+                    global_index_result = global_index_plan.indexed_result
+                    row_ranges = Range.sort_and_merge_overlap(
+                        global_index_result.results().to_range_list()
+                        + global_index_plan.unindexed_ranges,
+                        True,
+                    )
+                else:
+                    global_index_result = global_index_plan
+                    row_ranges = global_index_result.results().to_range_list()
+                if isinstance(global_index_result, ScoredGlobalIndexResult):
+                    score_getter = global_index_result.score_getter()
         if row_ranges is None and self.predicate is not None:
             row_ranges = _row_ranges_from_predicate(self.predicate)
 
+        if row_ranges is not None and not row_ranges:
+            return [], DataEvolutionSplitGenerator(
+                self.table,
+                self.target_split_size,
+                self.open_file_cost,
+                {},
+                row_ranges,
+                score_getter,
+            )
+
         # Filter manifest files by row ranges if available
         if row_ranges is not None:
             manifest_files = 
_filter_manifest_files_by_row_ranges(manifest_files, row_ranges)
@@ -463,7 +487,7 @@ class FileScanner:
             self.open_file_cost,
             self._deletion_files_map(entries),
             row_ranges,
-            score_getter
+            score_getter,
         )
 
     def plan_files(self) -> List[ManifestEntry]:
@@ -495,12 +519,19 @@ class FileScanner:
             if scanner is None:
                 return None
             with scanner:
-                result = scanner.scan(self.predicate)
-                if result is None:
+                evaluation = scanner.scan_with_coverage(self.predicate)
+                if evaluation is None:
                     return None
                 scalar_mode = self.table.options.scalar_index_search_mode()
-                return result.or_(
-                    scanner.unindexed_rows(self.predicate, 
search_mode=scalar_mode))
+                return _GlobalIndexPlanningResult(
+                    evaluation.result,
+                    scanner.unindexed_ranges(
+                        self.predicate,
+                        search_mode=scalar_mode,
+                        contributing_field_ids=(
+                            evaluation.contributing_field_ids),
+                    ),
+                )
         except Exception:
             return None
 
@@ -587,9 +618,23 @@ class FileScanner:
         return self
 
     def with_global_index_result(self, result) -> 'FileScanner':
+        if self._row_ranges is not None:
+            raise ValueError(
+                "with_global_index_result and with_row_ranges are mutually 
exclusive")
         self._global_index_result = result
         return self
 
+    def with_row_ranges(self, row_ranges) -> 'FileScanner':
+        if not self.data_evolution:
+            raise ValueError("Row ranges are only supported for data evolution 
tables")
+        if row_ranges is None:
+            raise ValueError("row_ranges cannot be None")
+        if self._global_index_result is not None:
+            raise ValueError(
+                "with_row_ranges and with_global_index_result are mutually 
exclusive")
+        self._row_ranges = Range.sort_and_merge_overlap(list(row_ranges), True)
+        return self
+
     def scan_with_stats(self) -> Tuple[Plan, ScanStats]:
         """Run one scan pass while recording :class:`ScanStats` counters.
 
@@ -620,6 +665,8 @@ class FileScanner:
             raise ValueError("chunk_shuffle cannot combine with limit")
         if self._global_index_result is not None:
             raise ValueError("chunk_shuffle cannot combine with global index")
+        if self._row_ranges is not None:
+            raise ValueError("chunk_shuffle cannot combine with row ranges")
         # Only partition predicates are allowed: row-level / column-level
         # predicates would silently shrink each chunk's effective row count,
         # breaking the chunk_size contract DataLoader callers expect.
diff --git a/paimon-python/pypaimon/read/table_scan.py 
b/paimon-python/pypaimon/read/table_scan.py
index 410cae9a4e..af1a939f2f 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -95,8 +95,9 @@ class TableScan:
 
     def _native_plan_supported_impl(self) -> bool:
         """Fall back to the Python scanner for scans native can't carry:
-        shard/slice, chunk-shuffle, global-index, first-row merge-engine (Rust
-        drops L0), deletion vectors, postpone bucket (drops synthetic buckets),
+        shard/slice, chunk-shuffle, global-index/row-ranges, first-row
+        merge-engine (Rust drops L0), deletion vectors, postpone bucket
+        (drops synthetic buckets),
         a primary-key table whose trimmed PK is empty (PK equals the partition
         key; native may mark splits raw-convertible and skip merge), dynamic
         bucket / cross-partition PK tables (unconfirmed Rust parity), a stale
@@ -113,6 +114,7 @@ class TableScan:
                 or getattr(fs, 'start_pos_of_this_subtask', None) is not None
                 or getattr(fs, 'chunk_shuffle', None) is not None
                 or getattr(fs, '_global_index_result', None) is not None
+                or getattr(fs, '_row_ranges', None) is not None
                 or getattr(fs, 'deletion_vectors_enabled', False)
                 or getattr(fs, 'only_read_real_buckets', False)):
             return False
@@ -363,6 +365,10 @@ class TableScan:
         self.file_scanner.with_global_index_result(result)
         return self
 
+    def with_row_ranges(self, row_ranges) -> 'TableScan':
+        self.file_scanner.with_row_ranges(row_ranges)
+        return self
+
     def with_chunk_shuffle(self, seed: int, chunk_size: int) -> 'TableScan':
         self.file_scanner.with_chunk_shuffle(seed, chunk_size)
         return self
diff --git a/paimon-python/pypaimon/table/source/full_text_read.py 
b/paimon-python/pypaimon/table/source/full_text_read.py
index 46372c5edb..036a6d9d3d 100644
--- a/paimon-python/pypaimon/table/source/full_text_read.py
+++ b/paimon-python/pypaimon/table/source/full_text_read.py
@@ -197,8 +197,7 @@ class DataEvolutionFullTextRead(FullTextRead):
 
         projection = [self._text_columns[0].name, SpecialFields.ROW_ID.name]
         read_builder = read_builder.with_projection(projection)
-        plan = read_builder.new_scan().with_global_index_result(
-            GlobalIndexResult.from_ranges(raw_row_ranges)).plan()
+        plan = read_builder.new_scan().with_row_ranges(raw_row_ranges).plan()
         return read_builder.new_read().to_arrow(plan.splits())
 
     def _build_raw_index(self, row_ids, texts, row_range_start):
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 199dc42c93..46c45f01f3 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -167,8 +167,8 @@ class AbstractVectorSearchReadImpl:
     def _raw_pre_filter(self, splits, snapshot=None):
         if self._filter is None:
             return None
-        raw_rows = _bitmap_of_ranges(_raw_row_ranges(splits))
-        if raw_rows.is_empty():
+        raw_row_ranges = _raw_row_ranges(splits)
+        if not raw_row_ranges:
             return None
 
         seen = set()
@@ -192,17 +192,21 @@ class AbstractVectorSearchReadImpl:
         if scanner is None:
             return None
         try:
-            result = scanner.scan(self._filter)
-            if result is None:
+            evaluation = scanner.scan_with_coverage(self._filter)
+            if evaluation is None:
                 return None
-            include = result.results()
-            include = RoaringBitmap64.or_(
-                include,
-                scanner.unindexed_rows(
+            include_ranges = evaluation.result.results().to_range_list()
+            include_ranges.extend(
+                scanner.unindexed_ranges(
                     self._filter,
                     search_mode=self._table.options.scalar_index_search_mode(),
-                ).results())
-            return RoaringBitmap64.and_(include, raw_rows)
+                    contributing_field_ids=(
+                        evaluation.contributing_field_ids),
+                ))
+            return Range.and_(
+                raw_row_ranges,
+                Range.sort_and_merge_overlap(include_ranges, True),
+            )
         finally:
             scanner.close()
 
@@ -322,8 +326,7 @@ class AbstractVectorSearchReadImpl:
             read_builder = read_builder.with_filter(self._filter)
         read_builder = read_builder.with_projection(
             self._raw_search_projection(include_filter))
-        plan = read_builder.new_scan().with_global_index_result(
-            GlobalIndexResult.from_ranges(raw_row_ranges)).plan()
+        plan = read_builder.new_scan().with_row_ranges(raw_row_ranges).plan()
         return read_builder.new_read().to_arrow(plan.splits())
 
     def _score_raw_vectors(self, candidates, raw_vectors, query_vector, 
metric, top_k):
@@ -639,7 +642,7 @@ def _filtered_raw_row_ranges(raw_row_ranges, pre_filter):
         return raw_row_ranges
     return Range.and_(
         raw_row_ranges,
-        Range.sort_and_merge_overlap(pre_filter.to_range_list(), True),
+        Range.sort_and_merge_overlap(pre_filter, True),
     )
 
 
@@ -678,13 +681,6 @@ def _bitmap_of_range(row_range):
     return bitmap
 
 
-def _bitmap_of_ranges(ranges):
-    bitmap = RoaringBitmap64()
-    for row_range in ranges:
-        bitmap.add_range(row_range.from_, row_range.to)
-    return bitmap
-
-
 def _to_vector_list(value):
     if hasattr(value, "to_list"):
         return value.to_list()
diff --git a/paimon-python/pypaimon/table/source/vector_search_scan.py 
b/paimon-python/pypaimon/table/source/vector_search_scan.py
index 83e381d16b..8c94390b51 100644
--- a/paimon-python/pypaimon/table/source/vector_search_scan.py
+++ b/paimon-python/pypaimon/table/source/vector_search_scan.py
@@ -22,6 +22,9 @@ from collections import defaultdict
 
 from pypaimon.common.options.core_options import GlobalIndexSearchMode
 from pypaimon.globalindex.data_evolution_global_index_coverage import 
DataEvolutionGlobalIndexCoverage
+from pypaimon.globalindex.data_evolution_global_index_scanner import (
+    is_supported_scalar_index,
+)
 from pypaimon.table.source.vector_search_split import (
     IndexVectorSearchSplit,
     RawVectorSearchSplit,
@@ -121,6 +124,8 @@ class DataEvolutionVectorScan(VectorSearchScan):
             field_id = global_index_meta.index_field_id
             if vector_column.id == field_id:
                 return True
+            if not is_supported_scalar_index(entry.index_file):
+                return False
             for filter_field_id in filter_field_ids:
                 if contains_field(global_index_meta, filter_field_id):
                     return True
@@ -154,7 +159,8 @@ class DataEvolutionVectorScan(VectorSearchScan):
             for index_file in all_index_files:
                 meta = index_file.global_index_meta
                 assert meta is not None
-                if meta.index_field_id == vector_column.id:
+                if (meta.index_field_id == vector_column.id
+                        or not is_supported_scalar_index(index_file)):
                     continue
                 scalar_range = Range(meta.row_range_start, meta.row_range_end)
                 if range_key.overlaps(scalar_range):
@@ -182,6 +188,7 @@ class DataEvolutionVectorScan(VectorSearchScan):
             f for f in all_index_files
             if f.global_index_meta is not None
             and f.global_index_meta.index_field_id != vector_column.id
+            and is_supported_scalar_index(f)
         ]
         if self._filter is not None:
             scalar_unindexed_ranges = DataEvolutionGlobalIndexCoverage(
@@ -231,7 +238,9 @@ def _scalar_index_files_for_ranges(all_index_files, 
row_ranges, vector_field_id)
     scalar_files = []
     for index_file in all_index_files:
         meta = index_file.global_index_meta
-        if meta is None or meta.index_field_id == vector_field_id:
+        if (meta is None
+                or meta.index_field_id == vector_field_id
+                or not is_supported_scalar_index(index_file)):
             continue
         if _has_intersection(row_ranges, Range(meta.row_range_start, 
meta.row_range_end)):
             scalar_files.append(index_file)
diff --git a/paimon-python/pypaimon/tests/global_index_evaluator_test.py 
b/paimon-python/pypaimon/tests/global_index_evaluator_test.py
index 7f3b49388a..d979275ec0 100644
--- a/paimon-python/pypaimon/tests/global_index_evaluator_test.py
+++ b/paimon-python/pypaimon/tests/global_index_evaluator_test.py
@@ -173,6 +173,64 @@ class GlobalIndexEvaluatorTest(unittest.TestCase):
         self.assertIsNone(result)
         evaluator.close()
 
+    def test_and_tracks_only_evaluated_fields(self):
+        fields = _make_fields()
+        indexed = GlobalIndexResult.from_range(Range(42, 42))
+
+        evaluator = GlobalIndexEvaluator(
+            fields,
+            lambda field: [StubGlobalIndexReader(indexed)]
+            if field.id == 0 else [],
+        )
+        predicate = Predicate(
+            method='and', index=None, field=None,
+            literals=[
+                Predicate(method='equal', index=0, field='a', literals=[42]),
+                Predicate(method='equal', index=1, field='b', literals=[99]),
+            ],
+        )
+
+        evaluation = evaluator.evaluate_with_contributing_fields(predicate)
+
+        self.assertIsNotNone(evaluation)
+        self.assertEqual(frozenset([0]), evaluation.contributing_field_ids)
+        self.assertEqual([Range(42, 42)],
+                         evaluation.result.results().to_range_list())
+        evaluator.close()
+
+    def test_discarded_or_branch_does_not_contribute_fields(self):
+        fields = _make_fields()
+        indexed = GlobalIndexResult.from_range(Range(42, 42))
+
+        evaluator = GlobalIndexEvaluator(
+            fields,
+            lambda field: [StubGlobalIndexReader(indexed)]
+            if field.id in (0, 2) else [],
+        )
+        predicate = Predicate(
+            method='and', index=None, field=None,
+            literals=[
+                Predicate(
+                    method='or', index=None, field=None,
+                    literals=[
+                        Predicate(method='equal', index=0, field='a',
+                                  literals=[42]),
+                        Predicate(method='equal', index=1, field='b',
+                                  literals=[99]),
+                    ],
+                ),
+                Predicate(method='equal', index=2, field='c', literals=[42]),
+            ],
+        )
+
+        evaluation = evaluator.evaluate_with_contributing_fields(predicate)
+
+        self.assertIsNotNone(evaluation)
+        self.assertEqual(frozenset([2]), evaluation.contributing_field_ids)
+        self.assertEqual([Range(42, 42)],
+                         evaluation.result.results().to_range_list())
+        evaluator.close()
+
     def test_and_with_disjoint_results(self):
         fields = _make_fields()
         result_a = GlobalIndexResult.from_range(Range(1, 3))
diff --git 
a/paimon-python/pypaimon/tests/global_index_scalar_search_mode_test.py 
b/paimon-python/pypaimon/tests/global_index_scalar_search_mode_test.py
index 1985af3287..84d3e94683 100644
--- a/paimon-python/pypaimon/tests/global_index_scalar_search_mode_test.py
+++ b/paimon-python/pypaimon/tests/global_index_scalar_search_mode_test.py
@@ -26,6 +26,7 @@ from 
pypaimon.globalindex.data_evolution_global_index_coverage import (
 from pypaimon.globalindex.data_evolution_global_index_scanner import (
     DataEvolutionGlobalIndexScanner,
 )
+from pypaimon.utils.range import Range
 
 
 def _ranges(result):
@@ -47,6 +48,14 @@ def _coverage(options):
         table, snapshot, None, [SimpleNamespace(global_index_meta=meta)])
 
 
+def _scanner(coverage):
+    scanner = DataEvolutionGlobalIndexScanner.__new__(
+        DataEvolutionGlobalIndexScanner)
+    scanner._coverage = coverage
+    scanner._fields = [1]
+    return scanner
+
+
 class ScalarGlobalIndexSearchModeTest(unittest.TestCase):
 
     def test_default_values(self):
@@ -93,15 +102,13 @@ class ScalarGlobalIndexSearchModeTest(unittest.TestCase):
 
     def test_scanner_applies_passed_scalar_mode(self):
         coverage = _coverage(CoreOptions(Options.from_none()))
-        scanner = SimpleNamespace(_coverage=coverage, _fields=[1])
-        result = DataEvolutionGlobalIndexScanner.unindexed_rows(
-            scanner, None, search_mode=GlobalIndexSearchMode.FULL)
-        self.assertEqual([(100, 199)], _ranges(result))
+        result = _scanner(coverage).unindexed_ranges(
+            None, search_mode=GlobalIndexSearchMode.FULL)
+        self.assertEqual([Range(100, 199)], result)
 
     def test_scanner_default_is_scalar_mode(self):
         coverage = _coverage(CoreOptions(Options.from_none()))
-        scanner = SimpleNamespace(_coverage=coverage, _fields=[1])
-        result = DataEvolutionGlobalIndexScanner.unindexed_rows(scanner, None)
+        result = _scanner(coverage).unindexed_rows(None)
         self.assertEqual([], _ranges(result))
 
 
diff --git a/paimon-python/pypaimon/tests/global_index_test.py 
b/paimon-python/pypaimon/tests/global_index_test.py
index 7959ffecfc..07adeeb25d 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -26,6 +26,7 @@ from pypaimon.common.options.options import Options
 from pypaimon.common.predicate import Predicate
 from pypaimon.common.predicate_builder import PredicateBuilder
 from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.globalindex.global_index_evaluator import GlobalIndexEvaluation
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.index.index_file_meta import IndexFileMeta
 from pypaimon.index.index_file_handler import IndexFileHandler
@@ -35,6 +36,7 @@ from pypaimon.tests.data_evolution_test_helpers import (
     BatchModeMixin,
     DataEvolutionTestBase,
 )
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 from pypaimon.utils.range import Range
 
 
@@ -217,8 +219,11 @@ class 
DataEvolutionGlobalIndexCoverageTest(unittest.TestCase):
 
 class GlobalIndexScalarFallbackTest(unittest.TestCase):
 
-    def 
test_eval_global_index_merges_unindexed_rows_when_index_scan_succeeds(self):
-        from pypaimon.read.scanner.file_scanner import FileScanner
+    def test_eval_global_index_keeps_unindexed_ranges_out_of_bitmap(self):
+        from pypaimon.read.scanner.file_scanner import (
+            FileScanner,
+            _GlobalIndexPlanningResult,
+        )
 
         class _Options:
             def global_index_enabled(self):
@@ -237,24 +242,63 @@ class GlobalIndexScalarFallbackTest(unittest.TestCase):
         scanner.table = _Table()
 
         index_result = GlobalIndexResult.from_range(Range(1, 1))
-        unindexed = GlobalIndexResult.from_range(Range(5, 6))
+        unindexed = [Range(5, 6)]
         fake_scanner = unittest.mock.MagicMock()
-        fake_scanner.scan.return_value = index_result
-        fake_scanner.unindexed_rows.return_value = unindexed
+        fake_scanner.scan_with_coverage.return_value = GlobalIndexEvaluation(
+            index_result, frozenset([0]))
+        fake_scanner.unindexed_ranges.return_value = unindexed
         fake_scanner.__enter__.return_value = fake_scanner
         fake_scanner.__exit__.return_value = None
 
         with unittest.mock.patch(
                 
"pypaimon.globalindex.data_evolution_global_index_scanner.DataEvolutionGlobalIndexScanner.create",
-                return_value=fake_scanner):
+                return_value=fake_scanner), unittest.mock.patch.object(
+                    GlobalIndexResult,
+                    "from_ranges",
+                    side_effect=AssertionError("fallback ranges entered 
bitmap")):
             result = scanner._eval_global_index(snapshot=object())
 
+        self.assertIsInstance(result, _GlobalIndexPlanningResult)
+        self.assertIs(index_result, result.indexed_result)
+        self.assertEqual(unindexed, result.unindexed_ranges)
+        fake_scanner.unindexed_ranges.assert_called_once_with(
+            predicate,
+            search_mode=GlobalIndexSearchMode.FULL,
+            contributing_field_ids=frozenset([0]),
+        )
+
+    def test_split_planning_merges_indexed_and_unindexed_ranges(self):
+        from pypaimon.read.scanner.file_scanner import (
+            FileScanner,
+            _GlobalIndexPlanningResult,
+        )
+
+        scanner = FileScanner.__new__(FileScanner)
+        scanner.manifest_scanner = unittest.mock.MagicMock(
+            return_value=([], unittest.mock.Mock(id=3)))
+        scanner._global_index_result = None
+        scanner._eval_global_index = unittest.mock.MagicMock(
+            return_value=_GlobalIndexPlanningResult(
+                GlobalIndexResult.from_range(Range(1, 1)),
+                [Range(10, 10 ** 12)],
+            ))
+        scanner.predicate = Predicate(
+            method="equal", index=0, field="id", literals=[1])
+        scanner.read_manifest_entries = 
unittest.mock.MagicMock(return_value=[])
+        scanner.table = unittest.mock.Mock()
+        scanner.target_split_size = 1
+        scanner.open_file_cost = 1
+        scanner._deletion_files_map = unittest.mock.MagicMock(return_value={})
+
+        with unittest.mock.patch(
+                
"pypaimon.read.scanner.file_scanner.DataEvolutionSplitGenerator"
+        ) as split_generator:
+            scanner._create_data_evolution_split_generator()
+
         self.assertEqual(
-            [Range(1, 1), Range(5, 6)],
-            result.results().to_range_list(),
+            [Range(1, 1), Range(10, 10 ** 12)],
+            split_generator.call_args[0][4],
         )
-        fake_scanner.unindexed_rows.assert_called_once_with(
-            predicate, search_mode=GlobalIndexSearchMode.FULL)
 
     def test_eval_global_index_keeps_none_as_full_scan(self):
         from pypaimon.read.scanner.file_scanner import FileScanner
@@ -273,7 +317,7 @@ class GlobalIndexScalarFallbackTest(unittest.TestCase):
         scanner.table = _Table()
 
         fake_scanner = unittest.mock.MagicMock()
-        fake_scanner.scan.return_value = None
+        fake_scanner.scan_with_coverage.return_value = None
         fake_scanner.__enter__.return_value = fake_scanner
         fake_scanner.__exit__.return_value = None
 
@@ -295,6 +339,27 @@ class PlanSnapshotFetchRegressionTest(
         'bucket': '-1',
     }
 
+    @pytest.mark.python_plan
+    def test_plan_accepts_row_ranges_without_bitmap(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.table(
+            {'id': [1, 2, 3], 'name': ['a', 'b', 'c'],
+             'age': [10, 20, 30], 'city': ['x', 'y', 'z']},
+            schema=self.pa_schema))
+
+        read_builder = table.new_read_builder()
+        ranges = [Range(0, 10 ** 12)]
+        with patch.object(
+                RoaringBitmap64,
+                'to_range_list',
+                side_effect=AssertionError('row ranges entered a bitmap')):
+            plan = read_builder.new_scan().with_row_ranges(ranges).plan()
+
+        result = read_builder.new_read().to_arrow(plan.splits())
+        self.assertEqual([1, 2, 3], sorted(result.column('id').to_pylist()))
+        self.assertEqual(
+            [], read_builder.new_scan().with_row_ranges([]).plan().splits())
+
     @pytest.mark.python_plan
     def test_plan_fetches_latest_snapshot_only_once(self):
         table = self._create_table()
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py 
b/paimon-python/pypaimon/tests/native_plan_test.py
index b590bb4f90..3abecc22f8 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -64,6 +64,7 @@ def _scan(native_enabled, file_scanner):
     file_scanner.start_pos_of_this_subtask = None  # no slice
     file_scanner.chunk_shuffle = None              # no chunk-shuffle
     file_scanner._global_index_result = None       # no global-index result
+    file_scanner._row_ranges = None                # no explicit row ranges
     file_scanner.deletion_vectors_enabled = False  # no deletion vectors
     file_scanner.data_evolution = False            # no data evolution
     file_scanner.only_read_real_buckets = False    # not postpone bucket
@@ -174,8 +175,8 @@ class NativePlanTest(unittest.TestCase):
         )
 
     def test_plan_falls_back_when_scan_is_not_plain(self):
-        # Native planning does not carry shard/slice, global-index, or
-        # incremental scans -> must fall back to the file scanner.
+        # Native planning does not carry shard/slice, global-index, row ranges,
+        # or incremental scans -> must fall back to the file scanner.
         def check(setup):
             fs = Mock(partition_key_predicate=None)
             sentinel = object()
@@ -191,6 +192,7 @@ class NativePlanTest(unittest.TestCase):
         check(lambda s, fs: setattr(fs, 'start_pos_of_this_subtask', 0))
         check(lambda s, fs: setattr(fs, 'chunk_shuffle', (1, 100)))
         check(lambda s, fs: setattr(fs, '_global_index_result', object()))
+        check(lambda s, fs: setattr(fs, '_row_ranges', [object()]))
         check(lambda s, fs: setattr(fs, 'deletion_vectors_enabled', True))
         check(lambda s, fs: setattr(fs, 'only_read_real_buckets', True))
         check(lambda s, fs: (setattr(s.table, 'is_primary_key_table', True),
diff --git 
a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py 
b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
index 735a0edab0..7bf7c9e927 100644
--- a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
@@ -1123,6 +1123,14 @@ class 
DataEvolutionChunkShuffleEndToEndTest(unittest.TestCase):
         self.assertEqual(actual.column('id').to_pylist(), list(range(200)))
         self.assertEqual(actual.column('payload').to_pylist(), 
self._payloads(range(200)))
 
+    def test_row_ranges_with_chunk_shuffle_rejected(self):
+        table, _ = self._create_de_table('cs_de_row_ranges')
+        scan = table.new_read_builder().new_scan() \
+            .with_row_ranges([Range(0, 0)]) \
+            .with_chunk_shuffle(seed=1, chunk_size=10)
+        with self.assertRaisesRegex(ValueError, "row ranges"):
+            scan.plan()
+
     def test_deterministic_plan_across_calls(self):
         table, pa_schema = self._create_de_table('cs_de_determinism')
         for c in range(3):
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 8cd2d7e6a2..e3bbccc911 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -36,6 +36,7 @@ from pypaimon.common.predicate import Predicate
 from pypaimon.common.predicate_builder import PredicateBuilder
 from pypaimon.globalindex.btree.btree_index_meta import BTreeIndexMeta
 from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta, 
GlobalIndexMeta
+from pypaimon.globalindex.global_index_evaluator import GlobalIndexEvaluation
 from pypaimon.globalindex.global_index_reader import _completed_future
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.globalindex.vector_search import VectorSearch
@@ -169,7 +170,7 @@ def _bitmap(*row_ids):
 
 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."""
+    """Install a fake raw read builder which honors row ranges."""
     import pyarrow as pa
 
     calls = calls if calls is not None else {}
@@ -185,10 +186,9 @@ def _install_raw_vector_read_builder(table, 
vector_column_name, row_id_to_vector
         def __init__(self):
             self._row_ids = []
 
-        def with_global_index_result(self, result):
-            ranges = result.results().to_range_list()
+        def with_row_ranges(self, ranges):
             calls["raw_read_count"] = calls.get("raw_read_count", 0) + 1
-            calls["global_index_ranges"] = ranges
+            calls["global_index_ranges"] = list(ranges)
             self._row_ids = [
                 row_id
                 for row_id in sorted(row_id_to_vector)
@@ -234,7 +234,7 @@ def _install_raw_vector_read_builder(table, 
vector_column_name, row_id_to_vector
 
 def _install_raw_full_text_read_builder(table, text_column_name, 
row_id_to_text,
                                         calls=None):
-    """Install a fake raw read builder which honors GlobalIndexResult 
ranges."""
+    """Install a fake raw read builder which honors row ranges."""
     import pyarrow as pa
 
     calls = calls if calls is not None else {}
@@ -250,9 +250,8 @@ def _install_raw_full_text_read_builder(table, 
text_column_name, row_id_to_text,
         def __init__(self):
             self._row_ids = []
 
-        def with_global_index_result(self, result):
-            ranges = result.results().to_range_list()
-            calls["global_index_ranges"] = ranges
+        def with_row_ranges(self, ranges):
+            calls["global_index_ranges"] = list(ranges)
             self._row_ids = [
                 row_id
                 for row_id in sorted(row_id_to_text)
@@ -993,6 +992,50 @@ class VectorSearchFilterTest(unittest.TestCase):
                          (splits_sorted[1].row_range_start,
                           splits_sorted[1].row_range_end))
 
+    def test_unsupported_scalar_coverage_still_plans_raw_split(self):
+        from pypaimon.table.source.vector_search_split import (
+            IndexVectorSearchSplit,
+            RawVectorSearchSplit,
+        )
+
+        entries = [
+            _entry(None, field_id=1, index_type="lumina-vector-ann",
+                   file_name="vec.index", row_range_start=0, row_range_end=9),
+            _entry(None, field_id=0, index_type="full-text",
+                   file_name="id-ft.index", row_range_start=0, 
row_range_end=9),
+        ]
+        table = _StubTable(
+            fields=[self.id_field, self.embedding_field], entries=entries)
+        table.options = CoreOptions(Options({
+            "scalar-index.search-mode": "full",
+            "vector-index.search-mode": "full",
+        }))
+        self._scan_patch.stop()
+        self._travel_patch.stop()
+        _patch_snapshot(
+            self, entries, types.SimpleNamespace(id=1, next_row_id=10))
+
+        predicate = Predicate(
+            method="equal", index=0, field="id", literals=[5])
+        splits = (
+            VectorSearchBuilderImpl(table)
+            .with_vector_column("embedding")
+            .with_query_vector([1.0, 0.0, 0.0, 0.0])
+            .with_limit(3)
+            .with_filter(predicate)
+            .new_vector_search_scan()
+            .scan()
+            .splits()
+        )
+
+        index = [s for s in splits if isinstance(s, IndexVectorSearchSplit)]
+        raw = [s for s in splits if isinstance(s, RawVectorSearchSplit)]
+        self.assertEqual(1, len(index))
+        self.assertEqual([], index[0].scalar_index_files)
+        self.assertEqual(1, len(raw))
+        self.assertEqual([Range(0, 9)], raw[0].row_ranges)
+        self.assertEqual([], raw[0].scalar_index_files)
+
     def test_read_threads_prefilter_bitmap_as_include_row_ids(self):
         """preFilter bitmap from scanner.scan(filter) must reach each split's
         VectorSearch, offset-rebased to local coords by 
OffsetGlobalIndexReader.
@@ -1588,8 +1631,9 @@ class VectorSearchFilterTest(unittest.TestCase):
             "scalar-index.search-mode": "detail",
         }))
         scanner = mock.MagicMock()
-        scanner.scan.return_value = GlobalIndexResult.create_empty()
-        scanner.unindexed_rows.return_value = GlobalIndexResult.create_empty()
+        scanner.scan_with_coverage.return_value = GlobalIndexEvaluation(
+            GlobalIndexResult.create_empty(), frozenset([0]))
+        scanner.unindexed_ranges.return_value = []
         reader = DataEvolutionVectorRead(
             table,
             limit=3,
@@ -1602,11 +1646,79 @@ class VectorSearchFilterTest(unittest.TestCase):
                 "pypaimon.globalindex.data_evolution_global_index_scanner."
                 "DataEvolutionGlobalIndexScanner.create",
                 return_value=scanner):
-            reader._raw_pre_filter([
+            result = reader._raw_pre_filter([
                 RawVectorSearchSplit([Range(0, 9)], [scalar_file])])
 
-        scanner.unindexed_rows.assert_called_once_with(
-            predicate, search_mode=GlobalIndexSearchMode.DETAIL)
+        self.assertEqual([], result)
+        scanner.unindexed_ranges.assert_called_once_with(
+            predicate,
+            search_mode=GlobalIndexSearchMode.DETAIL,
+            contributing_field_ids=frozenset([0]),
+        )
+
+    def test_raw_vector_pre_filter_keeps_full_fallback_as_ranges(self):
+        from pypaimon.table.source.vector_search_read import 
DataEvolutionVectorRead
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        predicate = Predicate(method="equal", index=0, field="id", 
literals=[5])
+        scalar_file = self.entries[2].index_file
+        table = _StubTable(fields=[self.id_field, self.embedding_field], 
entries=[])
+        table.options = CoreOptions(Options({
+            "scalar-index.search-mode": "full",
+        }))
+        scanner = mock.MagicMock()
+        scanner.scan_with_coverage.return_value = GlobalIndexEvaluation(
+            GlobalIndexResult.from_range(Range(5, 5)), frozenset([0]))
+        scanner.unindexed_ranges.return_value = [Range(10, 10 ** 12)]
+        scanner.unindexed_rows.side_effect = AssertionError(
+            "FULL fallback must not enter a bitmap")
+        reader = DataEvolutionVectorRead(
+            table,
+            limit=3,
+            vector_column=self.embedding_field,
+            query_vector=[1.0, 0.0, 0.0, 0.0],
+            filter_=predicate,
+        )
+
+        with mock.patch(
+                "pypaimon.globalindex.data_evolution_global_index_scanner."
+                "DataEvolutionGlobalIndexScanner.create",
+                return_value=scanner):
+            result = reader._raw_pre_filter([
+                RawVectorSearchSplit([Range(0, 20)], [scalar_file])])
+
+        self.assertEqual([Range(5, 5), Range(10, 20)], result)
+        scanner.unindexed_ranges.assert_called_once_with(
+            predicate,
+            search_mode=GlobalIndexSearchMode.FULL,
+            contributing_field_ids=frozenset([0]),
+        )
+
+    def test_raw_vector_read_passes_ranges_without_bitmap(self):
+        from pypaimon.table.source.vector_search_read import 
DataEvolutionVectorRead
+
+        table = _StubTable(fields=[self.embedding_field], entries=[])
+        calls = _install_raw_vector_read_builder(
+            table,
+            "embedding",
+            {10: [1.0, 0.0, 0.0, 0.0]},
+        )
+        ranges = [Range(10, 10 ** 12)]
+        reader = DataEvolutionVectorRead(
+            table,
+            limit=1,
+            vector_column=self.embedding_field,
+            query_vector=[1.0, 0.0, 0.0, 0.0],
+        )
+
+        with mock.patch.object(
+                GlobalIndexResult,
+                "from_ranges",
+                side_effect=AssertionError("row ranges entered a bitmap")):
+            result = reader._read_raw_arrow(ranges, include_filter=True)
+
+        self.assertEqual(ranges, calls["global_index_ranges"])
+        self.assertEqual(1, result.num_rows)
 
     def test_scan_threads_builder_options_to_raw_split_index_type(self):
         from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
@@ -1729,23 +1841,29 @@ class 
VectorSearchMultiShardScalarTest(unittest.TestCase):
             has_nulls=False)
 
         with mock.patch(
-                
"pypaimon.globalindex.btree.lazy_filtered_btree_reader.BTreeIndexReader",
-                _StubBTreeReader):
+                "pypaimon.globalindex.data_evolution_global_index_scanner."
+                "_exclude_ranges",
+                side_effect=AssertionError("single group must not compute 
padding")):
             with mock.patch(
-                    
"pypaimon.globalindex.sorted_file_global_index_reader.SortedIndexFileMeta.deserialize",
-                    return_value=wide_meta):
-                scanner = DataEvolutionGlobalIndexScanner(
-                    fields=table.fields,
-                    file_io=table.file_io,
-                    index_path="/unused",
-                    index_files=[shard_a, shard_b],
-                )
-                try:
-                    result = scanner.scan(
-                        Predicate(method="equal", index=0, field="id",
-                                  literals=[7]))
-                finally:
-                    scanner.close()
+                    "pypaimon.globalindex.btree.lazy_filtered_btree_reader."
+                    "BTreeIndexReader",
+                    _StubBTreeReader):
+                with mock.patch(
+                        "pypaimon.globalindex.sorted_file_global_index_reader."
+                        "SortedIndexFileMeta.deserialize",
+                        return_value=wide_meta):
+                    scanner = DataEvolutionGlobalIndexScanner(
+                        fields=table.fields,
+                        file_io=table.file_io,
+                        index_path="/unused",
+                        index_files=[shard_a, shard_b],
+                    )
+                    try:
+                        result = scanner.scan(
+                            Predicate(method="equal", index=0, field="id",
+                                      literals=[7]))
+                    finally:
+                        scanner.close()
 
         self.assertIsNotNone(result)
         hits = sorted(list(result.results()))
@@ -1753,6 +1871,134 @@ class 
VectorSearchMultiShardScalarTest(unittest.TestCase):
         # Must not be empty despite shard_a being empty (no short-circuit).
         self.assertEqual([7], hits)
 
+    def test_primary_and_extra_field_indexes_share_coverage(self):
+        from pypaimon.globalindex.global_index_reader import GlobalIndexReader
+        from pypaimon.globalindex.data_evolution_global_index_scanner import (
+            DataEvolutionGlobalIndexScanner,
+        )
+
+        fields = [_field(0, "a"), _field(1, "b"), _field(2, "c")]
+        primary = _entry(None, field_id=2, index_type="btree",
+                         file_name="c-primary.index",
+                         row_range_start=0, row_range_end=4).index_file
+        extra = _entry(None, field_id=0, index_type="btree",
+                       file_name="a-c.index",
+                       row_range_start=5, row_range_end=9).index_file
+        extra.global_index_meta.extra_field_ids = [2]
+        table = _StubTable(fields=fields, entries=[])
+        table.options = CoreOptions(Options({
+            "scalar-index.search-mode": "full",
+        }))
+
+        class _StubReader(GlobalIndexReader):
+            def __init__(self_inner, file_name):
+                self_inner._file_name = file_name
+
+            def visit_equal(self_inner, field_ref, literal):
+                bitmap = RoaringBitmap64()
+                bitmap.add(1 if self_inner._file_name == "c-primary.index" 
else 2)
+                return _completed_future(GlobalIndexResult.create(bitmap))
+
+            def close(self_inner):
+                pass
+
+        def _stub_create_inner_readers(
+                index_type, file_io, index_path, field, io_metas,
+                executor=None, options=None):
+            return [_StubReader(io_meta.file_name) for io_meta in io_metas]
+
+        with mock.patch(
+                "pypaimon.globalindex.data_evolution_global_index_scanner."
+                "_create_inner_readers",
+                side_effect=_stub_create_inner_readers):
+            scanner = DataEvolutionGlobalIndexScanner(
+                fields=fields,
+                file_io=object(),
+                index_path="/unused",
+                index_files=[primary, extra],
+                options=table.options,
+                table=table,
+                snapshot=types.SimpleNamespace(next_row_id=10),
+            )
+            try:
+                evaluation = scanner.scan_with_coverage(
+                    Predicate(method="equal", index=2, field="c",
+                              literals=[42]))
+                fallback = scanner.unindexed_rows(
+                    None,
+                    search_mode=GlobalIndexSearchMode.FULL,
+                    contributing_field_ids=(
+                        evaluation.contributing_field_ids),
+                )
+            finally:
+                scanner.close()
+
+        result = evaluation.result.or_(fallback)
+        self.assertTrue(fallback.results().is_empty())
+        self.assertEqual([1, 7], sorted(result.results()))
+
+    def test_unsupported_extra_field_index_does_not_poison_primary(self):
+        from pypaimon.globalindex.global_index_reader import GlobalIndexReader
+        from pypaimon.globalindex.data_evolution_global_index_scanner import (
+            DataEvolutionGlobalIndexScanner,
+        )
+
+        fields = [_field(0, "a"), _field(1, "b"), _field(2, "c")]
+        primary = _entry(None, field_id=2, index_type="btree",
+                         file_name="c-primary.index",
+                         row_range_start=0, row_range_end=4).index_file
+        unsupported = _entry(None, field_id=0, index_type="es-index",
+                             file_name="a-c.index",
+                             row_range_start=5, row_range_end=9).index_file
+        unsupported.global_index_meta.extra_field_ids = [2]
+        table = _StubTable(fields=fields, entries=[])
+        table.options = CoreOptions(Options({
+            "scalar-index.search-mode": "full",
+        }))
+
+        class _StubReader(GlobalIndexReader):
+            def visit_equal(self_inner, field_ref, literal):
+                bitmap = RoaringBitmap64()
+                bitmap.add(1)
+                return _completed_future(GlobalIndexResult.create(bitmap))
+
+            def close(self_inner):
+                pass
+
+        observed_types = []
+
+        def _stub_create_inner_readers(
+                index_type, file_io, index_path, field, io_metas,
+                executor=None, options=None):
+            observed_types.append(index_type)
+            return [_StubReader()]
+
+        with mock.patch(
+                "pypaimon.globalindex.data_evolution_global_index_scanner."
+                "_create_inner_readers",
+                side_effect=_stub_create_inner_readers):
+            scanner = DataEvolutionGlobalIndexScanner.create(
+                table,
+                index_files=[primary, unsupported],
+                snapshot=types.SimpleNamespace(next_row_id=10),
+            )
+            try:
+                evaluation = scanner.scan_with_coverage(
+                    Predicate(method="equal", index=2, field="c",
+                              literals=[42]))
+                fallback = scanner.unindexed_ranges(
+                    None,
+                    search_mode=GlobalIndexSearchMode.FULL,
+                    contributing_field_ids=(
+                        evaluation.contributing_field_ids),
+                )
+            finally:
+                scanner.close()
+
+        self.assertEqual(["btree"], observed_types)
+        self.assertEqual([1], sorted(evaluation.result.results()))
+        self.assertEqual([Range(5, 9)], fallback)
+
     def test_extra_field_groups_are_padded_before_and(self):
         from pypaimon.globalindex.global_index_reader import GlobalIndexReader
         from pypaimon.globalindex.data_evolution_global_index_scanner import (
@@ -1933,70 +2179,66 @@ class 
VectorSearchMultiShardScalarTest(unittest.TestCase):
 
         self.assertIsNone(result)
 
-    def test_native_fulltext_index_is_dispatched_by_scanner(self):
-        """Non-btree scalar global indexes (full-text, etc.) must be
-        instantiated by DataEvolutionGlobalIndexScanner — previously only 
'btree' was
-        handled and everything else was silently dropped, making text-column
-        pre-filter a no-op."""
-        from pypaimon.globalindex.global_index_result import GlobalIndexResult
+    def test_full_text_index_is_not_scalar_coverage(self):
+        from pypaimon.globalindex.global_index_reader import GlobalIndexReader
         from pypaimon.globalindex.data_evolution_global_index_scanner import (
             DataEvolutionGlobalIndexScanner,
         )
 
-        name_field = _field(0, "name", "STRING")
-        emb_field = _field(1, "embedding", "FLOAT")
-        full_text_shard = _entry(
-            None, field_id=0, index_type="full-text",
-            file_name="name-ft.index",
-            row_range_start=0, row_range_end=9,
-            external_path="oss://bucket/name-ft.index").index_file
-        table = _StubTable(fields=[name_field, emb_field], entries=[])
-
-        captured_ctor_args = []
-        visit_calls = []
-
-        from pypaimon.globalindex.global_index_reader import _completed_future 
as _cf
-
-        class _StubFullTextReader:
-            def __init__(self_inner, file_io, index_path, io_metas):
-                captured_ctor_args.append(
-                    (file_io, index_path, list(io_metas)))
+        field = _field(0, "name", "STRING")
+        btree = _entry(None, field_id=0, index_type="btree",
+                       file_name="name-btree.index",
+                       row_range_start=0, row_range_end=4).index_file
+        full_text = _entry(None, field_id=0, index_type="full-text",
+                           file_name="name-ft.index",
+                           row_range_start=5, row_range_end=9).index_file
+        table = _StubTable(fields=[field], entries=[])
+        table.options = CoreOptions(Options({
+            "scalar-index.search-mode": "full",
+        }))
 
+        class _StubReader(GlobalIndexReader):
             def visit_equal(self_inner, field_ref, literal):
-                visit_calls.append(("equal", literal))
                 bm = RoaringBitmap64()
-                bm.add(4)
-                return _cf(GlobalIndexResult.create(bm))
+                bm.add(1)
+                return _completed_future(GlobalIndexResult.create(bm))
 
             def close(self_inner):
                 pass
 
+        observed_types = []
+
+        def _stub_create_inner_readers(
+                index_type, file_io, index_path, field, io_metas,
+                executor=None, options=None):
+            observed_types.append(index_type)
+            return [_StubReader()]
+
         with mock.patch(
-                
"pypaimon.globalindex.full_text.NativeFullTextGlobalIndexReader",
-                _StubFullTextReader):
-            scanner = DataEvolutionGlobalIndexScanner(
-                fields=table.fields,
-                file_io=table.file_io,
-                index_path="/unused",
-                index_files=[full_text_shard],
+                "pypaimon.globalindex.data_evolution_global_index_scanner."
+                "_create_inner_readers",
+                side_effect=_stub_create_inner_readers):
+            scanner = DataEvolutionGlobalIndexScanner.create(
+                table,
+                index_files=[btree, full_text],
+                snapshot=types.SimpleNamespace(next_row_id=10),
             )
             try:
-                result = scanner.scan(
+                evaluation = scanner.scan_with_coverage(
                     Predicate(method="equal", index=0, field="name",
                               literals=["x"]))
+                fallback = scanner.unindexed_ranges(
+                    None,
+                    search_mode=GlobalIndexSearchMode.FULL,
+                    contributing_field_ids=(
+                        evaluation.contributing_field_ids),
+                )
             finally:
                 scanner.close()
 
-        # Native full-text reader was instantiated (it would NOT be before 
this fix).
-        self.assertEqual(1, len(captured_ctor_args))
-        _, _, io_metas = captured_ctor_args[0]
-        self.assertEqual("oss://bucket/name-ft.index",
-                         io_metas[0].external_path)
-        # visit_equal was dispatched all the way through evaluator → union →
-        # offset → stub native full-text reader.
-        self.assertEqual([("equal", "x")], visit_calls)
-        # Row id 4 is inside [0,9] so offset rebase is a no-op.
-        self.assertEqual([4], sorted(list(result.results())))
+        self.assertEqual(["btree"], observed_types)
+        self.assertEqual([1], sorted(evaluation.result.results()))
+        self.assertEqual([Range(5, 9)], fallback)
 
     def test_like_predicate_is_dispatched_to_reader(self):
         """Evaluator must dispatch ``like`` to reader.visit_like — otherwise
@@ -2725,8 +2967,8 @@ class VectorSearchManySplitsTest(unittest.TestCase):
                 return ["split"]
 
         class _Scan:
-            def with_global_index_result(self_inner, result):
-                calls["global_index_ranges"] = result.results().to_range_list()
+            def with_row_ranges(self_inner, ranges):
+                calls["global_index_ranges"] = list(ranges)
                 return self_inner
 
             def plan(self_inner):

Reply via email to