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 89dab0ef6c [vector] Support raw fallback for vector search (#8302)
89dab0ef6c is described below

commit 89dab0ef6c8ef7db22f4793920123f4f8cc6bd9c
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jun 21 15:10:23 2026 +0800

    [vector] Support raw fallback for vector search (#8302)
    
    Support vector search over unindexed raw rows when
    `global-index.search-mode` is configured as `full` or `detail`, while
    keeping the default `fast` mode index-only. This improves vector search
    freshness after new data is written before the vector global index is
    rebuilt.
---
 .../paimon/globalindex/VectorGlobalIndexer.java    |  17 +-
 .../testvector/TestVectorGlobalIndexer.java        |  21 +-
 .../paimon/globalindex/DataEvolutionBatchScan.java |   2 +-
 .../paimon/globalindex/GlobalIndexCoverage.java    | 146 ++++++++
 .../paimon/globalindex/GlobalIndexScanner.java     | 118 ++----
 .../org/apache/paimon/index/IndexFileHandler.java  |   6 +-
 .../paimon/table/source/AbstractVectorRead.java    | 411 +++++++++++++++++++-
 .../paimon/table/source/BatchVectorRead.java       |   7 +-
 .../paimon/table/source/BatchVectorReadImpl.java   |  56 ++-
 .../table/source/BatchVectorSearchBuilderImpl.java |   5 +-
 ...earchSplit.java => IndexVectorSearchSplit.java} |  13 +-
 ...rSearchSplit.java => RawVectorSearchSplit.java} |  83 ++---
 .../org/apache/paimon/table/source/VectorRead.java |   8 +-
 .../apache/paimon/table/source/VectorReadImpl.java |  42 ++-
 .../apache/paimon/table/source/VectorScanImpl.java | 153 +++++++-
 .../table/source/VectorSearchBuilderImpl.java      |   5 +-
 .../paimon/table/source/VectorSearchSplit.java     | 107 +-----
 .../table/source/snapshot/TimeTravelUtil.java      |   1 +
 .../paimon/table/BtreeGlobalIndexTableTest.java    |  25 ++
 .../table/source/VectorSearchBuilderTest.java      | 412 ++++++++++++++++++++-
 .../lumina/index/LuminaVectorGlobalIndexer.java    |   9 +-
 .../paimon/spark/read/SparkVectorReadImpl.java     | 272 +++++++++++---
 .../spark/read/SparkVectorSearchBuilderImpl.java   |   3 +-
 .../paimon/spark/read/SparkVectorReadImplTest.java | 172 +++++++++
 .../vector/index/NativeVectorGlobalIndexer.java    |  11 +-
 25 files changed, 1692 insertions(+), 413 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/VectorGlobalIndexer.java
similarity index 69%
copy from 
paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
copy to 
paimon-common/src/main/java/org/apache/paimon/globalindex/VectorGlobalIndexer.java
index 74e17e2845..63166ef7c1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/VectorGlobalIndexer.java
@@ -16,18 +16,11 @@
  * limitations under the License.
  */
 
-package org.apache.paimon.table.source;
+package org.apache.paimon.globalindex;
 
-import org.apache.paimon.globalindex.GlobalIndexResult;
+/** A {@link GlobalIndexer} that supports vector similarity search. */
+public interface VectorGlobalIndexer extends GlobalIndexer {
 
-import java.util.List;
-
-/** Vector read to read index files. */
-public interface VectorRead {
-
-    default GlobalIndexResult read(VectorScan.Plan plan) {
-        return read(plan.splits());
-    }
-
-    GlobalIndexResult read(List<VectorSearchSplit> splits);
+    /** Returns the metric name used to convert vector distances to comparable 
scores. */
+    String metric();
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
index cd3a140681..f652719834 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
@@ -21,7 +21,7 @@ package org.apache.paimon.globalindex.testvector;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexWriter;
-import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
 import org.apache.paimon.options.Options;
@@ -32,12 +32,13 @@ import org.apache.paimon.types.FloatType;
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /**
- * A test-only {@link GlobalIndexer} for vector similarity search. Uses 
brute-force linear scan for
- * ANN queries. No native library dependency required.
+ * A test-only {@link VectorGlobalIndexer} for vector similarity search. Uses 
brute-force linear
+ * scan for ANN queries. No native library dependency required.
  *
  * <p>Supported distance metrics (configured via option {@code 
test.vector.metric}):
  *
@@ -47,7 +48,7 @@ import static 
org.apache.paimon.utils.Preconditions.checkArgument;
  *   <li>{@code inner_product} - Inner product similarity (directly used as 
score)
  * </ul>
  */
-public class TestVectorGlobalIndexer implements GlobalIndexer {
+public class TestVectorGlobalIndexer implements VectorGlobalIndexer {
 
     /** Option key for vector dimension. */
     public static final String OPT_DIMENSION = "test.vector.dimension";
@@ -59,6 +60,8 @@ public class TestVectorGlobalIndexer implements GlobalIndexer 
{
 
     public static final String OPT_REQUIRED_OPTION_VALUE = 
"test.vector.required-option.value";
 
+    private static final AtomicInteger METRIC_CALLS = new AtomicInteger();
+
     private final DataType fieldType;
     private final int dimension;
     private final String metric;
@@ -96,7 +99,17 @@ public class TestVectorGlobalIndexer implements 
GlobalIndexer {
         return dimension;
     }
 
+    @Override
     public String metric() {
+        METRIC_CALLS.incrementAndGet();
         return metric;
     }
+
+    public static void resetMetricCalls() {
+        METRIC_CALLS.set(0);
+    }
+
+    public static int metricCalls() {
+        return METRIC_CALLS.get();
+    }
 }
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 b03f27f3d3..76ae6195d5 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
@@ -282,7 +282,7 @@ public class DataEvolutionBatchScan implements 
DataTableScan {
             Optional<GlobalIndexResult> result = scanner.scan(filter);
             if (result.isPresent()) {
                 LOG.info("Scan table '{}' with global index.", table.name());
-                return result;
+                return 
Optional.of(result.get().or(scanner.unindexedRows(filter)));
             }
             return Optional.empty();
         } catch (IOException e) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
new file mode 100644
index 0000000000..6ef52eee2d
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexCoverage.java
@@ -0,0 +1,146 @@
+/*
+ * 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.globalindex;
+
+import org.apache.paimon.CoreOptions.GlobalIndexSearchMode;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.ScanMode;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.predicate.PredicateVisitor.collectFieldIds;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Row ranges covered and not covered by global index files. */
+public class GlobalIndexCoverage {
+
+    private final FileStoreTable table;
+    @Nullable private final Snapshot snapshot;
+    @Nullable private final PartitionPredicate partitionFilter;
+    private final Map<Integer, List<Range>> coverageByField;
+
+    public GlobalIndexCoverage(
+            FileStoreTable table,
+            @Nullable Snapshot snapshot,
+            @Nullable PartitionPredicate partitionFilter,
+            Collection<IndexFileMeta> indexFiles) {
+        this.table = table;
+        this.snapshot = snapshot;
+        this.partitionFilter = partitionFilter;
+        this.coverageByField = new HashMap<>();
+        for (IndexFileMeta indexFile : indexFiles) {
+            GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
+            Range range = new Range(meta.rowRangeStart(), meta.rowRangeEnd());
+            addCoverage(meta.indexFieldId(), range);
+            if (meta.extraFieldIds() != null) {
+                for (int extra : meta.extraFieldIds()) {
+                    addCoverage(extra, range);
+                }
+            }
+        }
+    }
+
+    public List<Range> unindexedRanges(RowType rowType, @Nullable Predicate 
predicate) {
+        return unindexedRanges(collectFieldIds(rowType, predicate));
+    }
+
+    public List<Range> unindexedRanges(int fieldId) {
+        return unindexedRanges(Collections.singleton(fieldId));
+    }
+
+    private void addCoverage(int fieldId, Range range) {
+        coverageByField.computeIfAbsent(fieldId, k -> new 
ArrayList<>()).add(range);
+    }
+
+    private List<Range> indexedRanges(Collection<Integer> fieldIds) {
+        List<Range> ranges = null;
+        for (Integer fieldId : fieldIds) {
+            List<Range> fieldRanges = coverageByField.get(fieldId);
+            if (fieldRanges == null || fieldRanges.isEmpty()) {
+                return Collections.emptyList();
+            }
+            fieldRanges = Range.sortAndMergeOverlap(fieldRanges, true);
+            ranges = ranges == null ? fieldRanges : Range.and(ranges, 
fieldRanges);
+        }
+        return ranges == null ? Collections.emptyList() : 
Range.sortAndMergeOverlap(ranges, true);
+    }
+
+    private List<Range> unindexedRanges(Collection<Integer> fieldIds) {
+        GlobalIndexSearchMode searchMode = 
table.coreOptions().globalIndexSearchMode();
+        if (searchMode == GlobalIndexSearchMode.FAST) {
+            return Collections.emptyList();
+        }
+        if (snapshot == null || snapshot.nextRowId() == null || 
snapshot.nextRowId() <= 0) {
+            return Collections.emptyList();
+        }
+
+        List<Range> dataRanges;
+        if (searchMode == GlobalIndexSearchMode.DETAIL) {
+            dataRanges = dataRangesByDataFiles();
+        } else {
+            dataRanges = Collections.singletonList(new Range(0, 
snapshot.nextRowId() - 1));
+        }
+
+        List<Range> predicateIndexedRanges =
+                Range.sortAndMergeOverlap(indexedRanges(fieldIds), true);
+        List<Range> unindexedRanges = new ArrayList<>();
+        for (Range dataRange : Range.sortAndMergeOverlap(dataRanges, true)) {
+            unindexedRanges.addAll(dataRange.exclude(predicateIndexedRanges));
+        }
+        return Range.sortAndMergeOverlap(unindexedRanges, true);
+    }
+
+    private List<Range> dataRangesByDataFiles() {
+        SnapshotReader snapshotReader =
+                table.newSnapshotReader()
+                        .withPartitionFilter(partitionFilter)
+                        .withMode(ScanMode.ALL)
+                        .withSnapshot(snapshot);
+        List<Range> dataRanges = new ArrayList<>();
+        for (Split split : snapshotReader.read().splits()) {
+            if (!(split instanceof DataSplit)) {
+                continue;
+            }
+            for (DataFileMeta file : ((DataSplit) split).dataFiles()) {
+                if (file.firstRowId() != null) {
+                    dataRanges.add(file.nonNullRowIdRange());
+                }
+            }
+        }
+        return dataRanges;
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
index 93504d317c..7edb68365b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
@@ -18,7 +18,6 @@
 
 package org.apache.paimon.globalindex;
 
-import org.apache.paimon.CoreOptions.GlobalIndexSearchMode;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
@@ -26,22 +25,19 @@ import 
org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
-import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.table.FileStoreTable;
-import org.apache.paimon.table.source.DataSplit;
-import org.apache.paimon.table.source.ScanMode;
-import org.apache.paimon.table.source.Split;
-import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.Filter;
 import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
+import javax.annotation.Nullable;
+
 import java.io.Closeable;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -72,43 +68,33 @@ public class GlobalIndexScanner implements Closeable {
     private final ExecutorService executor;
     private final GlobalIndexEvaluator globalIndexEvaluator;
     private final IndexPathFactory indexPathFactory;
-    private final Map<Integer, List<Range>> coverageByField;
+    private final GlobalIndexCoverage coverage;
     private final FileStoreTable table;
-    private final Snapshot snapshot;
-    private final PartitionPredicate partitionFilter;
 
     private GlobalIndexScanner(
             FileStoreTable table,
-            Snapshot snapshot,
-            PartitionPredicate partitionFilter,
+            @Nullable Snapshot snapshot,
+            @Nullable PartitionPredicate partitionFilter,
             Options options,
             RowType rowType,
             FileIO fileIO,
             IndexPathFactory indexPathFactory,
             Collection<IndexFileMeta> indexFiles) {
         this.table = table;
-        this.snapshot = snapshot;
-        this.partitionFilter = partitionFilter;
         this.options = options;
         this.rowType = rowType;
         this.executor =
                 
GlobalIndexReadThreadPool.getExecutorService(options.get(GLOBAL_INDEX_THREAD_NUM));
         this.indexPathFactory = indexPathFactory;
+        this.coverage = new GlobalIndexCoverage(table, snapshot, 
partitionFilter, indexFiles);
         GlobalIndexFileReader indexFileReader = meta -> 
fileIO.newInputStream(meta.filePath());
         Map<Integer, IndexMetaFileGroup> indexMetas = new HashMap<>();
         Map<Integer, List<IndexMetaFileGroup>> extraIndexMetas = new 
HashMap<>();
-        this.coverageByField = new HashMap<>();
         for (IndexFileMeta indexFile : indexFiles) {
             GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
             String indexType = indexFile.indexType();
             Range range = new Range(meta.rowRangeStart(), meta.rowRangeEnd());
             int indexFieldId = meta.indexFieldId();
-            coverageByField.computeIfAbsent(indexFieldId, k -> new 
ArrayList<>()).add(range);
-            if (meta.extraFieldIds() != null) {
-                for (int extra : meta.extraFieldIds()) {
-                    coverageByField.computeIfAbsent(extra, k -> new 
ArrayList<>()).add(range);
-                }
-            }
             List<Integer> fieldIds = meta.getIndexedFieldIds();
             IndexMetaFileGroup group = indexMetas.get(indexFieldId);
             if (group == null) {
@@ -194,14 +180,21 @@ public class GlobalIndexScanner implements Closeable {
 
     public static Optional<GlobalIndexScanner> create(
             FileStoreTable table, Collection<IndexFileMeta> indexFiles) {
+        return create(table, null, indexFiles);
+    }
+
+    public static Optional<GlobalIndexScanner> create(
+            FileStoreTable table,
+            @Nullable PartitionPredicate partitionFilter,
+            Collection<IndexFileMeta> indexFiles) {
         if (indexFiles.isEmpty()) {
             return Optional.empty();
         }
         return Optional.of(
                 new GlobalIndexScanner(
-                        null,
-                        null,
-                        null,
+                        table,
+                        tryTravelOrLatest(table),
+                        partitionFilter,
                         table.coreOptions().toConfiguration(),
                         table.rowType(),
                         table.fileIO(),
@@ -210,8 +203,10 @@ public class GlobalIndexScanner implements Closeable {
     }
 
     public static Optional<GlobalIndexScanner> create(
-            FileStoreTable table, PartitionPredicate partitionFilter, 
Predicate filter) {
-        Snapshot snapshot = tryTravelOrLatest(table);
+            FileStoreTable table,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter) {
+        @Nullable Snapshot snapshot = tryTravelOrLatest(table);
         List<IndexFileMeta> indexFiles =
                 table.store().newIndexFileHandler()
                         .scan(snapshot, indexFileFilter(table, 
partitionFilter, filter)).stream()
@@ -233,7 +228,9 @@ public class GlobalIndexScanner implements Closeable {
     }
 
     private static Filter<IndexManifestEntry> indexFileFilter(
-            FileStoreTable table, PartitionPredicate partitionFilter, 
Predicate filter) {
+            FileStoreTable table,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter) {
         if (filter == null) {
             return entry -> false;
         }
@@ -265,80 +262,17 @@ public class GlobalIndexScanner implements Closeable {
     }
 
     public Optional<GlobalIndexResult> scan(Predicate predicate) {
-        Optional<GlobalIndexResult> result = 
globalIndexEvaluator.evaluate(predicate);
-        return result.map(indexedResultRows -> withUnindexedRows(predicate, 
indexedResultRows));
+        return globalIndexEvaluator.evaluate(predicate);
     }
 
-    private GlobalIndexResult withUnindexedRows(
-            Predicate predicate, GlobalIndexResult indexedResultRows) {
-        if (indexedResultRows instanceof ScoredGlobalIndexResult
-                || table == null
-                || table.coreOptions().globalIndexSearchMode() == 
GlobalIndexSearchMode.FAST) {
-            return indexedResultRows;
-        }
-
+    public GlobalIndexResult unindexedRows(Predicate predicate) {
         RoaringNavigableMap64 rows = new RoaringNavigableMap64();
-        rows.or(indexedResultRows.results());
-        for (Range range : unindexedRanges(predicate)) {
+        for (Range range : coverage.unindexedRanges(rowType, predicate)) {
             rows.addRange(range);
         }
         return GlobalIndexResult.create(rows);
     }
 
-    private List<Range> indexedRanges(Predicate predicate) {
-        List<Range> ranges = null;
-        for (Integer fieldId : collectFieldIds(rowType, predicate)) {
-            List<Range> fieldRanges = coverageByField.get(fieldId);
-            if (fieldRanges == null || fieldRanges.isEmpty()) {
-                return Collections.emptyList();
-            }
-            fieldRanges = Range.sortAndMergeOverlap(fieldRanges, true);
-            ranges = ranges == null ? fieldRanges : Range.and(ranges, 
fieldRanges);
-        }
-        return ranges == null ? Collections.emptyList() : 
Range.sortAndMergeOverlap(ranges, true);
-    }
-
-    private List<Range> unindexedRanges(Predicate predicate) {
-        if (snapshot == null || snapshot.nextRowId() == null || 
snapshot.nextRowId() <= 0) {
-            return Collections.emptyList();
-        }
-
-        List<Range> dataRanges;
-        if (table.coreOptions().globalIndexSearchMode() == 
GlobalIndexSearchMode.DETAIL) {
-            dataRanges = dataRangesByDataFiles();
-        } else {
-            dataRanges = Collections.singletonList(new Range(0, 
snapshot.nextRowId() - 1));
-        }
-
-        List<Range> predicateIndexedRanges =
-                Range.sortAndMergeOverlap(indexedRanges(predicate), true);
-        List<Range> unindexedRanges = new ArrayList<>();
-        for (Range dataRange : Range.sortAndMergeOverlap(dataRanges, true)) {
-            unindexedRanges.addAll(dataRange.exclude(predicateIndexedRanges));
-        }
-        return Range.sortAndMergeOverlap(unindexedRanges, true);
-    }
-
-    private List<Range> dataRangesByDataFiles() {
-        SnapshotReader snapshotReader =
-                table.newSnapshotReader()
-                        .withPartitionFilter(partitionFilter)
-                        .withMode(ScanMode.ALL)
-                        .withSnapshot(snapshot);
-        List<Range> dataRanges = new ArrayList<>();
-        for (Split split : snapshotReader.read().splits()) {
-            if (!(split instanceof DataSplit)) {
-                continue;
-            }
-            for (DataFileMeta file : ((DataSplit) split).dataFiles()) {
-                if (file.firstRowId() != null) {
-                    dataRanges.add(file.nonNullRowIdRange());
-                }
-            }
-        }
-        return dataRanges;
-    }
-
     private Collection<GlobalIndexReader> createReaders(
             GlobalIndexFileReader indexFileReadWrite,
             IndexMetaFileGroup group,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
index cb9525cc5c..e848ed205e 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
@@ -34,6 +34,8 @@ import org.apache.paimon.utils.IndexFilePathFactories;
 import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.SnapshotManager;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.util.ArrayList;
@@ -96,7 +98,7 @@ public class IndexFileHandler {
         return scan(snapshotManager.latestSnapshot(), indexType);
     }
 
-    public List<IndexManifestEntry> scan(Snapshot snapshot, String indexType) {
+    public List<IndexManifestEntry> scan(@Nullable Snapshot snapshot, String 
indexType) {
         if (snapshot == null) {
             return Collections.emptyList();
         }
@@ -115,7 +117,7 @@ public class IndexFileHandler {
     }
 
     public List<IndexManifestEntry> scan(
-            Snapshot snapshot, Filter<IndexManifestEntry> readTFilter) {
+            @Nullable Snapshot snapshot, Filter<IndexManifestEntry> 
readTFilter) {
         if (snapshot == null) {
             return Collections.emptyList();
         }
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 12bec4fc2f..bb783415d9 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
@@ -18,6 +18,9 @@
 
 package org.apache.paimon.table.source;
 
+import org.apache.paimon.data.InternalArray;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.InternalVector;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexReader;
@@ -27,16 +30,23 @@ import org.apache.paimon.globalindex.GlobalIndexer;
 import org.apache.paimon.globalindex.GlobalIndexerFactoryUtils;
 import org.apache.paimon.globalindex.OffsetGlobalIndexReader;
 import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.VectorSearch;
+import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
 import javax.annotation.Nullable;
@@ -63,18 +73,21 @@ public abstract class AbstractVectorRead implements 
Serializable {
     private static final long serialVersionUID = 1L;
 
     protected final FileStoreTable table;
-    private final Predicate filter;
+    @Nullable private final PartitionPredicate partitionFilter;
+    @Nullable private final Predicate filter;
     protected final int limit;
     protected final DataField vectorColumn;
     protected final Map<String, String> options;
 
     protected AbstractVectorRead(
             FileStoreTable table,
-            Predicate filter,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter,
             int limit,
             DataField vectorColumn,
-            Map<String, String> options) {
+            @Nullable Map<String, String> options) {
         this.table = table;
+        this.partitionFilter = partitionFilter;
         this.filter = filter;
         this.limit = limit;
         this.vectorColumn = vectorColumn;
@@ -84,38 +97,113 @@ public abstract class AbstractVectorRead implements 
Serializable {
                         : Collections.unmodifiableMap(new HashMap<>(options));
     }
 
-    protected GlobalIndexer createGlobalIndexer(List<VectorSearchSplit> 
splits) {
-        IndexFileMeta firstFile = splits.get(0).vectorIndexFiles().get(0);
-        String indexType = firstFile.indexType();
+    protected GlobalIndexer createGlobalIndexer(List<IndexVectorSearchSplit> 
splits) {
+        IndexFileMeta firstFile = firstVectorIndexFile(splits);
         GlobalIndexMeta firstMeta = checkNotNull(firstFile.globalIndexMeta());
-        if (firstMeta.extraFieldIds() != null) {
-            return GlobalIndexerFactoryUtils.load(indexType)
-                    .create(
-                            firstMeta.getIndexField(table.rowType()),
-                            firstMeta.getExtraFields(table.rowType()),
-                            table.coreOptions().toConfiguration());
-        }
+        return createGlobalIndexer(firstFile.indexType(), firstMeta);
+    }
+
+    protected GlobalIndexer createGlobalIndexer(String indexType) {
         return GlobalIndexerFactoryUtils.load(indexType)
                 .create(vectorColumn, table.coreOptions().toConfiguration());
     }
 
-    protected Optional<RoaringNavigableMap64> 
preFilter(List<VectorSearchSplit> splits) {
+    private GlobalIndexer createGlobalIndexer(String indexType, 
GlobalIndexMeta meta) {
+        if (meta.extraFieldIds() == null) {
+            return createGlobalIndexer(indexType);
+        }
+        return GlobalIndexerFactoryUtils.load(indexType)
+                .create(
+                        meta.getIndexField(table.rowType()),
+                        meta.getExtraFields(table.rowType()),
+                        table.coreOptions().toConfiguration());
+    }
+
+    protected List<RoaringNavigableMap64> 
preFilters(List<IndexVectorSearchSplit> splits) {
+        if (filter == null) {
+            return Collections.emptyList();
+        }
+
         Set<IndexFileMeta> scalarIndexFiles =
                 new TreeSet<>(Comparator.comparing(IndexFileMeta::fileName));
-        for (VectorSearchSplit split : splits) {
+        for (IndexVectorSearchSplit split : splits) {
             scalarIndexFiles.addAll(split.scalarIndexFiles());
         }
 
         Optional<GlobalIndexScanner> optionalScanner =
-                GlobalIndexScanner.create(table, scalarIndexFiles);
+                GlobalIndexScanner.create(table, partitionFilter, 
scalarIndexFiles);
+        if (!optionalScanner.isPresent()) {
+            return emptyPreFilters(splits.size());
+        }
+
+        RoaringNavigableMap64 matchedRows;
+        try (GlobalIndexScanner scanner = optionalScanner.get()) {
+            Optional<GlobalIndexResult> result = scanner.scan(filter);
+            if (!result.isPresent()) {
+                return emptyPreFilters(splits.size());
+            }
+            matchedRows = 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
+    protected RoaringNavigableMap64 rawPreFilter(List<RawVectorSearchSplit> 
splits) {
+        if (filter == null) {
+            return null;
+        }
+
+        RoaringNavigableMap64 rawRows = bitmapOf(rawRowRanges(splits));
+        if (rawRows.isEmpty()) {
+            return null;
+        }
+
+        Set<IndexFileMeta> scalarIndexFiles =
+                new TreeSet<>(Comparator.comparing(IndexFileMeta::fileName));
+        for (RawVectorSearchSplit split : splits) {
+            scalarIndexFiles.addAll(split.scalarIndexFiles());
+        }
+        Optional<GlobalIndexScanner> optionalScanner =
+                GlobalIndexScanner.create(table, partitionFilter, 
scalarIndexFiles);
         if (!optionalScanner.isPresent()) {
-            return Optional.empty();
+            return null;
         }
+
+        RoaringNavigableMap64 include = new RoaringNavigableMap64();
         try (GlobalIndexScanner scanner = optionalScanner.get()) {
-            return scanner.scan(filter).map(GlobalIndexResult::results);
+            Optional<GlobalIndexResult> result = scanner.scan(filter);
+            if (!result.isPresent()) {
+                return null;
+            }
+            include.or(result.get().results());
+            include.or(scanner.unindexedRows(filter).results());
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
+
+        include.and(rawRows);
+        return include;
     }
 
     protected CompletableFuture<Optional<ScoredGlobalIndexResult>> eval(
@@ -127,6 +215,10 @@ public abstract class AbstractVectorRead implements 
Serializable {
             float[] vector,
             @Nullable RoaringNavigableMap64 includeRowIds,
             ExecutorService executor) {
+        if (vectorIndexFiles.isEmpty()) {
+            return CompletableFuture.completedFuture(Optional.empty());
+        }
+
         List<GlobalIndexIOMeta> indexIOMetaList =
                 buildIOMetaList(indexPathFactory, vectorIndexFiles);
         @SuppressWarnings("resource")
@@ -151,6 +243,10 @@ public abstract class AbstractVectorRead implements 
Serializable {
             float[][] vectors,
             @Nullable RoaringNavigableMap64 includeRowIds,
             ExecutorService executor) {
+        if (vectorIndexFiles.isEmpty()) {
+            return 
CompletableFuture.completedFuture(emptyOptionalResults(vectors.length));
+        }
+
         List<GlobalIndexIOMeta> indexIOMetaList =
                 buildIOMetaList(indexPathFactory, vectorIndexFiles);
         @SuppressWarnings("resource")
@@ -166,6 +262,34 @@ public abstract class AbstractVectorRead implements 
Serializable {
                 .whenComplete((r, t) -> IOUtils.closeQuietly(reader));
     }
 
+    protected ScoredGlobalIndexResult withRawSearch(
+            ScoredGlobalIndexResult result,
+            List<RawVectorSearchSplit> rawSplits,
+            @Nullable GlobalIndexer globalIndexer,
+            @Nullable RoaringNavigableMap64 preFilter,
+            float[] queryVector) {
+        List<Range> rawRowRanges = rawRowRanges(rawSplits);
+        if (rawRowRanges.isEmpty()) {
+            return result.topK(limit);
+        }
+
+        ScoredGlobalIndexResult rawResult =
+                readRawSearch(
+                        rawRowRanges,
+                        preFilter,
+                        rawSearchIndexer(rawSplits, globalIndexer),
+                        queryVector);
+        return result.or(rawResult).topK(limit);
+    }
+
+    protected ScoredGlobalIndexResult[] emptyScoredResults(int n) {
+        ScoredGlobalIndexResult[] results = new ScoredGlobalIndexResult[n];
+        for (int i = 0; i < n; i++) {
+            results[i] = ScoredGlobalIndexResult.createEmpty();
+        }
+        return results;
+    }
+
     private List<GlobalIndexIOMeta> buildIOMetaList(
             IndexPathFactory indexPathFactory, List<IndexFileMeta> 
vectorIndexFiles) {
         List<GlobalIndexIOMeta> indexIOMetaList = new ArrayList<>();
@@ -179,4 +303,255 @@ public abstract class AbstractVectorRead implements 
Serializable {
         }
         return indexIOMetaList;
     }
+
+    private static RoaringNavigableMap64 bitmapOf(Range range) {
+        RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+        bitmap.addRange(range);
+        return bitmap;
+    }
+
+    private static RoaringNavigableMap64 bitmapOf(List<Range> ranges) {
+        RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+        for (Range range : ranges) {
+            bitmap.addRange(range);
+        }
+        return bitmap;
+    }
+
+    protected ScoredGlobalIndexResult readRawSearch(
+            List<Range> rawRowRanges,
+            @Nullable RoaringNavigableMap64 preFilter,
+            @Nullable GlobalIndexer globalIndexer,
+            float[] queryVector) {
+        return readRawSearch(rawRowRanges, preFilter, 
rawSearchMetric(globalIndexer), queryVector);
+    }
+
+    protected ScoredGlobalIndexResult readRawSearch(
+            List<Range> rawRowRanges,
+            @Nullable RoaringNavigableMap64 preFilter,
+            String metric,
+            float[] queryVector) {
+        RowType readType = SpecialFields.rowTypeWithRowId(table.rowType());
+        if (preFilter != null) {
+            rawRowRanges =
+                    Range.and(
+                            Range.sortAndMergeOverlap(rawRowRanges, true),
+                            Range.sortAndMergeOverlap(preFilter.toRangeList(), 
true));
+        }
+        if (rawRowRanges.isEmpty()) {
+            return ScoredGlobalIndexResult.createEmpty();
+        }
+
+        TableScan.Plan plan =
+                newRawReadBuilder(readType, 
false).withRowRanges(rawRowRanges).newScan().plan();
+        ReadBuilder readBuilder = newRawReadBuilder(readType, true);
+        RoaringNavigableMap64 resultBitmap = new RoaringNavigableMap64();
+        Map<Long, Float> scoreMap = new HashMap<>();
+        int vectorIndex = readType.getFieldIndex(vectorColumn.name());
+        int rowIdIndex = readType.getFieldIndex(SpecialFields.ROW_ID.name());
+
+        try (RecordReader<InternalRow> reader =
+                readBuilder.newRead().executeFilter().createReader(plan)) {
+            reader.forEachRemaining(
+                    row -> {
+                        if (row.isNullAt(vectorIndex)) {
+                            return;
+                        }
+                        float[] stored = getVector(row, vectorIndex);
+                        if (stored.length != queryVector.length) {
+                            throw new IllegalArgumentException(
+                                    String.format(
+                                            "Query vector dimension mismatch: 
expected %d, got %d",
+                                            stored.length, 
queryVector.length));
+                        }
+                        long rowId = row.getLong(rowIdIndex);
+                        resultBitmap.add(rowId);
+                        scoreMap.put(rowId, computeScore(queryVector, stored, 
metric));
+                    });
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to read raw vectors for vector 
search.", e);
+        }
+
+        return ScoredGlobalIndexResult.create(resultBitmap, 
scoreMap::get).topK(limit);
+    }
+
+    private ReadBuilder newRawReadBuilder(RowType readType, boolean 
includeFilter) {
+        ReadBuilder readBuilder = 
table.newReadBuilder().withReadType(readType);
+        if (partitionFilter != null) {
+            readBuilder.withPartitionFilter(partitionFilter);
+        }
+        if (includeFilter && filter != null) {
+            readBuilder.withFilter(filter);
+        }
+        return readBuilder;
+    }
+
+    protected static void splitSearchSplits(
+            List<? extends VectorSearchSplit> splits,
+            List<IndexVectorSearchSplit> indexSplits,
+            List<RawVectorSearchSplit> rawSplits) {
+        for (VectorSearchSplit split : splits) {
+            if (split instanceof IndexVectorSearchSplit) {
+                indexSplits.add((IndexVectorSearchSplit) split);
+            } else if (split instanceof RawVectorSearchSplit) {
+                rawSplits.add((RawVectorSearchSplit) split);
+            }
+        }
+    }
+
+    protected static List<Range> rawRowRanges(List<RawVectorSearchSplit> 
rawSplits) {
+        List<Range> rawRowRanges = new ArrayList<>();
+        for (RawVectorSearchSplit split : rawSplits) {
+            rawRowRanges.addAll(split.rowRanges());
+        }
+        return Range.sortAndMergeOverlap(rawRowRanges, true);
+    }
+
+    @Nullable
+    protected GlobalIndexer rawSearchIndexer(
+            List<RawVectorSearchSplit> rawSplits, @Nullable GlobalIndexer 
globalIndexer) {
+        if (globalIndexer != null) {
+            return globalIndexer;
+        }
+        for (RawVectorSearchSplit split : rawSplits) {
+            String indexType = split.indexType();
+            if (indexType != null) {
+                return createGlobalIndexer(indexType);
+            }
+        }
+        return null;
+    }
+
+    private float[] getVector(InternalRow row, int vectorIndex) {
+        if (vectorColumn.type().getTypeRoot() == DataTypeRoot.VECTOR) {
+            InternalVector vector = row.getVector(vectorIndex);
+            return vector.toFloatArray();
+        } else if (vectorColumn.type().getTypeRoot() == DataTypeRoot.ARRAY) {
+            InternalArray array = row.getArray(vectorIndex);
+            return array.toFloatArray();
+        }
+        throw new IllegalArgumentException(
+                "Unsupported vector column type: " + vectorColumn.type());
+    }
+
+    protected String rawSearchMetric(@Nullable GlobalIndexer globalIndexer) {
+        String metric = null;
+        if (globalIndexer != null) {
+            if (!(globalIndexer instanceof VectorGlobalIndexer)) {
+                throw new IllegalArgumentException(
+                        "Index type '"
+                                + globalIndexer.getClass().getName()
+                                + "' does not provide vector metric for raw 
search.");
+            }
+            metric = ((VectorGlobalIndexer) globalIndexer).metric();
+        }
+        if (metric == null) {
+            metric = configuredRawSearchMetric();
+        }
+        return metric == null ? "l2" : normalizeMetric(metric);
+    }
+
+    @Nullable
+    private String configuredRawSearchMetric() {
+        String metric = configuredRawSearchMetric(options);
+        return metric == null ? configuredRawSearchMetric(table.options()) : 
metric;
+    }
+
+    @Nullable
+    private String configuredRawSearchMetric(Map<String, String> options) {
+        String fieldPrefix = "fields." + vectorColumn.name() + ".";
+        String metric = option(options, fieldPrefix + "distance.metric");
+        if (metric == null) {
+            metric = option(options, fieldPrefix + "metric");
+        }
+        if (metric == null) {
+            metric = option(options, "test.vector.metric");
+        }
+        if (metric == null) {
+            metric = option(options, "lumina.distance.metric");
+        }
+        if (metric == null) {
+            metric = option(options, "distance.metric");
+        }
+        if (metric == null) {
+            metric = option(options, "metric");
+        }
+        if (metric != null) {
+            return metric;
+        }
+
+        for (Map.Entry<String, String> entry : options.entrySet()) {
+            String key = entry.getKey();
+            if (key.endsWith(".distance.metric") || key.endsWith(".metric")) {
+                String value = normalizeMetric(entry.getValue());
+                if (isRawSearchMetric(value)) {
+                    if (metric != null && !metric.equals(value)) {
+                        return null;
+                    }
+                    metric = value;
+                }
+            }
+        }
+        return metric;
+    }
+
+    @Nullable
+    private static String option(Map<String, String> options, String key) {
+        String value = options.get(key);
+        return value == null ? null : normalizeMetric(value);
+    }
+
+    private static boolean isRawSearchMetric(String metric) {
+        return "l2".equals(metric) || "cosine".equals(metric) || 
"inner_product".equals(metric);
+    }
+
+    private static String normalizeMetric(String metric) {
+        return metric.toLowerCase().replace('-', '_');
+    }
+
+    private static IndexFileMeta 
firstVectorIndexFile(List<IndexVectorSearchSplit> splits) {
+        for (IndexVectorSearchSplit split : splits) {
+            if (!split.vectorIndexFiles().isEmpty()) {
+                return split.vectorIndexFiles().get(0);
+            }
+        }
+        throw new IllegalArgumentException("No vector index files found.");
+    }
+
+    private static float computeScore(float[] query, float[] stored, String 
metric) {
+        if ("l2".equals(metric)) {
+            float sumSq = 0;
+            for (int i = 0; i < query.length; i++) {
+                float diff = query[i] - stored[i];
+                sumSq += diff * diff;
+            }
+            return 1.0f / (1.0f + sumSq);
+        } else if ("cosine".equals(metric)) {
+            float dot = 0;
+            float normA = 0;
+            float normB = 0;
+            for (int i = 0; i < query.length; i++) {
+                dot += query[i] * stored[i];
+                normA += query[i] * query[i];
+                normB += stored[i] * stored[i];
+            }
+            float denominator = (float) (Math.sqrt(normA) * Math.sqrt(normB));
+            return denominator == 0 ? 0 : dot / denominator;
+        } else if ("inner_product".equals(metric)) {
+            float dot = 0;
+            for (int i = 0; i < query.length; i++) {
+                dot += query[i] * stored[i];
+            }
+            return dot;
+        }
+        throw new IllegalArgumentException("Unknown vector search metric: " + 
metric);
+    }
+
+    private static List<Optional<ScoredGlobalIndexResult>> 
emptyOptionalResults(int n) {
+        List<Optional<ScoredGlobalIndexResult>> results = new ArrayList<>(n);
+        for (int i = 0; i < n; i++) {
+            results.add(Optional.empty());
+        }
+        return results;
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorRead.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorRead.java
index 7b2169779d..8a33fdc640 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorRead.java
@@ -26,10 +26,5 @@ import java.util.List;
 public interface BatchVectorRead {
 
     /** Read batch results; result {@code i} corresponds to input vector 
{@code i}. */
-    default List<GlobalIndexResult> readBatch(VectorScan.Plan plan) {
-        return readBatch(plan.splits());
-    }
-
-    /** Read batch results; result {@code i} corresponds to input vector 
{@code i}. */
-    List<GlobalIndexResult> readBatch(List<VectorSearchSplit> splits);
+    List<GlobalIndexResult> readBatch(VectorScan.Plan plan);
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorReadImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorReadImpl.java
index 1da9ad554f..2a6fb07f7c 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorReadImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorReadImpl.java
@@ -23,11 +23,14 @@ import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.GlobalIndexer;
 import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
 import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -46,19 +49,27 @@ public class BatchVectorReadImpl extends AbstractVectorRead 
implements BatchVect
 
     public BatchVectorReadImpl(
             FileStoreTable table,
-            Predicate filter,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter,
             int limit,
             DataField vectorColumn,
             float[][] vectors,
-            Map<String, String> options) {
-        super(table, filter, limit, vectorColumn, options);
+            @Nullable Map<String, String> options) {
+        super(table, partitionFilter, filter, limit, vectorColumn, options);
         this.vectors = vectors;
     }
 
     @Override
-    public List<GlobalIndexResult> readBatch(List<VectorSearchSplit> splits) {
+    public List<GlobalIndexResult> readBatch(VectorScan.Plan plan) {
+        return readBatch(plan.splits());
+    }
+
+    private List<GlobalIndexResult> readBatch(List<VectorSearchSplit> splits) {
         int n = vectors.length;
-        if (splits.isEmpty()) {
+        List<IndexVectorSearchSplit> indexSplits = new ArrayList<>();
+        List<RawVectorSearchSplit> rawSplits = new ArrayList<>();
+        splitSearchSplits(splits, indexSplits, rawSplits);
+        if (indexSplits.isEmpty() && rawSplits.isEmpty()) {
             List<GlobalIndexResult> empty = new ArrayList<>(n);
             for (int i = 0; i < n; i++) {
                 empty.add(GlobalIndexResult.createEmpty());
@@ -66,9 +77,28 @@ public class BatchVectorReadImpl extends AbstractVectorRead 
implements BatchVect
             return empty;
         }
 
-        RoaringNavigableMap64 preFilter = preFilter(splits).orElse(null);
+        GlobalIndexer globalIndexer =
+                indexSplits.isEmpty() ? null : 
createGlobalIndexer(indexSplits);
+        ScoredGlobalIndexResult[] indexedResults =
+                indexSplits.isEmpty()
+                        ? emptyScoredResults(n)
+                        : readIndexedBatch(indexSplits, globalIndexer);
+
+        List<GlobalIndexResult> results = new ArrayList<>(n);
+        RoaringNavigableMap64 rawPreFilter = rawPreFilter(rawSplits);
+        for (int i = 0; i < n; i++) {
+            results.add(
+                    withRawSearch(
+                            indexedResults[i], rawSplits, globalIndexer, 
rawPreFilter, vectors[i]));
+        }
+        return results;
+    }
+
+    protected ScoredGlobalIndexResult[] readIndexedBatch(
+            List<IndexVectorSearchSplit> splits, GlobalIndexer globalIndexer) {
+        int n = vectors.length;
+        List<RoaringNavigableMap64> preFilters = preFilters(splits);
 
-        GlobalIndexer globalIndexer = createGlobalIndexer(splits);
         IndexPathFactory indexPathFactory = 
table.store().pathFactory().globalIndexFileFactory();
 
         int parallelism = 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM);
@@ -76,7 +106,8 @@ public class BatchVectorReadImpl extends AbstractVectorRead 
implements BatchVect
 
         List<CompletableFuture<List<Optional<ScoredGlobalIndexResult>>>> 
futures =
                 new ArrayList<>(splits.size());
-        for (VectorSearchSplit split : splits) {
+        for (int i = 0; i < splits.size(); i++) {
+            IndexVectorSearchSplit split = splits.get(i);
             futures.add(
                     evalBatch(
                             globalIndexer,
@@ -85,7 +116,7 @@ public class BatchVectorReadImpl extends AbstractVectorRead 
implements BatchVect
                             split.rowRangeEnd(),
                             split.vectorIndexFiles(),
                             vectors,
-                            preFilter,
+                            preFilters.isEmpty() ? null : preFilters.get(i),
                             executor));
         }
 
@@ -104,11 +135,6 @@ public class BatchVectorReadImpl extends 
AbstractVectorRead implements BatchVect
                 }
             }
         }
-
-        List<GlobalIndexResult> results = new ArrayList<>(n);
-        for (int i = 0; i < n; i++) {
-            results.add(merged[i].topK(limit));
-        }
-        return results;
+        return merged;
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
index 3e8dd877df..8a34691b7a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
@@ -102,7 +102,7 @@ public class BatchVectorSearchBuilderImpl implements 
BatchVectorSearchBuilder {
 
     @Override
     public VectorScan newVectorScan() {
-        return new VectorScanImpl(table, partitionFilter, filter, 
vectorColumn);
+        return new VectorScanImpl(table, partitionFilter, filter, 
vectorColumn, options);
     }
 
     @Override
@@ -114,6 +114,7 @@ public class BatchVectorSearchBuilderImpl implements 
BatchVectorSearchBuilder {
         for (float[] vector : vectors) {
             checkNotNull(vector, "Search vector element cannot be null");
         }
-        return new BatchVectorReadImpl(table, filter, limit, vectorColumn, 
vectors, options);
+        return new BatchVectorReadImpl(
+                table, partitionFilter, filter, limit, vectorColumn, vectors, 
options);
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/IndexVectorSearchSplit.java
similarity index 92%
copy from 
paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
copy to 
paimon-core/src/main/java/org/apache/paimon/table/source/IndexVectorSearchSplit.java
index 032c2be301..355b60d8b7 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/IndexVectorSearchSplit.java
@@ -26,12 +26,11 @@ import org.apache.paimon.io.DataOutputViewStreamWrapper;
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
-import java.io.Serializable;
 import java.util.List;
 import java.util.Objects;
 
-/** Split of vector search. */
-public class VectorSearchSplit implements Serializable {
+/** Split to read vector index files. */
+public class IndexVectorSearchSplit extends VectorSearchSplit {
 
     private static final long serialVersionUID = 1L;
 
@@ -45,7 +44,7 @@ public class VectorSearchSplit implements Serializable {
     private transient List<IndexFileMeta> vectorIndexFiles;
     private transient List<IndexFileMeta> scalarIndexFiles;
 
-    public VectorSearchSplit(
+    public IndexVectorSearchSplit(
             long rowRangeStart,
             long rowRangeEnd,
             List<IndexFileMeta> vectorIndexFiles,
@@ -87,7 +86,7 @@ public class VectorSearchSplit implements Serializable {
         in.defaultReadObject();
         int version = in.readInt();
         if (version != VERSION) {
-            throw new IOException("Unsupported VectorSearchSplit version: " + 
version);
+            throw new IOException("Unsupported IndexVectorSearchSplit version: 
" + version);
         }
         IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
         DataInputViewStreamWrapper view = new DataInputViewStreamWrapper(in);
@@ -102,7 +101,7 @@ public class VectorSearchSplit implements Serializable {
         if (o == null || getClass() != o.getClass()) {
             return false;
         }
-        VectorSearchSplit that = (VectorSearchSplit) o;
+        IndexVectorSearchSplit that = (IndexVectorSearchSplit) o;
         return rowRangeStart == that.rowRangeStart
                 && rowRangeEnd == that.rowRangeEnd
                 && Objects.equals(vectorIndexFiles, that.vectorIndexFiles)
@@ -116,7 +115,7 @@ public class VectorSearchSplit implements Serializable {
 
     @Override
     public String toString() {
-        return "VectorSearchSplit{"
+        return "IndexVectorSearchSplit{"
                 + "rowRangeStart="
                 + rowRangeStart
                 + ", rowRangeEnd="
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/RawVectorSearchSplit.java
similarity index 59%
copy from 
paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
copy to 
paimon-core/src/main/java/org/apache/paimon/table/source/RawVectorSearchSplit.java
index 032c2be301..36221e8ae8 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/RawVectorSearchSplit.java
@@ -22,16 +22,20 @@ import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexFileMetaSerializer;
 import org.apache.paimon.io.DataInputViewStreamWrapper;
 import org.apache.paimon.io.DataOutputViewStreamWrapper;
+import org.apache.paimon.utils.Range;
+
+import javax.annotation.Nullable;
 
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
-import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 
-/** Split of vector search. */
-public class VectorSearchSplit implements Serializable {
+/** Split to scan raw vectors. */
+public class RawVectorSearchSplit extends VectorSearchSplit {
 
     private static final long serialVersionUID = 1L;
 
@@ -40,46 +44,37 @@ public class VectorSearchSplit implements Serializable {
     private static final ThreadLocal<IndexFileMetaSerializer> INDEX_SERIALIZER 
=
             ThreadLocal.withInitial(IndexFileMetaSerializer::new);
 
-    private transient long rowRangeStart;
-    private transient long rowRangeEnd;
-    private transient List<IndexFileMeta> vectorIndexFiles;
+    private final List<Range> rowRanges;
+    @Nullable private final String indexType;
     private transient List<IndexFileMeta> scalarIndexFiles;
 
-    public VectorSearchSplit(
-            long rowRangeStart,
-            long rowRangeEnd,
-            List<IndexFileMeta> vectorIndexFiles,
-            List<IndexFileMeta> scalarIndexFiles) {
-        this.rowRangeStart = rowRangeStart;
-        this.rowRangeEnd = rowRangeEnd;
-        this.vectorIndexFiles = vectorIndexFiles;
-        this.scalarIndexFiles = scalarIndexFiles;
-    }
-
-    public long rowRangeStart() {
-        return rowRangeStart;
+    public RawVectorSearchSplit(
+            List<Range> rowRanges,
+            List<IndexFileMeta> scalarIndexFiles,
+            @Nullable String indexType) {
+        this.rowRanges = Collections.unmodifiableList(new 
ArrayList<>(rowRanges));
+        this.scalarIndexFiles = Collections.unmodifiableList(new 
ArrayList<>(scalarIndexFiles));
+        this.indexType = indexType;
     }
 
-    public long rowRangeEnd() {
-        return rowRangeEnd;
-    }
-
-    public List<IndexFileMeta> vectorIndexFiles() {
-        return vectorIndexFiles;
+    public List<Range> rowRanges() {
+        return rowRanges;
     }
 
     public List<IndexFileMeta> scalarIndexFiles() {
         return scalarIndexFiles;
     }
 
+    @Nullable
+    public String indexType() {
+        return indexType;
+    }
+
     private void writeObject(ObjectOutputStream out) throws IOException {
         out.defaultWriteObject();
         out.writeInt(VERSION);
         IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
         DataOutputViewStreamWrapper view = new 
DataOutputViewStreamWrapper(out);
-        view.writeLong(rowRangeStart);
-        view.writeLong(rowRangeEnd);
-        serializer.serializeList(vectorIndexFiles, view);
         serializer.serializeList(scalarIndexFiles, view);
     }
 
@@ -87,14 +82,12 @@ public class VectorSearchSplit implements Serializable {
         in.defaultReadObject();
         int version = in.readInt();
         if (version != VERSION) {
-            throw new IOException("Unsupported VectorSearchSplit version: " + 
version);
+            throw new IOException("Unsupported RawVectorSearchSplit version: " 
+ version);
         }
         IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
         DataInputViewStreamWrapper view = new DataInputViewStreamWrapper(in);
-        this.rowRangeStart = view.readLong();
-        this.rowRangeEnd = view.readLong();
-        this.vectorIndexFiles = serializer.deserializeList(view);
-        this.scalarIndexFiles = serializer.deserializeList(view);
+        this.scalarIndexFiles =
+                Collections.unmodifiableList(new 
ArrayList<>(serializer.deserializeList(view)));
     }
 
     @Override
@@ -102,29 +95,27 @@ public class VectorSearchSplit implements Serializable {
         if (o == null || getClass() != o.getClass()) {
             return false;
         }
-        VectorSearchSplit that = (VectorSearchSplit) o;
-        return rowRangeStart == that.rowRangeStart
-                && rowRangeEnd == that.rowRangeEnd
-                && Objects.equals(vectorIndexFiles, that.vectorIndexFiles)
-                && Objects.equals(scalarIndexFiles, that.scalarIndexFiles);
+        RawVectorSearchSplit that = (RawVectorSearchSplit) o;
+        return Objects.equals(rowRanges, that.rowRanges)
+                && Objects.equals(scalarIndexFiles, that.scalarIndexFiles)
+                && Objects.equals(indexType, that.indexType);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(rowRangeStart, rowRangeEnd, vectorIndexFiles, 
scalarIndexFiles);
+        return Objects.hash(rowRanges, scalarIndexFiles, indexType);
     }
 
     @Override
     public String toString() {
-        return "VectorSearchSplit{"
-                + "rowRangeStart="
-                + rowRangeStart
-                + ", rowRangeEnd="
-                + rowRangeEnd
-                + ", vectorIndexFiles="
-                + vectorIndexFiles
+        return "RawVectorSearchSplit{"
+                + "rowRanges="
+                + rowRanges
                 + ", scalarIndexFiles="
                 + scalarIndexFiles
+                + ", indexType='"
+                + indexType
+                + '\''
                 + '}';
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
index 74e17e2845..4fc3c7aaf0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
@@ -20,14 +20,8 @@ package org.apache.paimon.table.source;
 
 import org.apache.paimon.globalindex.GlobalIndexResult;
 
-import java.util.List;
-
 /** Vector read to read index files. */
 public interface VectorRead {
 
-    default GlobalIndexResult read(VectorScan.Plan plan) {
-        return read(plan.splits());
-    }
-
-    GlobalIndexResult read(List<VectorSearchSplit> splits);
+    GlobalIndexResult read(VectorScan.Plan plan);
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorReadImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorReadImpl.java
index eab7ab2737..f04a03c064 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorReadImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorReadImpl.java
@@ -23,11 +23,14 @@ import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.GlobalIndexer;
 import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
 import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -46,24 +49,42 @@ public class VectorReadImpl extends AbstractVectorRead 
implements VectorRead {
 
     public VectorReadImpl(
             FileStoreTable table,
-            Predicate filter,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter,
             int limit,
             DataField vectorColumn,
             float[] vector,
-            Map<String, String> options) {
-        super(table, filter, limit, vectorColumn, options);
+            @Nullable Map<String, String> options) {
+        super(table, partitionFilter, filter, limit, vectorColumn, options);
         this.vector = vector;
     }
 
     @Override
-    public GlobalIndexResult read(List<VectorSearchSplit> splits) {
-        if (splits.isEmpty()) {
+    public GlobalIndexResult read(VectorScan.Plan plan) {
+        return readSplits(plan.splits());
+    }
+
+    protected GlobalIndexResult readSplits(List<? extends VectorSearchSplit> 
splits) {
+        List<IndexVectorSearchSplit> indexSplits = new ArrayList<>();
+        List<RawVectorSearchSplit> rawSplits = new ArrayList<>();
+        splitSearchSplits(splits, indexSplits, rawSplits);
+        if (indexSplits.isEmpty() && rawSplits.isEmpty()) {
             return GlobalIndexResult.createEmpty();
         }
 
-        RoaringNavigableMap64 preFilter = preFilter(splits).orElse(null);
+        GlobalIndexer globalIndexer =
+                indexSplits.isEmpty() ? null : 
createGlobalIndexer(indexSplits);
+        ScoredGlobalIndexResult result =
+                indexSplits.isEmpty()
+                        ? ScoredGlobalIndexResult.createEmpty()
+                        : readIndexed(indexSplits, globalIndexer);
+        return withRawSearch(result, rawSplits, globalIndexer, 
rawPreFilter(rawSplits), vector);
+    }
+
+    protected ScoredGlobalIndexResult readIndexed(
+            List<IndexVectorSearchSplit> splits, GlobalIndexer globalIndexer) {
+        List<RoaringNavigableMap64> preFilters = preFilters(splits);
 
-        GlobalIndexer globalIndexer = createGlobalIndexer(splits);
         IndexPathFactory indexPathFactory = 
table.store().pathFactory().globalIndexFileFactory();
 
         int parallelism = 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM);
@@ -71,7 +92,8 @@ public class VectorReadImpl extends AbstractVectorRead 
implements VectorRead {
 
         List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> futures =
                 new ArrayList<>(splits.size());
-        for (VectorSearchSplit split : splits) {
+        for (int i = 0; i < splits.size(); i++) {
+            IndexVectorSearchSplit split = splits.get(i);
             futures.add(
                     eval(
                             globalIndexer,
@@ -80,7 +102,7 @@ public class VectorReadImpl extends AbstractVectorRead 
implements VectorRead {
                             split.rowRangeEnd(),
                             split.vectorIndexFiles(),
                             vector,
-                            preFilter,
+                            preFilters.isEmpty() ? null : preFilters.get(i),
                             executor));
         }
 
@@ -93,6 +115,6 @@ public class VectorReadImpl extends AbstractVectorRead 
implements VectorRead {
                 merged = merged.or(splitResult.get());
             }
         }
-        return merged.topK(limit);
+        return merged;
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorScanImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorScanImpl.java
index 74ae2afe8d..63b2dadea3 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorScanImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorScanImpl.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.table.source;
 
 import org.apache.paimon.Snapshot;
+import org.apache.paimon.globalindex.GlobalIndexCoverage;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileHandler;
 import org.apache.paimon.index.IndexFileMeta;
@@ -31,7 +32,10 @@ import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.Filter;
 import org.apache.paimon.utils.Range;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -39,38 +43,40 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
-import static org.apache.paimon.predicate.PredicateVisitor.collectFieldNames;
+import static org.apache.paimon.predicate.PredicateVisitor.collectFieldIds;
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
 /** Implementation for {@link VectorScan}. */
 public class VectorScanImpl implements VectorScan {
 
     private final FileStoreTable table;
-    private final PartitionPredicate partitionFilter;
-    private final Predicate filter;
+    @Nullable private final PartitionPredicate partitionFilter;
+    @Nullable private final Predicate filter;
     private final DataField vectorColumn;
+    private final Map<String, String> options;
 
     public VectorScanImpl(
             FileStoreTable table,
-            PartitionPredicate partitionFilter,
-            Predicate filter,
-            DataField vectorColumn) {
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter,
+            DataField vectorColumn,
+            @Nullable Map<String, String> options) {
         this.table = table;
         this.partitionFilter = partitionFilter;
         this.filter = filter;
         this.vectorColumn = vectorColumn;
+        this.options =
+                options == null
+                        ? Collections.emptyMap()
+                        : Collections.unmodifiableMap(new HashMap<>(options));
     }
 
     @Override
     public Plan scan() {
         Objects.requireNonNull(vectorColumn, "Vector column must be set");
 
-        Set<Integer> filterFieldIds =
-                collectFieldNames(filter).stream()
-                        .filter(name -> table.rowType().containsField(name))
-                        .map(name -> table.rowType().getField(name).id())
-                        .collect(Collectors.toSet());
-        Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest(table);
+        Set<Integer> filterFieldIds = collectFieldIds(table.rowType(), filter);
+        @Nullable Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest(table);
         IndexFileHandler indexFileHandler = 
table.store().newIndexFileHandler();
         Filter<IndexManifestEntry> indexFileFilter =
                 entry -> {
@@ -96,14 +102,20 @@ public class VectorScanImpl implements VectorScan {
                 indexFileHandler.scan(snapshot, indexFileFilter).stream()
                         .map(IndexManifestEntry::indexFile)
                         .collect(Collectors.toList());
+        String vectorIndexType = vectorIndexType(allIndexFiles);
+        if (vectorIndexType == null) {
+            vectorIndexType = configuredVectorIndexType();
+        }
 
         // Group vector index files by (rowRangeStart, rowRangeEnd)
         Map<Range, List<IndexFileMeta>> vectorByRange = new HashMap<>();
+        List<IndexFileMeta> vectorIndexFiles = new ArrayList<>();
         for (IndexFileMeta indexFile : allIndexFiles) {
             GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
             if (isPrimaryColumn(meta, vectorColumn.id())) {
                 Range range = new Range(meta.rowRangeStart(), 
meta.rowRangeEnd());
                 vectorByRange.computeIfAbsent(range, k -> new 
ArrayList<>()).add(indexFile);
+                vectorIndexFiles.add(indexFile);
             }
         }
 
@@ -124,16 +136,129 @@ public class VectorScanImpl implements VectorScan {
                                         return 
range.hasIntersection(globalIndex.rowRange());
                                     })
                             .collect(Collectors.toList());
-            splits.add(new VectorSearchSplit(range.from, range.to, 
vectorFiles, scalarFiles));
+            splits.add(new IndexVectorSearchSplit(range.from, range.to, 
vectorFiles, scalarFiles));
+        }
+
+        List<Range> rawRowRanges =
+                new GlobalIndexCoverage(table, snapshot, partitionFilter, 
vectorIndexFiles)
+                        .unindexedRanges(vectorColumn.id());
+        if (filter != null) {
+            rawRowRanges =
+                    Range.sortAndMergeOverlap(
+                            addAll(
+                                    rawRowRanges,
+                                    new GlobalIndexCoverage(
+                                                    table,
+                                                    snapshot,
+                                                    partitionFilter,
+                                                    
scalarIndexFiles(allIndexFiles))
+                                            .unindexedRanges(table.rowType(), 
filter)),
+                            true);
+        }
+        if (!rawRowRanges.isEmpty()) {
+            splits.add(
+                    new RawVectorSearchSplit(
+                            rawRowRanges,
+                            scalarIndexFiles(allIndexFiles, rawRowRanges),
+                            vectorIndexType));
         }
 
-        return () -> splits;
+        return new Plan() {
+            @Override
+            public List<VectorSearchSplit> splits() {
+                return splits;
+            }
+        };
     }
 
     private static boolean isPrimaryColumn(GlobalIndexMeta meta, int fieldId) {
         return meta.indexFieldId() == fieldId;
     }
 
+    private List<IndexFileMeta> scalarIndexFiles(List<IndexFileMeta> 
allIndexFiles) {
+        return allIndexFiles.stream()
+                .filter(
+                        f -> {
+                            GlobalIndexMeta globalIndex = 
checkNotNull(f.globalIndexMeta());
+                            return !isPrimaryColumn(globalIndex, 
vectorColumn.id());
+                        })
+                .collect(Collectors.toList());
+    }
+
+    private List<IndexFileMeta> scalarIndexFiles(
+            List<IndexFileMeta> allIndexFiles, List<Range> rowRanges) {
+        return allIndexFiles.stream()
+                .filter(
+                        f -> {
+                            GlobalIndexMeta globalIndex = 
checkNotNull(f.globalIndexMeta());
+                            if (isPrimaryColumn(globalIndex, 
vectorColumn.id())) {
+                                return false;
+                            }
+                            return hasIntersection(rowRanges, 
globalIndex.rowRange());
+                        })
+                .collect(Collectors.toList());
+    }
+
+    private static boolean hasIntersection(List<Range> ranges, Range range) {
+        for (Range r : ranges) {
+            if (r.hasIntersection(range)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static List<Range> addAll(List<Range> left, List<Range> right) {
+        List<Range> result = new ArrayList<>(left.size() + right.size());
+        result.addAll(left);
+        result.addAll(right);
+        return result;
+    }
+
+    @Nullable
+    private String vectorIndexType(List<IndexFileMeta> indexFiles) {
+        String indexType = null;
+        for (IndexFileMeta indexFile : indexFiles) {
+            GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
+            if (!isPrimaryColumn(meta, vectorColumn.id())) {
+                continue;
+            }
+            if (indexType == null) {
+                indexType = indexFile.indexType();
+            } else if (!indexType.equals(indexFile.indexType())) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Vector column '%s' has multiple index types: 
%s and %s.",
+                                vectorColumn.name(), indexType, 
indexFile.indexType()));
+            }
+        }
+        return indexType;
+    }
+
+    @Nullable
+    private String configuredVectorIndexType() {
+        String indexType = option("index_type");
+        if (indexType == null) {
+            indexType = option("index-type");
+        }
+        if (indexType == null) {
+            indexType = option("vector.index-type");
+        }
+        if (indexType == null) {
+            indexType = option("fields." + vectorColumn.name() + 
".index-type");
+        }
+        return indexType;
+    }
+
+    @Nullable
+    private String option(String key) {
+        String value = options.get(key);
+        if (value == null) {
+            value = table.options().get(key);
+        }
+        return value == null ? null : value.toLowerCase().trim();
+    }
+
     private static boolean containsField(GlobalIndexMeta meta, int fieldId) {
         if (meta.indexFieldId() == fieldId) {
             return true;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
index 0ca9612436..996f8b4f19 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
@@ -101,12 +101,13 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
 
     @Override
     public VectorScan newVectorScan() {
-        return new VectorScanImpl(table, partitionFilter, filter, 
vectorColumn);
+        return new VectorScanImpl(table, partitionFilter, filter, 
vectorColumn, options);
     }
 
     @Override
     public VectorRead newVectorRead() {
         checkNotNull(vector, "vector must be set via withVector()");
-        return new VectorReadImpl(table, filter, limit, vectorColumn, vector, 
options);
+        return new VectorReadImpl(
+                table, partitionFilter, filter, limit, vectorColumn, vector, 
options);
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
index 032c2be301..82c813b9f2 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchSplit.java
@@ -18,113 +18,10 @@
 
 package org.apache.paimon.table.source;
 
-import org.apache.paimon.index.IndexFileMeta;
-import org.apache.paimon.index.IndexFileMetaSerializer;
-import org.apache.paimon.io.DataInputViewStreamWrapper;
-import org.apache.paimon.io.DataOutputViewStreamWrapper;
-
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
 import java.io.Serializable;
-import java.util.List;
-import java.util.Objects;
 
-/** Split of vector search. */
-public class VectorSearchSplit implements Serializable {
+/** Base split of vector search. */
+public abstract class VectorSearchSplit implements Serializable {
 
     private static final long serialVersionUID = 1L;
-
-    private static final int VERSION = 1;
-
-    private static final ThreadLocal<IndexFileMetaSerializer> INDEX_SERIALIZER 
=
-            ThreadLocal.withInitial(IndexFileMetaSerializer::new);
-
-    private transient long rowRangeStart;
-    private transient long rowRangeEnd;
-    private transient List<IndexFileMeta> vectorIndexFiles;
-    private transient List<IndexFileMeta> scalarIndexFiles;
-
-    public VectorSearchSplit(
-            long rowRangeStart,
-            long rowRangeEnd,
-            List<IndexFileMeta> vectorIndexFiles,
-            List<IndexFileMeta> scalarIndexFiles) {
-        this.rowRangeStart = rowRangeStart;
-        this.rowRangeEnd = rowRangeEnd;
-        this.vectorIndexFiles = vectorIndexFiles;
-        this.scalarIndexFiles = scalarIndexFiles;
-    }
-
-    public long rowRangeStart() {
-        return rowRangeStart;
-    }
-
-    public long rowRangeEnd() {
-        return rowRangeEnd;
-    }
-
-    public List<IndexFileMeta> vectorIndexFiles() {
-        return vectorIndexFiles;
-    }
-
-    public List<IndexFileMeta> scalarIndexFiles() {
-        return scalarIndexFiles;
-    }
-
-    private void writeObject(ObjectOutputStream out) throws IOException {
-        out.defaultWriteObject();
-        out.writeInt(VERSION);
-        IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
-        DataOutputViewStreamWrapper view = new 
DataOutputViewStreamWrapper(out);
-        view.writeLong(rowRangeStart);
-        view.writeLong(rowRangeEnd);
-        serializer.serializeList(vectorIndexFiles, view);
-        serializer.serializeList(scalarIndexFiles, view);
-    }
-
-    private void readObject(ObjectInputStream in) throws IOException, 
ClassNotFoundException {
-        in.defaultReadObject();
-        int version = in.readInt();
-        if (version != VERSION) {
-            throw new IOException("Unsupported VectorSearchSplit version: " + 
version);
-        }
-        IndexFileMetaSerializer serializer = INDEX_SERIALIZER.get();
-        DataInputViewStreamWrapper view = new DataInputViewStreamWrapper(in);
-        this.rowRangeStart = view.readLong();
-        this.rowRangeEnd = view.readLong();
-        this.vectorIndexFiles = serializer.deserializeList(view);
-        this.scalarIndexFiles = serializer.deserializeList(view);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (o == null || getClass() != o.getClass()) {
-            return false;
-        }
-        VectorSearchSplit that = (VectorSearchSplit) o;
-        return rowRangeStart == that.rowRangeStart
-                && rowRangeEnd == that.rowRangeEnd
-                && Objects.equals(vectorIndexFiles, that.vectorIndexFiles)
-                && Objects.equals(scalarIndexFiles, that.scalarIndexFiles);
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(rowRangeStart, rowRangeEnd, vectorIndexFiles, 
scalarIndexFiles);
-    }
-
-    @Override
-    public String toString() {
-        return "VectorSearchSplit{"
-                + "rowRangeStart="
-                + rowRangeStart
-                + ", rowRangeEnd="
-                + rowRangeEnd
-                + ", vectorIndexFiles="
-                + vectorIndexFiles
-                + ", scalarIndexFiles="
-                + scalarIndexFiles
-                + '}';
-    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/TimeTravelUtil.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/TimeTravelUtil.java
index 28eceb69ce..786fecd0cb 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/TimeTravelUtil.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/TimeTravelUtil.java
@@ -65,6 +65,7 @@ public class TimeTravelUtil {
         SCAN_TIMESTAMP_MILLIS.key()
     };
 
+    @Nullable
     public static Snapshot tryTravelOrLatest(FileStoreTable table) {
         return tryTravelToSnapshot(table).orElseGet(() -> 
table.latestSnapshot().orElse(null));
     }
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 8d963b11e9..0723ee5239 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
@@ -168,6 +168,31 @@ public class BtreeGlobalIndexTableTest extends 
DataEvolutionTestBase {
                 .containsExactly("a700");
     }
 
+    @Test
+    public void testGlobalIndexScannerKeepsUnindexedRowsSeparate() throws 
Exception {
+        write(500L);
+        createIndex("f1");
+        appendRows(500, 1000);
+
+        FileStoreTable table =
+                tableWithSearchMode((FileStoreTable) 
catalog.getTable(identifier()), "full");
+        Predicate predicate =
+                new PredicateBuilder(table.rowType())
+                        .in(
+                                1,
+                                Arrays.asList(
+                                        BinaryString.fromString("a100"),
+                                        BinaryString.fromString("a700")));
+
+        try (GlobalIndexScanner scanner =
+                GlobalIndexScanner.create(table, 
PartitionPredicate.ALWAYS_TRUE, predicate).get()) {
+            assertThat(scanner.scan(predicate).get().results().toRangeList())
+                    .containsExactly(new Range(100L, 100L));
+            
assertThat(scanner.unindexedRows(predicate).results().toRangeList())
+                    .containsExactly(new Range(500L, 999L));
+        }
+    }
+
     @Test
     public void testBTreeGlobalIndexSearchModeUsesAllPredicateFieldCoverage() 
throws Exception {
         write(500L);
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 522d9fa732..e776dd8314 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
@@ -30,14 +30,21 @@ import 
org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
 import org.apache.paimon.globalindex.ResultEntry;
 import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
 import org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
+import org.apache.paimon.globalindex.testvector.TestVectorGlobalIndexer;
 import org.apache.paimon.globalindex.testvector.TestVectorGlobalIndexerFactory;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataIncrement;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.FieldTransform;
+import org.apache.paimon.predicate.GreaterOrEqual;
+import org.apache.paimon.predicate.LeafFunction;
+import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.Transform;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.table.FileStoreTable;
@@ -240,6 +247,181 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
         assertThat(result.results().isEmpty()).isTrue();
     }
 
+    @Test
+    public void testFullModeRawOnlyUsesConfiguredMetric() throws Exception {
+        catalog.createTable(
+                identifier("full_search_raw_only_cosine_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .option("test.vector.metric", "cosine")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("full_search_raw_only_cosine_table"));
+
+        float[][] vectors = {{100.0f, 0.0f}, {0.9f, 0.1f}};
+        writeVectors(table, vectors);
+
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {1.0f, 0.0f})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeLocal();
+
+        assertThat(result.results()).containsExactly(0L);
+    }
+
+    @Test
+    public void testVectorSearchFullModeScansUnindexedData() throws Exception {
+        catalog.createTable(
+                identifier("full_search_cosine_table"),
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column(VECTOR_FIELD_NAME, new 
ArrayType(DataTypes.FLOAT()))
+                        .option(CoreOptions.BUCKET.key(), "-1")
+                        .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+                        .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), 
"true")
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .option("test.vector.dimension", 
String.valueOf(DIMENSION))
+                        .option("test.vector.metric", "cosine")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("full_search_cosine_table"));
+
+        float[][] vectors = {
+            {0.0f, 1.0f},
+            {0.1f, 0.9f},
+            {0.5f, 0.0f},
+            {0.99f, 0.01f}
+        };
+        writeVectors(table, vectors);
+
+        buildAndCommitVectorIndex(table, new float[][] {vectors[0], 
vectors[1]}, new Range(0, 1));
+
+        TestVectorGlobalIndexer.resetMetricCalls();
+        VectorScan.Plan plan =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {1.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .newVectorScan()
+                        .scan();
+        assertThat(indexVectorSearchSplits(plan.splits())).hasSize(1);
+        assertThat(rawVectorSearchSplits(plan.splits())).hasSize(1);
+        assertThat(rawVectorSearchSplits(plan.splits()).get(0).rowRanges())
+                .containsExactly(new Range(2, 3));
+
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {1.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeLocal();
+
+        assertThat(result.results()).containsExactlyInAnyOrder(2L, 3L);
+        assertThat(TestVectorGlobalIndexer.metricCalls()).isGreaterThan(0);
+    }
+
+    @Test
+    public void testVectorSearchFastModeSkipsUnindexedDataByDefault() throws 
Exception {
+        catalog.createTable(
+                identifier("fast_search_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME).build(),
+                false);
+        FileStoreTable table = getTable(identifier("fast_search_table"));
+
+        float[][] vectors = {
+            {0.0f, 1.0f},
+            {0.1f, 0.9f},
+            {1.0f, 0.0f},
+            {0.95f, 0.05f}
+        };
+        writeVectors(table, vectors);
+
+        buildAndCommitVectorIndex(table, new float[][] {vectors[0], 
vectors[1]}, new Range(0, 1));
+
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {1.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeLocal();
+
+        assertThat(result.results()).doesNotContain(2L, 3L);
+    }
+
+    @Test
+    public void testVectorSearchFullModeScansFilteredUnindexedData() throws 
Exception {
+        catalog.createTable(
+                identifier("full_search_filtered_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("full_search_filtered_table"));
+
+        float[][] vectors = {
+            {0.0f, 1.0f},
+            {0.1f, 0.9f},
+            {1.0f, 0.0f},
+            {0.95f, 0.05f}
+        };
+        writeVectors(table, vectors);
+
+        Range indexedRange = new Range(0, 1);
+        buildAndCommitVectorIndex(table, new float[][] {vectors[0], 
vectors[1]}, indexedRange);
+        buildAndCommitBTreeIndex(table, new int[] {0, 1}, indexedRange);
+
+        Predicate filter = new 
PredicateBuilder(table.rowType()).greaterOrEqual(0, 2);
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {1.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .withFilter(filter)
+                        .executeLocal();
+
+        assertThat(result).isInstanceOf(ScoredGlobalIndexResult.class);
+        assertThat(result.results()).containsExactlyInAnyOrder(2L, 3L);
+    }
+
+    @Test
+    public void testVectorSearchRawSearchUsesScalarPreFilter() throws 
Exception {
+        catalog.createTable(
+                identifier("raw_search_scalar_prefilter_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("raw_search_scalar_prefilter_table"));
+
+        float[][] vectors = {
+            {100.0f, 0.0f},
+            {200.0f, 0.0f},
+            {1.0f, 0.0f},
+            {2.0f, 0.0f}
+        };
+        writeVectors(table, vectors);
+
+        buildAndCommitVectorIndex(table, new float[][] {vectors[0], 
vectors[1]}, new Range(0, 1));
+        buildAndCommitBTreeIndex(table, new int[] {2, 3}, new Range(2, 3));
+
+        // Let scalar global index evaluate id >= 3, but let the final raw row 
filter pass through.
+        // Without raw pre-filtering, raw topK would pick row 2 because it is 
closer to the query.
+        Predicate filter = globalIndexOnlyIdGreaterOrEqual(table, 3);
+        VectorSearchBuilder builder =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {1.0f, 0.0f})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .withFilter(filter);
+        VectorScan.Plan plan = builder.newVectorScan().scan();
+        
assertThat(rawVectorSearchSplits(plan.splits()).get(0).scalarIndexFiles()).isNotEmpty();
+
+        GlobalIndexResult result = builder.newVectorRead().read(plan);
+        assertThat(result.results()).containsExactly(3L);
+    }
+
     @Test
     public void testVectorSearchTopKLimit() throws Exception {
         createTableDefault();
@@ -431,8 +613,13 @@ public class VectorSearchBuilderTest extends TableTestBase 
{
 
     @Test
     public void testScanPartialRangeIntersection() throws Exception {
-        createTableDefault();
-        FileStoreTable table = getTableDefault();
+        catalog.createTable(
+                identifier("full_search_partial_scalar_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("full_search_partial_scalar_table"));
 
         // Write 10 rows
         float[][] allVectors = new float[10][];
@@ -458,15 +645,19 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
                         .newVectorScan()
                         .scan();
 
-        assertThat(plan.splits()).hasSize(1);
-        VectorSearchSplit split = plan.splits().get(0);
+        assertThat(indexVectorSearchSplits(plan.splits())).hasSize(1);
+        assertThat(rawVectorSearchSplits(plan.splits())).hasSize(1);
+        IndexVectorSearchSplit split = 
indexVectorSearchSplits(plan.splits()).get(0);
         assertThat(split.rowRangeStart()).isEqualTo(0);
         assertThat(split.rowRangeEnd()).isEqualTo(9);
         assertThat(split.vectorIndexFiles()).isNotEmpty();
-        // Scalar index [3,7] intersects vector range [0,9] → attached
+        // Scalar index [3,7] intersects vector range [0,9], so it is attached.
         assertThat(split.scalarIndexFiles()).isNotEmpty();
+        assertThat(rawVectorSearchSplits(plan.splits()).get(0).rowRanges())
+                .containsExactly(new Range(0, 2), new Range(8, 9));
 
-        // Read with pre-filter: id >= 5, btree covers [3,7] so rows 5,6,7 
from btree
+        // Read with pre-filter: btree covers [3,7], while rows outside that 
scalar index coverage
+        // are searched through raw splits and filtered by the raw table read.
         GlobalIndexResult result =
                 table.newVectorSearchBuilder()
                         .withVector(new float[] {1.0f, 0.0f})
@@ -478,10 +669,9 @@ public class VectorSearchBuilderTest extends TableTestBase 
{
 
         assertThat(result).isInstanceOf(ScoredGlobalIndexResult.class);
         assertThat(result.results().isEmpty()).isFalse();
-        // Pre-filter restricts to rows matching id >= 5 from btree [3,7]
-        for (long rowId : result.results()) {
-            assertThat(rowId).isBetween(5L, 7L);
-        }
+
+        assertThat(result.results()).containsAnyOf(5L, 6L, 7L);
+        assertThat(result.results()).doesNotContain(0L, 1L, 2L);
     }
 
     @Test
@@ -513,13 +703,119 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
         VectorScan.Plan plan = searchBuilder.newVectorScan().scan();
         assertThat(plan.splits()).hasSize(1);
         // Scalar index is attached since field matches filter
-        assertThat(plan.splits().get(0).scalarIndexFiles()).isNotEmpty();
+        assertThat(((IndexVectorSearchSplit) 
plan.splits().get(0)).scalarIndexFiles()).isNotEmpty();
 
-        // Read: preFilter returns empty bitmap → vector search returns no 
results
+        // Read: preFilter returns empty bitmap, so vector search returns no 
results.
         GlobalIndexResult result = searchBuilder.newVectorRead().read(plan);
         assertThat(result.results().isEmpty()).isTrue();
     }
 
+    @Test
+    public void testPartialScalarPreFilterMustNotDropUnindexedScalarRows() 
throws Exception {
+        catalog.createTable(
+                identifier("full_search_partial_scalar_unindexed_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("full_search_partial_scalar_unindexed_table"));
+
+        float[][] vectors = new float[10][];
+        for (int i = 0; i < vectors.length; i++) {
+            vectors[i] = new float[] {Math.abs(i - 8), 0.0f};
+        }
+        writeVectors(table, vectors);
+
+        buildAndCommitVectorIndex(table, vectors, new Range(0, 9));
+        buildAndCommitBTreeIndex(table, new int[] {3, 4, 5, 6, 7}, new 
Range(3, 7));
+
+        Predicate idFilter = new 
PredicateBuilder(table.rowType()).greaterOrEqual(0, 8);
+        VectorSearchBuilder searchBuilder =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .withFilter(idFilter);
+
+        VectorScan.Plan vectorPlan = searchBuilder.newVectorScan().scan();
+        GlobalIndexResult result = 
searchBuilder.newVectorRead().read(vectorPlan);
+        assertThat(result.results()).contains(8L);
+
+        ReadBuilder readBuilder = table.newReadBuilder().withFilter(idFilter);
+        TableScan.Plan readPlan = 
readBuilder.newScan().withGlobalIndexResult(result).plan();
+        List<Integer> ids = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(readPlan)) {
+            reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+        }
+        assertThat(ids).containsExactly(8);
+    }
+
+    @Test
+    public void 
testFullModeFilterWithoutScalarIndexMustNotLetVectorIndexPolluteTopK()
+            throws Exception {
+        catalog.createTable(
+                identifier("full_search_no_scalar_index_filter_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = 
getTable(identifier("full_search_no_scalar_index_filter_table"));
+
+        float[][] vectors = new float[10][];
+        for (int i = 0; i < vectors.length; i++) {
+            vectors[i] = new float[] {Math.abs(i), 0.0f};
+        }
+        writeVectors(table, vectors);
+
+        buildAndCommitVectorIndex(table, vectors, new Range(0, 9));
+
+        Predicate idFilter = new 
PredicateBuilder(table.rowType()).greaterOrEqual(0, 8);
+        VectorSearchBuilder searchBuilder =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .withFilter(idFilter);
+
+        GlobalIndexResult result =
+                
searchBuilder.newVectorRead().read(searchBuilder.newVectorScan().scan());
+
+        ReadBuilder readBuilder = table.newReadBuilder().withFilter(idFilter);
+        TableScan.Plan readPlan = 
readBuilder.newScan().withGlobalIndexResult(result).plan();
+        List<Integer> ids = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(readPlan)) {
+            reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+        }
+        assertThat(ids).containsExactly(8);
+    }
+
+    @Test
+    public void testFastModePartialScalarPreFilterOnlyUsesIndexedRows() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+
+        float[][] vectors = new float[10][];
+        for (int i = 0; i < vectors.length; i++) {
+            vectors[i] = new float[] {Math.abs(i - 8), 0.0f};
+        }
+        writeVectors(table, vectors);
+
+        buildAndCommitVectorIndex(table, vectors, new Range(0, 9));
+        buildAndCommitBTreeIndex(table, new int[] {3, 4, 5, 6, 7}, new 
Range(3, 7));
+
+        Predicate idFilter = new 
PredicateBuilder(table.rowType()).greaterOrEqual(0, 8);
+        VectorSearchBuilder searchBuilder =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .withFilter(idFilter);
+
+        GlobalIndexResult result =
+                
searchBuilder.newVectorRead().read(searchBuilder.newVectorScan().scan());
+        assertThat(result.results().isEmpty()).isTrue();
+    }
+
     @Test
     public void testVectorSearchRequiresVectorColumnAsPrimaryField() throws 
Exception {
         createTableDefault();
@@ -567,7 +863,7 @@ public class VectorSearchBuilderTest extends TableTestBase {
                         .scan();
 
         assertThat(plan.splits()).hasSize(1);
-        VectorSearchSplit original = plan.splits().get(0);
+        IndexVectorSearchSplit original = (IndexVectorSearchSplit) 
plan.splits().get(0);
 
         // Serialize
         ByteArrayOutputStream bos = new ByteArrayOutputStream();
@@ -576,10 +872,10 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
         }
 
         // Deserialize
-        VectorSearchSplit deserialized;
+        IndexVectorSearchSplit deserialized;
         try (ObjectInputStream in =
                 new ObjectInputStream(new 
ByteArrayInputStream(bos.toByteArray()))) {
-            deserialized = (VectorSearchSplit) in.readObject();
+            deserialized = (IndexVectorSearchSplit) in.readObject();
         }
 
         // Verify all fields match
@@ -654,6 +950,37 @@ public class VectorSearchBuilderTest extends TableTestBase 
{
         assertThat(ids1).contains(4);
     }
 
+    @Test
+    public void testBatchVectorSearchFullModeScansUnindexedData() throws 
Exception {
+        catalog.createTable(
+                identifier("batch_full_search_table"),
+                vectorSchemaBuilder(VECTOR_FIELD_NAME)
+                        .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), 
"full")
+                        .build(),
+                false);
+        FileStoreTable table = getTable(identifier("batch_full_search_table"));
+
+        float[][] vectors = {
+            {0.0f, 1.0f},
+            {0.1f, 0.9f},
+            {1.0f, 0.0f},
+            {0.95f, 0.05f}
+        };
+        writeVectors(table, vectors);
+        buildAndCommitVectorIndex(table, new float[][] {vectors[0], 
vectors[1]}, new Range(0, 1));
+
+        List<GlobalIndexResult> results =
+                table.newBatchVectorSearchBuilder()
+                        .withVectors(new float[][] {{1.0f, 0.0f}, {0.0f, 
1.0f}})
+                        .withLimit(1)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeBatchLocal();
+
+        assertThat(results).hasSize(2);
+        assertThat(results.get(0).results()).containsExactly(2L);
+        assertThat(results.get(1).results()).containsExactly(0L);
+    }
+
     @Test
     public void testBatchVectorSearchWithMultipleIndexFiles() throws Exception 
{
         createTableDefault();
@@ -950,12 +1277,14 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
         VectorScan.Plan plan = searchBuilder.newVectorScan().scan();
         assertThat(plan.splits()).isNotEmpty();
         // Every split should have vector index files
-        for (VectorSearchSplit split : plan.splits()) {
+        for (IndexVectorSearchSplit split : 
indexVectorSearchSplits(plan.splits())) {
             assertThat(split.vectorIndexFiles()).isNotEmpty();
         }
         // At least one split should have scalar (btree) index files
         long scalarCount =
-                plan.splits().stream().filter(s -> 
!s.scalarIndexFiles().isEmpty()).count();
+                indexVectorSearchSplits(plan.splits()).stream()
+                        .filter(s -> !s.scalarIndexFiles().isEmpty())
+                        .count();
         assertThat(scalarCount).isGreaterThan(0);
 
         // --- Test VectorReadImpl: pre-filter should narrow results ---
@@ -1104,4 +1433,53 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
             commit.commit(Collections.singletonList(message));
         }
     }
+
+    private List<IndexVectorSearchSplit> 
indexVectorSearchSplits(List<VectorSearchSplit> splits) {
+        List<IndexVectorSearchSplit> indexSplits = new ArrayList<>();
+        for (VectorSearchSplit split : splits) {
+            if (split instanceof IndexVectorSearchSplit) {
+                indexSplits.add((IndexVectorSearchSplit) split);
+            }
+        }
+        return indexSplits;
+    }
+
+    private List<RawVectorSearchSplit> 
rawVectorSearchSplits(List<VectorSearchSplit> splits) {
+        List<RawVectorSearchSplit> rawSplits = new ArrayList<>();
+        for (VectorSearchSplit split : splits) {
+            if (split instanceof RawVectorSearchSplit) {
+                rawSplits.add((RawVectorSearchSplit) split);
+            }
+        }
+        return rawSplits;
+    }
+
+    private Predicate globalIndexOnlyIdGreaterOrEqual(FileStoreTable table, 
int literal) {
+        DataField idField = table.rowType().getField("id");
+        return new GlobalIndexOnlyLeafPredicate(
+                new FieldTransform(new FieldRef(0, idField.name(), 
idField.type())),
+                GreaterOrEqual.INSTANCE,
+                Collections.singletonList(literal));
+    }
+
+    private static class GlobalIndexOnlyLeafPredicate extends LeafPredicate {
+
+        private static final long serialVersionUID = 1L;
+
+        private GlobalIndexOnlyLeafPredicate(
+                Transform transform, LeafFunction function, List<Object> 
literals) {
+            super(transform, function, literals);
+        }
+
+        @Override
+        public boolean test(InternalRow row) {
+            return true;
+        }
+
+        @Override
+        public LeafPredicate copyWithNewInputs(List<Object> newInputs) {
+            return new GlobalIndexOnlyLeafPredicate(
+                    transform().copyWithNewInputs(newInputs), function(), 
literals());
+        }
+    }
 }
diff --git 
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexer.java
 
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexer.java
index 276cc5aa85..69b73a6bce 100644
--- 
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexer.java
+++ 
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexer.java
@@ -21,7 +21,7 @@ package org.apache.paimon.lumina.index;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexWriter;
-import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
 import org.apache.paimon.options.Options;
@@ -31,7 +31,7 @@ import java.util.List;
 import java.util.concurrent.ExecutorService;
 
 /** Lumina vector global indexer. */
-public class LuminaVectorGlobalIndexer implements GlobalIndexer {
+public class LuminaVectorGlobalIndexer implements VectorGlobalIndexer {
 
     private final DataType fieldType;
     private final LuminaVectorIndexOptions options;
@@ -53,4 +53,9 @@ public class LuminaVectorGlobalIndexer implements 
GlobalIndexer {
             ExecutorService executor) {
         return new LuminaVectorGlobalIndexReader(fileReader, files, fieldType, 
options, executor);
     }
+
+    @Override
+    public String metric() {
+        return options.metric().getLuminaName();
+    }
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorReadImpl.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorReadImpl.java
index 1ddc9c9ac9..90b1eef331 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorReadImpl.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorReadImpl.java
@@ -25,16 +25,20 @@ import org.apache.paimon.globalindex.GlobalIndexer;
 import org.apache.paimon.globalindex.GlobalIndexerFactoryUtils;
 import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
 import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.IndexVectorSearchSplit;
+import org.apache.paimon.table.source.RawVectorSearchSplit;
 import org.apache.paimon.table.source.VectorReadImpl;
-import org.apache.paimon.table.source.VectorSearchSplit;
+import org.apache.paimon.table.source.VectorScan;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.InstantiationUtil;
+import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 import org.apache.paimon.utils.SerializableFunction;
 
-import org.apache.spark.broadcast.Broadcast;
+import javax.annotation.Nullable;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -56,67 +60,90 @@ public class SparkVectorReadImpl extends VectorReadImpl {
 
     public SparkVectorReadImpl(
             FileStoreTable table,
-            Predicate filter,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter,
             int limit,
             DataField vectorColumn,
             float[] vector,
-            Map<String, String> options) {
-        super(table, filter, limit, vectorColumn, vector, options);
+            @Nullable Map<String, String> options) {
+        super(table, partitionFilter, filter, limit, vectorColumn, vector, 
options);
     }
 
     @Override
-    public GlobalIndexResult read(List<VectorSearchSplit> splits) {
-        if (splits.isEmpty()) {
+    public GlobalIndexResult read(VectorScan.Plan plan) {
+        List<IndexVectorSearchSplit> indexSplits = new ArrayList<>();
+        List<RawVectorSearchSplit> rawSplits = new ArrayList<>();
+        splitSearchSplits(plan.splits(), indexSplits, rawSplits);
+        if (indexSplits.isEmpty() && rawSplits.isEmpty()) {
             return GlobalIndexResult.createEmpty();
         }
 
-        int parallelism =
-                Math.max(1, 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+        GlobalIndexer globalIndexer =
+                !indexSplits.isEmpty() && !rawSplits.isEmpty()
+                        ? createGlobalIndexer(indexSplits)
+                        : null;
+        ScoredGlobalIndexResult result =
+                indexSplits.isEmpty()
+                        ? ScoredGlobalIndexResult.createEmpty()
+                        : readIndexSplitsInSpark(indexSplits, globalIndexer);
+        return result.or(readRawSplitsInSpark(rawSplits, globalIndexer, 
rawPreFilter(rawSplits)))
+                .topK(limit);
+    }
+
+    protected ScoredGlobalIndexResult readIndexSplitsInSpark(
+            List<IndexVectorSearchSplit> splits, @Nullable GlobalIndexer 
globalIndexer) {
+        if (splits.isEmpty()) {
+            return ScoredGlobalIndexResult.createEmpty();
+        }
+
+        int parallelism = sparkParallelism();
         if (splits.size() < parallelism * 2) {
-            return super.read(splits);
+            return readIndexed(
+                    splits, globalIndexer == null ? 
createGlobalIndexer(splits) : globalIndexer);
         }
 
-        RoaringNavigableMap64 preFilter = preFilter(splits).orElse(null);
+        List<RoaringNavigableMap64> preFilters = preFilters(splits);
         String indexType = splits.get(0).vectorIndexFiles().get(0).indexType();
-        List<byte[]> splitBytes = new ArrayList<>(splits.size());
-        for (VectorSearchSplit split : splits) {
+        List<SerializedSplit> serializedSplits = new 
ArrayList<>(splits.size());
+        for (int i = 0; i < splits.size(); i++) {
             try {
-                splitBytes.add(InstantiationUtil.serializeObject(split));
+                IndexVectorSearchSplit split = splits.get(i);
+                RoaringNavigableMap64 preFilter = preFilters.isEmpty() ? null 
: preFilters.get(i);
+                serializedSplits.add(
+                        new SerializedSplit(
+                                InstantiationUtil.serializeObject(split),
+                                preFilter == null
+                                        ? null
+                                        : 
InstantiationUtil.serializeObject(preFilter)));
             } catch (IOException e) {
                 throw new RuntimeException("Failed to serialize 
VectorSearchSplit", e);
             }
         }
-        List<List<byte[]>> splitGroups = splitGroups(splitBytes, parallelism);
-        SparkEngineContext engineContext = new SparkEngineContext();
-        Broadcast<RoaringNavigableMap64> preFilterBroadcast =
-                preFilter == null ? null : engineContext.broadcast(preFilter);
-
-        SerializableFunction<List<byte[]>, byte[]> task =
+        List<List<SerializedSplit>> splitGroups = 
splitGroups(serializedSplits, parallelism);
+        SerializableFunction<List<SerializedSplit>, byte[]> task =
                 group -> {
-                    GlobalIndexer globalIndexer =
+                    GlobalIndexer taskGlobalIndexer =
                             GlobalIndexerFactoryUtils.load(indexType)
                                     .create(vectorColumn, 
table.coreOptions().toConfiguration());
                     IndexPathFactory indexPathFactory =
                             
table.store().pathFactory().globalIndexFileFactory();
 
-                    RoaringNavigableMap64 includeRowIds =
-                            preFilterBroadcast == null ? null : 
preFilterBroadcast.value();
                     ExecutorService executor =
                             GlobalIndexReadThreadPool.getExecutorService(
                                     Math.min(parallelism, group.size()));
                     List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> 
futures =
                             new ArrayList<>(group.size());
-                    for (byte[] bytes : group) {
-                        VectorSearchSplit split = deserializeSplit(bytes);
+                    for (SerializedSplit serializedSplit : group) {
+                        IndexVectorSearchSplit split = 
deserializeSplit(serializedSplit.split);
                         futures.add(
                                 eval(
-                                        globalIndexer,
+                                        taskGlobalIndexer,
                                         indexPathFactory,
                                         split.rowRangeStart(),
                                         split.rowRangeEnd(),
                                         split.vectorIndexFiles(),
                                         vector,
-                                        includeRowIds,
+                                        
deserializePreFilter(serializedSplit.preFilter),
                                         executor));
                     }
                     CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).join();
@@ -139,15 +166,130 @@ public class SparkVectorReadImpl extends VectorReadImpl {
                     }
                 };
 
-        List<byte[]> remoteResults;
-        try {
-            remoteResults = engineContext.map(splitGroups, task, 
splitGroups.size());
-        } finally {
-            if (preFilterBroadcast != null) {
-                preFilterBroadcast.unpersist(false);
+        List<byte[]> remoteResults = mapInSpark(splitGroups, task, 
splitGroups.size());
+
+        return mergeRemoteResults(remoteResults);
+    }
+
+    protected ScoredGlobalIndexResult readRawSplitsInSpark(
+            List<RawVectorSearchSplit> splits,
+            @Nullable GlobalIndexer globalIndexer,
+            @Nullable RoaringNavigableMap64 preFilter) {
+        List<Range> rawRowRanges = rawRowRanges(splits);
+        if (rawRowRanges.isEmpty()) {
+            return ScoredGlobalIndexResult.createEmpty();
+        }
+
+        int parallelism = sparkParallelism();
+        if (rawRowCount(rawRowRanges) < parallelism * 2L) {
+            return readRawSearch(
+                    rawRowRanges, preFilter, rawSearchIndexer(splits, 
globalIndexer), vector);
+        }
+
+        String metric = rawSearchMetric(rawSearchIndexer(splits, 
globalIndexer));
+        List<List<Range>> rangeGroups = rangeGroups(rawRowRanges, parallelism);
+        List<SerializedSplit> serializedSplits = new 
ArrayList<>(rangeGroups.size());
+        for (List<Range> rangeGroup : rangeGroups) {
+            try {
+                serializedSplits.add(
+                        new SerializedSplit(
+                                InstantiationUtil.serializeObject(rangeGroup),
+                                preFilter == null
+                                        ? null
+                                        : 
InstantiationUtil.serializeObject(preFilter)));
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to serialize raw vector row 
ranges", e);
             }
         }
+        List<List<SerializedSplit>> splitGroups = 
splitGroups(serializedSplits, parallelism);
 
+        SerializableFunction<List<SerializedSplit>, byte[]> task =
+                group -> {
+                    ScoredGlobalIndexResult result = 
ScoredGlobalIndexResult.createEmpty();
+                    for (SerializedSplit serializedSplit : group) {
+                        List<Range> rowRanges = 
deserializeRanges(serializedSplit.split);
+                        ScoredGlobalIndexResult splitResult =
+                                readRawSearch(
+                                        rowRanges,
+                                        
deserializePreFilter(serializedSplit.preFilter),
+                                        metric,
+                                        vector);
+                        result = result.or(splitResult);
+                    }
+                    result = result.topK(limit);
+                    if (result.results().isEmpty()) {
+                        return null;
+                    }
+                    try {
+                        return new 
GlobalIndexResultSerializer().serialize(result);
+                    } catch (IOException e) {
+                        throw new RuntimeException(
+                                "Failed to serialize ScoredGlobalIndexResult", 
e);
+                    }
+                };
+
+        List<byte[]> remoteResults = mapInSpark(splitGroups, task, 
splitGroups.size());
+        return mergeRemoteResults(remoteResults);
+    }
+
+    protected int sparkParallelism() {
+        return Math.max(1, 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+    }
+
+    protected SparkEngineContext createEngineContext() {
+        return new SparkEngineContext();
+    }
+
+    protected <I, O> List<O> mapInSpark(
+            List<I> data, SerializableFunction<I, O> func, int parallelism) {
+        return createEngineContext().map(data, func, parallelism);
+    }
+
+    private IndexVectorSearchSplit deserializeSplit(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize 
VectorSearchSplit", e);
+        }
+    }
+
+    private List<Range> deserializeRanges(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize raw vector row 
ranges", e);
+        }
+    }
+
+    @Nullable
+    private RoaringNavigableMap64 deserializePreFilter(@Nullable byte[] bytes) 
{
+        if (bytes == null) {
+            return null;
+        }
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize vector 
pre-filter", e);
+        }
+    }
+
+    private List<List<SerializedSplit>> splitGroups(
+            List<SerializedSplit> serializedSplits, int parallelism) {
+        List<List<SerializedSplit>> groups = new ArrayList<>(parallelism);
+        int groupSize = (serializedSplits.size() + parallelism - 1) / 
parallelism;
+        for (int start = 0; start < serializedSplits.size(); start += 
groupSize) {
+            groups.add(
+                    new ArrayList<>(
+                            serializedSplits.subList(
+                                    start, Math.min(start + groupSize, 
serializedSplits.size()))));
+        }
+        return groups;
+    }
+
+    private ScoredGlobalIndexResult mergeRemoteResults(List<byte[]> 
remoteResults) {
         ScoredGlobalIndexResult result = ScoredGlobalIndexResult.createEmpty();
         GlobalIndexResultSerializer serializer = new 
GlobalIndexResultSerializer();
         for (byte[] bytes : remoteResults) {
@@ -162,24 +304,58 @@ public class SparkVectorReadImpl extends VectorReadImpl {
         return result.topK(limit);
     }
 
-    private VectorSearchSplit deserializeSplit(byte[] bytes) {
-        try {
-            return InstantiationUtil.deserializeObject(
-                    bytes, Thread.currentThread().getContextClassLoader());
-        } catch (IOException | ClassNotFoundException e) {
-            throw new RuntimeException("Failed to deserialize 
VectorSearchSplit", e);
+    private List<List<Range>> rangeGroups(List<Range> ranges, int parallelism) 
{
+        long rowCount = rawRowCount(ranges);
+        int groupCount = (int) Math.min(parallelism, rowCount);
+        long targetRowsPerGroup = (rowCount - 1) / groupCount + 1;
+
+        List<List<Range>> groups = new ArrayList<>(groupCount);
+        List<Range> currentGroup = new ArrayList<>();
+        long currentRows = 0;
+        for (Range range : ranges) {
+            long from = range.from;
+            while (from <= range.to) {
+                if (currentRows == targetRowsPerGroup) {
+                    groups.add(currentGroup);
+                    currentGroup = new ArrayList<>();
+                    currentRows = 0;
+                }
+                long remainingGroupRows = targetRowsPerGroup - currentRows;
+                long to = Math.min(range.to, from + remainingGroupRows - 1);
+                Range next = new Range(from, to);
+                currentGroup.add(next);
+                currentRows += next.count();
+                from = to + 1;
+            }
+        }
+        if (!currentGroup.isEmpty()) {
+            groups.add(currentGroup);
         }
+        return groups;
     }
 
-    private List<List<byte[]>> splitGroups(List<byte[]> splitBytes, int 
parallelism) {
-        List<List<byte[]>> groups = new ArrayList<>(parallelism);
-        int groupSize = (splitBytes.size() + parallelism - 1) / parallelism;
-        for (int start = 0; start < splitBytes.size(); start += groupSize) {
-            groups.add(
-                    new ArrayList<>(
-                            splitBytes.subList(
-                                    start, Math.min(start + groupSize, 
splitBytes.size()))));
+    private long rawRowCount(List<Range> ranges) {
+        long rowCount = 0;
+        for (Range range : ranges) {
+            long count = range.count();
+            if (Long.MAX_VALUE - rowCount < count) {
+                return Long.MAX_VALUE;
+            }
+            rowCount += count;
+        }
+        return rowCount;
+    }
+
+    private static class SerializedSplit implements java.io.Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private final byte[] split;
+        @Nullable private final byte[] preFilter;
+
+        private SerializedSplit(byte[] split, @Nullable byte[] preFilter) {
+            this.split = split;
+            this.preFilter = preFilter;
         }
-        return groups;
     }
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorSearchBuilderImpl.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorSearchBuilderImpl.java
index be8d3d8cad..8704486258 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorSearchBuilderImpl.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorSearchBuilderImpl.java
@@ -38,6 +38,7 @@ public class SparkVectorSearchBuilderImpl extends 
VectorSearchBuilderImpl {
 
     @Override
     public VectorRead newVectorRead() {
-        return new SparkVectorReadImpl(table, filter, limit, vectorColumn, 
vector, options);
+        return new SparkVectorReadImpl(
+                table, partitionFilter, filter, limit, vectorColumn, vector, 
options);
     }
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkVectorReadImplTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkVectorReadImplTest.java
new file mode 100644
index 0000000000..c9a3e330a0
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkVectorReadImplTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.spark.read;
+
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.table.source.IndexVectorSearchSplit;
+import org.apache.paimon.table.source.RawVectorSearchSplit;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.table.source.VectorSearchSplit;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link SparkVectorReadImpl}. */
+public class SparkVectorReadImplTest {
+
+    @Test
+    public void testRawSearchUsesSparkPath() {
+        TestingSparkVectorRead read = new TestingSparkVectorRead();
+        RawVectorSearchSplit rawSplit =
+                new RawVectorSearchSplit(
+                        Collections.singletonList(new Range(42, 42)),
+                        Collections.emptyList(),
+                        null);
+        VectorScan.Plan plan = () -> Collections.singletonList(rawSplit);
+
+        GlobalIndexResult result = read.read(plan);
+
+        assertThat(read.rawSparkPathUsed).isTrue();
+        assertThat(result.results().contains(42L)).isTrue();
+    }
+
+    @Test
+    public void testRawSearchSplitsRangesAcrossSparkTasks() {
+        RecordingSparkVectorRead read = new RecordingSparkVectorRead();
+        RawVectorSearchSplit rawSplit =
+                new RawVectorSearchSplit(
+                        Collections.singletonList(new Range(0, 63)), 
Collections.emptyList(), null);
+
+        ScoredGlobalIndexResult result =
+                read.readRawSplitsInSpark(Collections.singletonList(rawSplit), 
null, null);
+
+        assertThat(read.rawSearchRanges).containsExactly(new Range(0, 31), new 
Range(32, 63));
+        assertThat(read.sparkParallelism).isEqualTo(2);
+        assertThat(result.results().getLongCardinality()).isEqualTo(64);
+    }
+
+    private static class TestingSparkVectorRead extends SparkVectorReadImpl {
+
+        private boolean rawSparkPathUsed;
+
+        private TestingSparkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    10,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {1.0f},
+                    null);
+        }
+
+        @Override
+        protected GlobalIndexResult readSplits(List<? extends 
VectorSearchSplit> splits) {
+            throw new AssertionError("Raw search should not fall back to local 
vector read.");
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readIndexSplitsInSpark(
+                List<IndexVectorSearchSplit> splits, @Nullable GlobalIndexer 
globalIndexer) {
+            throw new AssertionError("Index search is not part of this test.");
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawSplitsInSpark(
+                List<RawVectorSearchSplit> splits,
+                @Nullable GlobalIndexer globalIndexer,
+                @Nullable RoaringNavigableMap64 preFilter) {
+            rawSparkPathUsed = true;
+            assertThat(splits).hasSize(1);
+            assertThat(globalIndexer).isNull();
+            assertThat(preFilter).isNull();
+
+            RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+            rows.add(42L);
+            return ScoredGlobalIndexResult.create(rows, rowId -> 1.0f);
+        }
+    }
+
+    private static class RecordingSparkVectorRead extends SparkVectorReadImpl {
+
+        private final AtomicInteger nextTask = new AtomicInteger();
+        private final List<Range> rawSearchRanges =
+                Collections.synchronizedList(new java.util.ArrayList<>());
+        private int sparkParallelism;
+
+        private RecordingSparkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    100,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {1.0f},
+                    Collections.singletonMap("test.vector.metric", "l2"));
+        }
+
+        @Override
+        protected int sparkParallelism() {
+            return 2;
+        }
+
+        @Override
+        protected <I, O> List<O> mapInSpark(
+                List<I> data,
+                org.apache.paimon.utils.SerializableFunction<I, O> func,
+                int parallelism) {
+            sparkParallelism = parallelism;
+            return data.stream().map(func::apply).collect(Collectors.toList());
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawSearch(
+                List<Range> rawRowRanges,
+                @Nullable RoaringNavigableMap64 preFilter,
+                String metric,
+                float[] queryVector) {
+            assertThat(preFilter).isNull();
+            assertThat(metric).isEqualTo("l2");
+            assertThat(queryVector).containsExactly(1.0f);
+            rawSearchRanges.addAll(rawRowRanges);
+
+            RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+            int scoreBase = nextTask.getAndIncrement();
+            for (Range range : rawRowRanges) {
+                rows.addRange(range);
+            }
+            return ScoredGlobalIndexResult.create(rows, rowId -> scoreBase + 
(float) rowId);
+        }
+    }
+}
diff --git 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
index 7095b5bd2e..f45a97d34a 100644
--- 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
+++ 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
@@ -21,7 +21,7 @@ package org.apache.paimon.vector.index;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.GlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexWriter;
-import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
 import org.apache.paimon.types.DataType;
@@ -32,7 +32,9 @@ import java.util.Objects;
 import java.util.concurrent.ExecutorService;
 
 /** Native vector global indexer backed by paimon-vector-index-java. */
-public class NativeVectorGlobalIndexer implements GlobalIndexer {
+public class NativeVectorGlobalIndexer implements VectorGlobalIndexer {
+
+    private static final String DEFAULT_METRIC = "inner_product";
 
     private final DataType fieldType;
     private final Map<String, String> options;
@@ -57,4 +59,9 @@ public class NativeVectorGlobalIndexer implements 
GlobalIndexer {
             ExecutorService executor) {
         return new NativeVectorGlobalIndexReader(fileReader, files, fieldType, 
executor);
     }
+
+    @Override
+    public String metric() {
+        return options.getOrDefault("metric", DEFAULT_METRIC);
+    }
 }


Reply via email to