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 f7d77e77d9 [core] Support batch vector search (#7857)
f7d77e77d9 is described below

commit f7d77e77d92eddcb86bbd9b11f14155d1da9f24e
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Jun 18 13:30:18 2026 +0800

    [core] Support batch vector search (#7857)
---
 docs/docs/multimodal-table/global-index/vector.mdx |  15 ++
 .../paimon/globalindex/GlobalIndexReader.java      |  22 +++
 .../globalindex/OffsetGlobalIndexReader.java       |  17 ++
 .../{VectorSearch.java => BatchVectorSearch.java}  |  73 +++----
 .../org/apache/paimon/predicate/VectorSearch.java  |  12 +-
 .../apache/paimon/predicate/VectorSearchUtils.java |  24 ++-
 .../apache/paimon/predicate/VectorSearchTest.java  |  17 ++
 .../java/org/apache/paimon/table/FormatTable.java  |   6 +
 .../java/org/apache/paimon/table/InnerTable.java   |   7 +
 .../main/java/org/apache/paimon/table/Table.java   |   7 +
 .../table/source/BatchVectorSearchBuilder.java     |  69 +++++++
 ...Impl.java => BatchVectorSearchBuilderImpl.java} |  35 ++--
 .../org/apache/paimon/table/source/VectorRead.java |  13 ++
 .../apache/paimon/table/source/VectorReadImpl.java |  85 +++++---
 .../table/source/VectorSearchBuilderImpl.java      |   5 +-
 .../table/source/VectorSearchBuilderTest.java      | 140 +++++++++++++
 .../index/LuminaVectorGlobalIndexReader.java       | 217 ++++++++++++++++-----
 .../lumina/index/LuminaVectorGlobalIndexTest.java  | 204 +++++++++++++++++++
 paimon-python/pypaimon/table/file_store_table.py   |   5 +
 .../pypaimon/table/format/format_table.py          |   3 +
 .../pypaimon/table/iceberg/iceberg_table.py        |   3 +
 .../pypaimon/table/object/object_table.py          |   5 +
 .../table/source/batch_vector_search_builder.py    | 124 ++++++++++++
 .../pypaimon/table/source/vector_search_builder.py |  23 ++-
 .../pypaimon/table/source/vector_search_read.py    |  31 ++-
 .../pypaimon/table/system/system_table.py          |   3 +
 paimon-python/pypaimon/table/table.py              |   7 +
 .../pypaimon/tests/vector_search_filter_test.py    |  83 ++++++++
 .../paimon/spark/read/SparkVectorReadImpl.java     |  21 +-
 .../spark/read/SparkVectorSearchBuilderImpl.java   |   5 +-
 .../vector/index/VectorGlobalIndexReader.java      | 156 ++++++++++++---
 .../paimon/vector/index/VectorGlobalIndexTest.java | 168 ++++++++++++++++
 32 files changed, 1413 insertions(+), 192 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/vector.mdx 
b/docs/docs/multimodal-table/global-index/vector.mdx
index f4ac4eedcc..5f6cb01691 100644
--- a/docs/docs/multimodal-table/global-index/vector.mdx
+++ b/docs/docs/multimodal-table/global-index/vector.mdx
@@ -182,6 +182,21 @@ try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(plan)
 }
 ```
 
+Batch results keep the same order as input vectors.
+
+```java
+float[][] queryVectors = {
+    {1.0f, 2.0f, 3.0f},
+    {3.0f, 2.0f, 1.0f}
+};
+List<GlobalIndexResult> batchResults = table.newBatchVectorSearchBuilder()
+        .withVectors(queryVectors)
+        .withLimit(5)
+        .withVectorColumn("embedding")
+        .executeBatchLocal();
+// batchResults.get(i) corresponds to queryVectors[i].
+```
+
 For Java, use `Table.newVectorSearchBuilder()` to produce a global index 
result, then pass
 the result to `TableScan.withGlobalIndexResult`.
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
index b16ce888af..5951d40726 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexReader.java
@@ -18,12 +18,14 @@
 
 package org.apache.paimon.globalindex;
 
+import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.FullTextSearch;
 import org.apache.paimon.predicate.FunctionVisitor;
 import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.VectorSearch;
 
 import java.io.Closeable;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
@@ -59,4 +61,24 @@ public interface GlobalIndexReader
             FullTextSearch fullTextSearch) {
         throw new UnsupportedOperationException();
     }
+
+    /** Batch search; result {@code i} matches vector {@code i}. */
+    default CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> 
visitBatchVectorSearch(
+            BatchVectorSearch batchVectorSearch) {
+        List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> futures = 
new ArrayList<>();
+        for (int i = 0; i < batchVectorSearch.vectorCount(); i++) {
+            futures.add(visitVectorSearch(batchVectorSearch.forIndex(i)));
+        }
+        return CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0]))
+                .thenApply(
+                        ignored -> {
+                            List<Optional<ScoredGlobalIndexResult>> results =
+                                    new ArrayList<>(futures.size());
+                            for 
(CompletableFuture<Optional<ScoredGlobalIndexResult>> future :
+                                    futures) {
+                                results.add(future.join());
+                            }
+                            return results;
+                        });
+    }
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
index e2a03bca76..f740223d5a 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/OffsetGlobalIndexReader.java
@@ -18,11 +18,13 @@
 
 package org.apache.paimon.globalindex;
 
+import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.FullTextSearch;
 import org.apache.paimon.predicate.VectorSearch;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
@@ -145,6 +147,21 @@ public class OffsetGlobalIndexReader implements 
GlobalIndexReader {
                 .thenApply(opt -> opt.map(r -> r.offset(offset)));
     }
 
+    @Override
+    public CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> 
visitBatchVectorSearch(
+            BatchVectorSearch batchVectorSearch) {
+        return 
wrapped.visitBatchVectorSearch(batchVectorSearch.offsetRange(this.offset, 
this.to))
+                .thenApply(
+                        results -> {
+                            List<Optional<ScoredGlobalIndexResult>> 
offsetResults =
+                                    new ArrayList<>(results.size());
+                            for (Optional<ScoredGlobalIndexResult> result : 
results) {
+                                offsetResults.add(result.map(r -> 
r.offset(offset)));
+                            }
+                            return offsetResults;
+                        });
+    }
+
     private Optional<GlobalIndexResult> 
applyOffset(Optional<GlobalIndexResult> result) {
         return result.map(r -> r.offset(offset));
     }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java 
b/paimon-common/src/main/java/org/apache/paimon/predicate/BatchVectorSearch.java
similarity index 57%
copy from 
paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
copy to 
paimon-common/src/main/java/org/apache/paimon/predicate/BatchVectorSearch.java
index 5f046c6307..f1617f6722 100644
--- a/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/predicate/BatchVectorSearch.java
@@ -18,7 +18,6 @@
 
 package org.apache.paimon.predicate;
 
-import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
 import javax.annotation.Nullable;
@@ -28,30 +27,31 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
-/**
- * VectorSearch to perform vector similarity search.
- *
- * <p>This is an internal pushdown representation. Use {@code 
Table.newVectorSearchBuilder()} to
- * configure vector search from Java.
- */
-public class VectorSearch implements Serializable {
+/** Batch vector similarity search. */
+public class BatchVectorSearch implements Serializable {
 
     private static final long serialVersionUID = 1L;
 
-    private final float[] vector;
+    private final float[][] vectors;
     private final String fieldName;
     private final int limit;
     private final Map<String, String> options;
 
     @Nullable private RoaringNavigableMap64 includeRowIds;
 
-    public VectorSearch(float[] vector, int limit, String fieldName) {
-        this(vector, limit, fieldName, Collections.emptyMap());
+    public BatchVectorSearch(float[][] vectors, int limit, String fieldName) {
+        this(vectors, limit, fieldName, Collections.emptyMap());
     }
 
-    public VectorSearch(float[] vector, int limit, String fieldName, 
Map<String, String> options) {
-        if (vector == null) {
-            throw new IllegalArgumentException("Search cannot be null");
+    public BatchVectorSearch(
+            float[][] vectors, int limit, String fieldName, Map<String, 
String> options) {
+        if (vectors == null || vectors.length == 0) {
+            throw new IllegalArgumentException("Search vectors cannot be null 
or empty");
+        }
+        for (float[] vector : vectors) {
+            if (vector == null) {
+                throw new IllegalArgumentException("Search vector element 
cannot be null");
+            }
         }
         if (limit <= 0) {
             throw new IllegalArgumentException("Limit must be positive, got: " 
+ limit);
@@ -59,7 +59,7 @@ public class VectorSearch implements Serializable {
         if (fieldName == null || fieldName.isEmpty()) {
             throw new IllegalArgumentException("Field name cannot be null or 
empty");
         }
-        this.vector = vector;
+        this.vectors = vectors;
         this.limit = limit;
         this.fieldName = fieldName;
         this.options =
@@ -68,8 +68,25 @@ public class VectorSearch implements Serializable {
                         : Collections.unmodifiableMap(new HashMap<>(options));
     }
 
-    public float[] vector() {
-        return vector;
+    /** Query vectors in input order. */
+    public float[][] vectors() {
+        return vectors;
+    }
+
+    public int vectorCount() {
+        return vectors.length;
+    }
+
+    public VectorSearch forIndex(int i) {
+        VectorSearch vectorSearch = new VectorSearch(vectors[i], limit, 
fieldName, options);
+        if (includeRowIds != null) {
+            vectorSearch.withIncludeRowIds(includeRowIds);
+        }
+        return vectorSearch;
+    }
+
+    public Map<String, String> options() {
+        return options;
     }
 
     public int limit() {
@@ -80,30 +97,19 @@ public class VectorSearch implements Serializable {
         return fieldName;
     }
 
-    public Map<String, String> options() {
-        return options == null ? Collections.emptyMap() : options;
-    }
-
     public RoaringNavigableMap64 includeRowIds() {
         return includeRowIds;
     }
 
-    public VectorSearch withIncludeRowIds(RoaringNavigableMap64 includeRowIds) 
{
+    public BatchVectorSearch withIncludeRowIds(RoaringNavigableMap64 
includeRowIds) {
         this.includeRowIds = includeRowIds;
         return this;
     }
 
-    public VectorSearch offsetRange(long from, long to) {
+    public BatchVectorSearch offsetRange(long from, long to) {
         if (includeRowIds != null) {
-            RoaringNavigableMap64 range = new RoaringNavigableMap64();
-            range.addRange(new Range(from, to));
-            RoaringNavigableMap64 and64 = RoaringNavigableMap64.and(range, 
includeRowIds);
-            final RoaringNavigableMap64 roaringNavigableMap64Offset = new 
RoaringNavigableMap64();
-            for (long rowId : and64) {
-                roaringNavigableMap64Offset.add(rowId - from);
-            }
-            VectorSearch target = new VectorSearch(vector, limit, fieldName, 
options());
-            target.withIncludeRowIds(roaringNavigableMap64Offset);
+            BatchVectorSearch target = new BatchVectorSearch(vectors, limit, 
fieldName, options);
+            
target.withIncludeRowIds(VectorSearchUtils.offsetRowIds(includeRowIds, from, 
to));
             return target;
         }
         return this;
@@ -111,6 +117,7 @@ public class VectorSearch implements Serializable {
 
     @Override
     public String toString() {
-        return String.format("FieldName(%s), Limit(%s)", fieldName, limit);
+        return String.format(
+                "FieldName(%s), Limit(%s), VectorCount(%s)", fieldName, limit, 
vectors.length);
     }
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java 
b/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
index 5f046c6307..1114904bc6 100644
--- a/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
+++ b/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
@@ -18,7 +18,6 @@
 
 package org.apache.paimon.predicate;
 
-import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.RoaringNavigableMap64;
 
 import javax.annotation.Nullable;
@@ -51,7 +50,7 @@ public class VectorSearch implements Serializable {
 
     public VectorSearch(float[] vector, int limit, String fieldName, 
Map<String, String> options) {
         if (vector == null) {
-            throw new IllegalArgumentException("Search cannot be null");
+            throw new IllegalArgumentException("Search vector cannot be null");
         }
         if (limit <= 0) {
             throw new IllegalArgumentException("Limit must be positive, got: " 
+ limit);
@@ -95,15 +94,8 @@ public class VectorSearch implements Serializable {
 
     public VectorSearch offsetRange(long from, long to) {
         if (includeRowIds != null) {
-            RoaringNavigableMap64 range = new RoaringNavigableMap64();
-            range.addRange(new Range(from, to));
-            RoaringNavigableMap64 and64 = RoaringNavigableMap64.and(range, 
includeRowIds);
-            final RoaringNavigableMap64 roaringNavigableMap64Offset = new 
RoaringNavigableMap64();
-            for (long rowId : and64) {
-                roaringNavigableMap64Offset.add(rowId - from);
-            }
             VectorSearch target = new VectorSearch(vector, limit, fieldName, 
options());
-            target.withIncludeRowIds(roaringNavigableMap64Offset);
+            
target.withIncludeRowIds(VectorSearchUtils.offsetRowIds(includeRowIds, from, 
to));
             return target;
         }
         return this;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java 
b/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearchUtils.java
similarity index 53%
copy from 
paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
copy to 
paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearchUtils.java
index 74e17e2845..7d664160b7 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorRead.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearchUtils.java
@@ -16,18 +16,24 @@
  * limitations under the License.
  */
 
-package org.apache.paimon.table.source;
+package org.apache.paimon.predicate;
 
-import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
 
-import java.util.List;
+/** Utilities for vector search. */
+class VectorSearchUtils {
 
-/** Vector read to read index files. */
-public interface VectorRead {
-
-    default GlobalIndexResult read(VectorScan.Plan plan) {
-        return read(plan.splits());
+    static RoaringNavigableMap64 offsetRowIds(RoaringNavigableMap64 rowIds, 
long from, long to) {
+        RoaringNavigableMap64 range = new RoaringNavigableMap64();
+        range.addRange(new Range(from, to));
+        RoaringNavigableMap64 filtered = RoaringNavigableMap64.and(range, 
rowIds);
+        RoaringNavigableMap64 offsetRowIds = new RoaringNavigableMap64();
+        for (long rowId : filtered) {
+            offsetRowIds.add(rowId - from);
+        }
+        return offsetRowIds;
     }
 
-    GlobalIndexResult read(List<VectorSearchSplit> splits);
+    private VectorSearchUtils() {}
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/predicate/VectorSearchTest.java 
b/paimon-common/src/test/java/org/apache/paimon/predicate/VectorSearchTest.java
index 0284ff3d37..a1e91d9c46 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/predicate/VectorSearchTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/predicate/VectorSearchTest.java
@@ -73,4 +73,21 @@ public class VectorSearchTest {
                 .containsEntry("hnsw.ef_search", "64")
                 .hasSize(2);
     }
+
+    @Test
+    public void testBatchVectorSearchOrder() {
+        float[][] vectors = new float[][] {new float[] {1.0f, 0.0f}, new 
float[] {0.0f, 1.0f}};
+
+        RoaringNavigableMap64 includeRowIds = new RoaringNavigableMap64();
+        includeRowIds.addRange(new Range(100L, 200L));
+
+        BatchVectorSearch batchSearch =
+                new BatchVectorSearch(vectors, 1, 
"test").withIncludeRowIds(includeRowIds);
+        batchSearch = batchSearch.offsetRange(60, 150);
+
+        assertThat(batchSearch.forIndex(0).vector()).isSameAs(vectors[0]);
+        assertThat(batchSearch.forIndex(1).vector()).isSameAs(vectors[1]);
+        assertThat(batchSearch.forIndex(0).includeRowIds().toRangeList())
+                .containsExactly(new Range(40L, 90L));
+    }
 }
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java 
b/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
index 86441c5dde..9a832176ca 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
@@ -31,6 +31,7 @@ import org.apache.paimon.table.format.FormatBatchWriteBuilder;
 import org.apache.paimon.table.format.FormatReadBuilder;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.StreamWriteBuilder;
+import org.apache.paimon.table.source.BatchVectorSearchBuilder;
 import org.apache.paimon.table.source.FullTextSearchBuilder;
 import org.apache.paimon.table.source.HybridSearchBuilder;
 import org.apache.paimon.table.source.ReadBuilder;
@@ -286,6 +287,11 @@ public interface FormatTable extends Table {
             throw new UnsupportedOperationException("FormatTable does not 
support hybrid search.");
         }
 
+        @Override
+        public BatchVectorSearchBuilder newBatchVectorSearchBuilder() {
+            throw new UnsupportedOperationException("FormatTable does not 
support vector search.");
+        }
+
         @Override
         public FullTextSearchBuilder newFullTextSearchBuilder() {
             throw new UnsupportedOperationException(
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java 
b/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
index 494aae1fe8..2dfc477561 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
@@ -25,6 +25,8 @@ import org.apache.paimon.table.sink.InnerTableWrite;
 import org.apache.paimon.table.sink.StreamWriteBuilder;
 import org.apache.paimon.table.sink.StreamWriteBuilderImpl;
 import org.apache.paimon.table.sink.WriteSelector;
+import org.apache.paimon.table.source.BatchVectorSearchBuilder;
+import org.apache.paimon.table.source.BatchVectorSearchBuilderImpl;
 import org.apache.paimon.table.source.FullTextSearchBuilder;
 import org.apache.paimon.table.source.FullTextSearchBuilderImpl;
 import org.apache.paimon.table.source.HybridSearchBuilder;
@@ -69,6 +71,11 @@ public interface InnerTable extends Table {
         return new HybridSearchBuilderImpl(this);
     }
 
+    @Override
+    default BatchVectorSearchBuilder newBatchVectorSearchBuilder() {
+        return new BatchVectorSearchBuilderImpl(this);
+    }
+
     @Override
     default FullTextSearchBuilder newFullTextSearchBuilder() {
         return new FullTextSearchBuilderImpl(this);
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/Table.java 
b/paimon-core/src/main/java/org/apache/paimon/table/Table.java
index 3a7b1730b6..b4fe2bc237 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/Table.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/Table.java
@@ -28,6 +28,7 @@ import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.stats.Statistics;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.StreamWriteBuilder;
+import org.apache.paimon.table.source.BatchVectorSearchBuilder;
 import org.apache.paimon.table.source.FullTextSearchBuilder;
 import org.apache.paimon.table.source.HybridSearchBuilder;
 import org.apache.paimon.table.source.ReadBuilder;
@@ -233,6 +234,12 @@ public interface Table extends Serializable {
                 getClass().getName() + " does not support hybrid search.");
     }
 
+    /** Returns a new batch vector search builder. */
+    default BatchVectorSearchBuilder newBatchVectorSearchBuilder() {
+        throw new UnsupportedOperationException(
+                getClass().getName() + " does not support batch vector 
search.");
+    }
+
     /** Returns a new full-text search builder. */
     FullTextSearchBuilder newFullTextSearchBuilder();
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilder.java
new file mode 100644
index 0000000000..a4b5d955b2
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilder.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+/** Builder to build batch vector search over multiple query vectors. */
+public interface BatchVectorSearchBuilder extends Serializable {
+
+    /** Push partition filters. */
+    BatchVectorSearchBuilder withPartitionFilter(PartitionPredicate 
partitionPredicate);
+
+    /** Push pre-filter for vector search. */
+    BatchVectorSearchBuilder withFilter(Predicate predicate);
+
+    /** The top k results to return per query vector. */
+    BatchVectorSearchBuilder withLimit(int limit);
+
+    /** The vector column to search. */
+    BatchVectorSearchBuilder withVectorColumn(String name);
+
+    /** The query vectors; result {@code i} corresponds to {@code vectors[i]}. 
*/
+    BatchVectorSearchBuilder withVectors(float[][] vectors);
+
+    /** Option for vector indexes. */
+    default BatchVectorSearchBuilder withOption(String key, String value) {
+        throw new UnsupportedOperationException(
+                getClass().getName() + " does not support vector options.");
+    }
+
+    /** Options for vector indexes. */
+    default BatchVectorSearchBuilder withOptions(Map<String, String> options) {
+        throw new UnsupportedOperationException(
+                getClass().getName() + " does not support vector options.");
+    }
+
+    /** Create vector scan to scan index files. */
+    VectorScan newVectorScan();
+
+    /** Create vector read to read index files. */
+    VectorRead newVectorRead();
+
+    /** Execute batch vector search locally; result {@code i} corresponds to 
{@code vectors[i]}. */
+    default List<GlobalIndexResult> executeBatchLocal() {
+        return newVectorRead().readBatch(newVectorScan().scan());
+    }
+}
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/BatchVectorSearchBuilderImpl.java
similarity index 67%
copy from 
paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
copy to 
paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
index d4686b4416..7b78b6369b 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/BatchVectorSearchBuilderImpl.java
@@ -29,9 +29,11 @@ import java.util.HashMap;
 import java.util.Map;
 
 import static 
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicate;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
-/** Implementation for {@link VectorSearchBuilder}. */
-public class VectorSearchBuilderImpl implements VectorSearchBuilder {
+/** Implementation for {@link BatchVectorSearchBuilder}. */
+public class BatchVectorSearchBuilderImpl implements BatchVectorSearchBuilder {
 
     private static final long serialVersionUID = 1L;
 
@@ -41,21 +43,21 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
     protected Predicate filter;
     protected int limit;
     protected DataField vectorColumn;
-    protected float[] vector;
+    protected float[][] vectors;
     protected Map<String, String> options = new HashMap<>();
 
-    public VectorSearchBuilderImpl(InnerTable table) {
+    public BatchVectorSearchBuilderImpl(InnerTable table) {
         this.table = (FileStoreTable) table;
     }
 
     @Override
-    public VectorSearchBuilder withPartitionFilter(PartitionPredicate 
partitionFilter) {
+    public BatchVectorSearchBuilder withPartitionFilter(PartitionPredicate 
partitionFilter) {
         this.partitionFilter = partitionFilter;
         return this;
     }
 
     @Override
-    public VectorSearchBuilder withFilter(Predicate predicate) {
+    public BatchVectorSearchBuilder withFilter(Predicate predicate) {
         if (this.filter == null) {
             this.filter = predicate;
         } else {
@@ -67,25 +69,25 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
     }
 
     @Override
-    public VectorSearchBuilder withLimit(int limit) {
+    public BatchVectorSearchBuilder withLimit(int limit) {
         this.limit = limit;
         return this;
     }
 
     @Override
-    public VectorSearchBuilder withVectorColumn(String name) {
+    public BatchVectorSearchBuilder withVectorColumn(String name) {
         this.vectorColumn = table.rowType().getField(name);
         return this;
     }
 
     @Override
-    public VectorSearchBuilder withVector(float[] vector) {
-        this.vector = vector;
+    public BatchVectorSearchBuilder withVectors(float[][] vectors) {
+        this.vectors = vectors;
         return this;
     }
 
     @Override
-    public VectorSearchBuilder withOptions(Map<String, String> options) {
+    public BatchVectorSearchBuilder withOptions(Map<String, String> options) {
         if (options != null) {
             this.options.putAll(options);
         }
@@ -93,7 +95,7 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
     }
 
     @Override
-    public VectorSearchBuilder withOption(String key, String value) {
+    public BatchVectorSearchBuilder withOption(String key, String value) {
         this.options.put(key, value);
         return this;
     }
@@ -105,6 +107,13 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
 
     @Override
     public VectorRead newVectorRead() {
-        return new VectorReadImpl(table, filter, limit, vectorColumn, vector, 
options);
+        checkArgument(limit > 0, "Limit must be positive, set via 
withLimit()");
+        checkNotNull(vectorColumn, "Vector column must be set via 
withVectorColumn()");
+        checkArgument(
+                vectors != null && vectors.length > 0, "vectors must be set 
via withVectors()");
+        for (float[] vector : vectors) {
+            checkNotNull(vector, "Search vector element cannot be null");
+        }
+        return new VectorReadImpl(table, filter, limit, vectorColumn, vectors, 
options);
     }
 }
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..54b696b691 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,6 +20,7 @@ package org.apache.paimon.table.source;
 
 import org.apache.paimon.globalindex.GlobalIndexResult;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /** Vector read to read index files. */
@@ -30,4 +31,16 @@ public interface VectorRead {
     }
 
     GlobalIndexResult read(List<VectorSearchSplit> splits);
+
+    /** 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}. */
+    default List<GlobalIndexResult> readBatch(List<VectorSearchSplit> splits) {
+        List<GlobalIndexResult> results = new ArrayList<>(1);
+        results.add(read(splits));
+        return results;
+    }
 }
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 1b3601619c..14e3bb643d 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
@@ -32,8 +32,8 @@ 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.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.Predicate;
-import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.IOUtils;
@@ -57,6 +57,7 @@ import java.util.concurrent.ExecutorService;
 
 import static org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
+import static org.apache.paimon.utils.Preconditions.checkState;
 
 /** Implementation for {@link VectorRead}. */
 public class VectorReadImpl implements VectorRead, Serializable {
@@ -67,7 +68,7 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
     private final Predicate filter;
     protected final int limit;
     protected final DataField vectorColumn;
-    protected final float[] vector;
+    protected final float[][] vectors;
     protected final Map<String, String> options;
 
     public VectorReadImpl(
@@ -75,8 +76,8 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
             Predicate filter,
             int limit,
             DataField vectorColumn,
-            float[] vector) {
-        this(table, filter, limit, vectorColumn, vector, 
Collections.emptyMap());
+            float[][] vectors) {
+        this(table, filter, limit, vectorColumn, vectors, 
Collections.emptyMap());
     }
 
     public VectorReadImpl(
@@ -84,13 +85,13 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
             Predicate filter,
             int limit,
             DataField vectorColumn,
-            float[] vector,
+            float[][] vectors,
             Map<String, String> options) {
         this.table = table;
         this.filter = filter;
         this.limit = limit;
         this.vectorColumn = vectorColumn;
-        this.vector = vector;
+        this.vectors = vectors;
         this.options =
                 options == null
                         ? Collections.emptyMap()
@@ -99,8 +100,21 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
 
     @Override
     public GlobalIndexResult read(List<VectorSearchSplit> splits) {
+        checkState(
+                vectors.length == 1,
+                "read() is single-vector only; use readBatch() for multiple 
vectors");
+        return readBatch(splits).get(0);
+    }
+
+    @Override
+    public List<GlobalIndexResult> readBatch(List<VectorSearchSplit> splits) {
+        int n = vectors.length;
         if (splits.isEmpty()) {
-            return GlobalIndexResult.createEmpty();
+            List<GlobalIndexResult> empty = new ArrayList<>(n);
+            for (int i = 0; i < n; i++) {
+                empty.add(GlobalIndexResult.createEmpty());
+            }
+            return empty;
         }
 
         RoaringNavigableMap64 preFilter = preFilter(splits).orElse(null);
@@ -126,11 +140,11 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
         int parallelism = 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM);
         ExecutorService executor = 
GlobalIndexReadThreadPool.getExecutorService(parallelism);
 
-        List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> futures =
+        List<CompletableFuture<List<Optional<ScoredGlobalIndexResult>>>> 
futures =
                 new ArrayList<>(splits.size());
         for (VectorSearchSplit split : splits) {
             futures.add(
-                    eval(
+                    evalBatch(
                             globalIndexer,
                             indexPathFactory,
                             split.rowRangeStart(),
@@ -142,15 +156,25 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
 
         CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).join();
 
-        ScoredGlobalIndexResult result = ScoredGlobalIndexResult.createEmpty();
-        for (CompletableFuture<Optional<ScoredGlobalIndexResult>> f : futures) 
{
-            Optional<ScoredGlobalIndexResult> next = f.join();
-            if (next.isPresent()) {
-                result = result.or(next.get());
+        ScoredGlobalIndexResult[] merged = new ScoredGlobalIndexResult[n];
+        for (int i = 0; i < n; i++) {
+            merged[i] = ScoredGlobalIndexResult.createEmpty();
+        }
+
+        for (CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> future 
: futures) {
+            List<Optional<ScoredGlobalIndexResult>> splitResults = 
future.join();
+            for (int i = 0; i < n; i++) {
+                if (splitResults.get(i).isPresent()) {
+                    merged[i] = merged[i].or(splitResults.get(i).get());
+                }
             }
         }
 
-        return result.topK(limit);
+        List<GlobalIndexResult> results = new ArrayList<>(n);
+        for (int i = 0; i < n; i++) {
+            results.add(merged[i].topK(limit));
+        }
+        return results;
     }
 
     protected Optional<RoaringNavigableMap64> 
preFilter(List<VectorSearchSplit> splits) {
@@ -172,7 +196,7 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
         }
     }
 
-    protected CompletableFuture<Optional<ScoredGlobalIndexResult>> eval(
+    protected CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> 
evalBatch(
             GlobalIndexer globalIndexer,
             IndexPathFactory indexPathFactory,
             long rowRangeStart,
@@ -180,6 +204,23 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
             List<IndexFileMeta> vectorIndexFiles,
             @Nullable RoaringNavigableMap64 includeRowIds,
             ExecutorService executor) {
+        List<GlobalIndexIOMeta> indexIOMetaList =
+                buildIOMetaList(indexPathFactory, vectorIndexFiles);
+        @SuppressWarnings("resource")
+        FileIO fileIO = table.fileIO();
+        GlobalIndexFileReader indexFileReader = m -> 
fileIO.newInputStream(m.filePath());
+        GlobalIndexReader reader =
+                globalIndexer.createReader(indexFileReader, indexIOMetaList, 
executor);
+        BatchVectorSearch batchVectorSearch =
+                new BatchVectorSearch(vectors, limit, vectorColumn.name(), 
options)
+                        .withIncludeRowIds(includeRowIds);
+        return new OffsetGlobalIndexReader(reader, rowRangeStart, rowRangeEnd)
+                .visitBatchVectorSearch(batchVectorSearch)
+                .whenComplete((r, t) -> IOUtils.closeQuietly(reader));
+    }
+
+    private List<GlobalIndexIOMeta> buildIOMetaList(
+            IndexPathFactory indexPathFactory, List<IndexFileMeta> 
vectorIndexFiles) {
         List<GlobalIndexIOMeta> indexIOMetaList = new ArrayList<>();
         for (IndexFileMeta indexFile : vectorIndexFiles) {
             GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
@@ -189,16 +230,6 @@ public class VectorReadImpl implements VectorRead, 
Serializable {
                             indexFile.fileSize(),
                             meta.indexMeta()));
         }
-        @SuppressWarnings("resource")
-        FileIO fileIO = table.fileIO();
-        GlobalIndexFileReader indexFileReader = m -> 
fileIO.newInputStream(m.filePath());
-        GlobalIndexReader reader =
-                globalIndexer.createReader(indexFileReader, indexIOMetaList, 
executor);
-        VectorSearch vectorSearch =
-                new VectorSearch(vector, limit, vectorColumn.name(), options)
-                        .withIncludeRowIds(includeRowIds);
-        return new OffsetGlobalIndexReader(reader, rowRangeStart, rowRangeEnd)
-                .visitVectorSearch(vectorSearch)
-                .whenComplete((r, t) -> IOUtils.closeQuietly(reader));
+        return indexIOMetaList;
     }
 }
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 d4686b4416..b7652719ad 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
@@ -29,6 +29,7 @@ import java.util.HashMap;
 import java.util.Map;
 
 import static 
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicate;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
 /** Implementation for {@link VectorSearchBuilder}. */
 public class VectorSearchBuilderImpl implements VectorSearchBuilder {
@@ -105,6 +106,8 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
 
     @Override
     public VectorRead newVectorRead() {
-        return new VectorReadImpl(table, filter, limit, vectorColumn, vector, 
options);
+        checkNotNull(vector, "vector must be set via withVector()");
+        return new VectorReadImpl(
+                table, filter, limit, vectorColumn, new float[][] {vector}, 
options);
     }
 }
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 fb5ca42764..ed9705c786 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
@@ -572,6 +572,136 @@ public class VectorSearchBuilderTest extends 
TableTestBase {
         }
     }
 
+    @Test
+    public void testBatchVectorSearch() throws Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+
+        float[][] vectors = {
+            {1.0f, 0.0f},
+            {0.95f, 0.1f},
+            {0.1f, 0.95f},
+            {0.98f, 0.05f},
+            {0.0f, 1.0f},
+            {0.05f, 0.98f}
+        };
+
+        writeVectors(table, vectors);
+        buildAndCommitIndex(table, vectors);
+
+        float[][] queryVectors = {
+            {1.0f, 0.0f},
+            {0.0f, 1.0f},
+            {0.7f, 0.7f}
+        };
+
+        List<GlobalIndexResult> results =
+                table.newBatchVectorSearchBuilder()
+                        .withVectors(queryVectors)
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeBatchLocal();
+
+        assertThat(results).hasSize(3);
+
+        // Query 0 near (1,0): should find rows 0 (1,0) and 3 (0.98,0.05)
+        assertThat(results.get(0).results().isEmpty()).isFalse();
+        ReadBuilder rb0 = table.newReadBuilder();
+        List<Integer> ids0 = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                rb0.newRead()
+                        
.createReader(rb0.newScan().withGlobalIndexResult(results.get(0)).plan())) {
+            reader.forEachRemaining(row -> ids0.add(row.getInt(0)));
+        }
+        assertThat(ids0).contains(0);
+
+        // Query 1 near (0,1): should find rows 4 (0,1) and 5 (0.05,0.98)
+        assertThat(results.get(1).results().isEmpty()).isFalse();
+        ReadBuilder rb1 = table.newReadBuilder();
+        List<Integer> ids1 = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                rb1.newRead()
+                        
.createReader(rb1.newScan().withGlobalIndexResult(results.get(1)).plan())) {
+            reader.forEachRemaining(row -> ids1.add(row.getInt(0)));
+        }
+        assertThat(ids1).contains(4);
+    }
+
+    @Test
+    public void testBatchVectorSearchWithMultipleIndexFiles() throws Exception 
{
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+
+        float[][] allVectors = {
+            {1.0f, 0.0f},
+            {0.95f, 0.1f},
+            {0.1f, 0.95f},
+            {0.98f, 0.05f},
+            {0.0f, 1.0f},
+            {0.05f, 0.98f}
+        };
+
+        writeVectors(table, allVectors);
+        buildAndCommitMultipleIndexFiles(table, allVectors);
+
+        List<GlobalIndexResult> results =
+                table.newBatchVectorSearchBuilder()
+                        .withVectors(new float[][] {{0.85f, 0.15f}, {0.0f, 
1.0f}})
+                        .withLimit(3)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeBatchLocal();
+
+        assertThat(results).hasSize(2);
+
+        List<Integer> ids0 = readIds(table, results.get(0));
+        assertThat(ids0.size()).isLessThanOrEqualTo(3);
+        assertThat(ids0).contains(0, 3);
+
+        List<Integer> ids1 = readIds(table, results.get(1));
+        assertThat(ids1.size()).isLessThanOrEqualTo(3);
+        assertThat(ids1).contains(4, 5);
+    }
+
+    @Test
+    public void testBatchSingleVector() throws Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+
+        float[][] vectors = {
+            {1.0f, 0.0f},
+            {0.95f, 0.1f},
+            {0.0f, 1.0f},
+            {0.98f, 0.05f}
+        };
+
+        writeVectors(table, vectors);
+        buildAndCommitIndex(table, vectors);
+
+        float[] queryVector = {0.9f, 0.1f};
+
+        GlobalIndexResult singleResult =
+                table.newVectorSearchBuilder()
+                        .withVector(queryVector)
+                        .withLimit(3)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeLocal();
+
+        List<GlobalIndexResult> batchResults =
+                table.newBatchVectorSearchBuilder()
+                        .withVectors(new float[][] {queryVector})
+                        .withLimit(3)
+                        .withVectorColumn(VECTOR_FIELD_NAME)
+                        .executeBatchLocal();
+
+        assertThat(batchResults).hasSize(1);
+        assertThat(batchResults.get(0).results().getIntCardinality())
+                .isEqualTo(singleResult.results().getIntCardinality());
+
+        for (long rowId : singleResult.results()) {
+            assertThat(batchResults.get(0).results().contains(rowId)).isTrue();
+        }
+    }
+
     // ====================== Helper methods ======================
 
     private void writeVectors(FileStoreTable table, float[][] vectors) throws 
Exception {
@@ -601,6 +731,16 @@ public class VectorSearchBuilderTest extends TableTestBase 
{
         }
     }
 
+    private List<Integer> readIds(FileStoreTable table, GlobalIndexResult 
result) throws Exception {
+        ReadBuilder readBuilder = table.newReadBuilder();
+        TableScan.Plan plan = 
readBuilder.newScan().withGlobalIndexResult(result).plan();
+        List<Integer> ids = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(plan)) {
+            reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+        }
+        return ids;
+    }
+
     private void buildAndCommitIndex(FileStoreTable table, float[][] vectors) 
throws Exception {
         buildAndCommitIndex(table, VECTOR_FIELD_NAME, vectors);
     }
diff --git 
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexReader.java
 
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexReader.java
index 20fffa9d11..39aabb7f1a 100644
--- 
a/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexReader.java
+++ 
b/paimon-lumina/src/main/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexReader.java
@@ -24,6 +24,7 @@ import org.apache.paimon.globalindex.GlobalIndexReader;
 import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
+import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.types.ArrayType;
@@ -36,6 +37,7 @@ import org.apache.paimon.utils.RoaringNavigableMap64;
 import org.aliyun.lumina.LuminaFileInput;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -107,65 +109,118 @@ public class LuminaVectorGlobalIndexReader implements 
GlobalIndexReader {
                 executor);
     }
 
+    @Override
+    public CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> 
visitBatchVectorSearch(
+            BatchVectorSearch batchVectorSearch) {
+        return CompletableFuture.supplyAsync(
+                () -> {
+                    try {
+                        ensureLoaded();
+                        return searchBatch(batchVectorSearch);
+                    } catch (IOException e) {
+                        throw new RuntimeException(
+                                String.format(
+                                        "Failed to batch search Lumina vector 
index with fieldName=%s, limit=%d, vectorCount=%d",
+                                        batchVectorSearch.fieldName(),
+                                        batchVectorSearch.limit(),
+                                        batchVectorSearch.vectorCount()),
+                                e);
+                    }
+                },
+                executor);
+    }
+
+    private List<Optional<ScoredGlobalIndexResult>> 
searchBatch(BatchVectorSearch batchVectorSearch)
+            throws IOException {
+        int n = batchVectorSearch.vectorCount();
+        if (n == 1) {
+            List<Optional<ScoredGlobalIndexResult>> results = new 
ArrayList<>(1);
+            
results.add(Optional.ofNullable(search(batchVectorSearch.forIndex(0))));
+            return results;
+        }
+
+        float[][] vectors = batchVectorSearch.vectors();
+        int dim = indexMeta.dim();
+        for (float[] v : vectors) {
+            validateSearchVector(v);
+        }
+
+        int limit = batchVectorSearch.limit();
+        int effectiveK = (int) Math.min(limit, index.size());
+        if (effectiveK <= 0) {
+            return emptyResults(n);
+        }
+
+        float[] queryVectors = new float[n * dim];
+        for (int i = 0; i < n; i++) {
+            System.arraycopy(vectors[i], 0, queryVectors, i * dim, dim);
+        }
+
+        RoaringNavigableMap64 includeRowIds = 
batchVectorSearch.includeRowIds();
+        long[] scopedIds = toScopedIds(includeRowIds);
+        if (scopedIds != null && scopedIds.length == 0) {
+            return emptyResults(n);
+        }
+        if (scopedIds != null) {
+            effectiveK = Math.min(effectiveK, scopedIds.length);
+        }
+
+        float[] distances = new float[n * effectiveK];
+        long[] labels = new long[n * effectiveK];
+        Map<String, String> searchOptions =
+                buildSearchOptions(scopedIds != null, effectiveK, 
batchVectorSearch.options());
+
+        if (scopedIds != null) {
+            index.searchWithFilter(
+                    queryVectors, n, effectiveK, distances, labels, scopedIds, 
searchOptions);
+        } else {
+            index.search(queryVectors, n, effectiveK, distances, labels, 
searchOptions);
+        }
+
+        LuminaVectorMetric indexMetric = indexMeta.metric();
+        List<Optional<ScoredGlobalIndexResult>> results = new ArrayList<>(n);
+        for (int i = 0; i < n; i++) {
+            results.add(
+                    buildScoredResult(distances, labels, i * effectiveK, 
effectiveK, indexMetric));
+        }
+        return results;
+    }
+
     private ScoredGlobalIndexResult search(VectorSearch vectorSearch) throws 
IOException {
         validateSearchVector(vectorSearch.vector());
         float[] queryVector = vectorSearch.vector().clone();
-        int limit = vectorSearch.limit();
-        LuminaVectorMetric indexMetric = indexMeta.metric();
 
-        int effectiveK = (int) Math.min(limit, index.size());
+        int effectiveK = (int) Math.min(vectorSearch.limit(), index.size());
         if (effectiveK <= 0) {
             return null;
         }
 
         RoaringNavigableMap64 includeRowIds = vectorSearch.includeRowIds();
-        float[] distances;
-        long[] labels;
-
-        if (includeRowIds != null) {
-            long cardinality = includeRowIds.getLongCardinality();
-            if (cardinality > Integer.MAX_VALUE) {
-                throw new IllegalArgumentException(
-                        "includeRowIds cardinality ("
-                                + cardinality
-                                + ") exceeds Integer.MAX_VALUE");
-            }
-            long[] scopedIds = new long[(int) cardinality];
-            Iterator<Long> iter = includeRowIds.iterator();
-            for (int i = 0; i < scopedIds.length; i++) {
-                scopedIds[i] = iter.next();
-            }
-            if (scopedIds.length == 0) {
-                return null;
-            }
+        long[] scopedIds = toScopedIds(includeRowIds);
+        if (scopedIds != null && scopedIds.length == 0) {
+            return null;
+        }
+        if (scopedIds != null) {
             effectiveK = Math.min(effectiveK, scopedIds.length);
-            distances = new float[effectiveK];
-            labels = new long[effectiveK];
-            Map<String, String> mergedOptions =
-                    mergeOptions(
-                            this.options.toLuminaOptions(),
-                            indexMeta.options(),
-                            vectorSearch.options());
-            mergedOptions.put("search.thread_safe_filter", "true");
-            ensureSearchListSize(mergedOptions, effectiveK);
+        }
+
+        float[] distances = new float[effectiveK];
+        long[] labels = new long[effectiveK];
+        Map<String, String> searchOptions =
+                buildSearchOptions(scopedIds != null, effectiveK, 
vectorSearch.options());
+
+        if (scopedIds != null) {
             index.searchWithFilter(
-                    queryVector, 1, effectiveK, distances, labels, scopedIds, 
mergedOptions);
+                    queryVector, 1, effectiveK, distances, labels, scopedIds, 
searchOptions);
         } else {
-            distances = new float[effectiveK];
-            labels = new long[effectiveK];
-            Map<String, String> mergedOptions =
-                    mergeOptions(
-                            this.options.toLuminaOptions(),
-                            indexMeta.options(),
-                            vectorSearch.options());
-            ensureSearchListSize(mergedOptions, effectiveK);
-            index.search(queryVector, 1, effectiveK, distances, labels, 
mergedOptions);
+            index.search(queryVector, 1, effectiveK, distances, labels, 
searchOptions);
         }
 
+        LuminaVectorMetric indexMetric = indexMeta.metric();
         // Min-heap: smallest score at head, so we can evict the weakest 
candidate efficiently.
         PriorityQueue<ScoredRow> topK =
                 new PriorityQueue<>(effectiveK + 1, 
Comparator.comparingDouble(s -> s.score));
-        collectResults(distances, labels, effectiveK, effectiveK, topK, 
indexMetric);
+        collectResults(distances, labels, 0, effectiveK, effectiveK, topK, 
indexMetric);
 
         RoaringNavigableMap64 roaringBitmap64 = new RoaringNavigableMap64();
         HashMap<Long, Float> id2scores = new HashMap<>(topK.size());
@@ -176,36 +231,96 @@ public class LuminaVectorGlobalIndexReader implements 
GlobalIndexReader {
         return new LuminaScoredGlobalIndexResult(roaringBitmap64, id2scores);
     }
 
+    private long[] toScopedIds(RoaringNavigableMap64 includeRowIds) {
+        if (includeRowIds == null) {
+            return null;
+        }
+        long cardinality = includeRowIds.getLongCardinality();
+        if (cardinality > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException(
+                    "includeRowIds cardinality (" + cardinality + ") exceeds 
Integer.MAX_VALUE");
+        }
+        long[] scopedIds = new long[(int) cardinality];
+        Iterator<Long> iter = includeRowIds.iterator();
+        for (int i = 0; i < scopedIds.length; i++) {
+            scopedIds[i] = iter.next();
+        }
+        return scopedIds;
+    }
+
+    private Map<String, String> buildSearchOptions(
+            boolean withFilter, int effectiveK, Map<String, String> 
queryOptions) {
+        Map<String, String> searchOptions = options.toLuminaOptions();
+        searchOptions.putAll(indexMeta.options());
+        searchOptions.putAll(queryOptions);
+        if (withFilter) {
+            searchOptions.put("search.thread_safe_filter", "true");
+        }
+        ensureSearchListSize(searchOptions, effectiveK);
+        return searchOptions;
+    }
+
     static Map<String, String> mergeOptions(
             Map<String, String> baseOptions,
             Map<String, String> indexOptions,
             Map<String, String> queryOptions) {
-        Map<String, String> options = new HashMap<>(baseOptions);
-        options.putAll(indexOptions);
-        options.putAll(queryOptions);
-        return options;
+        Map<String, String> merged = new HashMap<>(baseOptions);
+        merged.putAll(indexOptions);
+        merged.putAll(queryOptions);
+        return merged;
+    }
+
+    private static Optional<ScoredGlobalIndexResult> buildScoredResult(
+            float[] distances,
+            long[] labels,
+            int offset,
+            int effectiveK,
+            LuminaVectorMetric indexMetric) {
+        PriorityQueue<ScoredRow> topK =
+                new PriorityQueue<>(effectiveK + 1, 
Comparator.comparingDouble(s -> s.score));
+        collectResults(distances, labels, offset, effectiveK, effectiveK, 
topK, indexMetric);
+        if (topK.isEmpty()) {
+            return Optional.empty();
+        }
+        RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+        HashMap<Long, Float> id2scores = new HashMap<>(topK.size());
+        for (ScoredRow row : topK) {
+            bitmap.add(row.rowId);
+            id2scores.put(row.rowId, row.score);
+        }
+        return Optional.of(new LuminaScoredGlobalIndexResult(bitmap, 
id2scores));
+    }
+
+    private static List<Optional<ScoredGlobalIndexResult>> emptyResults(int n) 
{
+        List<Optional<ScoredGlobalIndexResult>> results = new ArrayList<>(n);
+        for (int i = 0; i < n; i++) {
+            results.add(Optional.empty());
+        }
+        return results;
     }
 
-    private static void ensureSearchListSize(Map<String, String> options, int 
topK) {
-        if (!options.containsKey("diskann.search.list_size")) {
+    private static void ensureSearchListSize(Map<String, String> 
searchOptions, int topK) {
+        if (!searchOptions.containsKey("diskann.search.list_size")) {
             int listSize = Math.max((int) (topK * 1.5), MIN_SEARCH_LIST_SIZE);
-            options.put("diskann.search.list_size", String.valueOf(listSize));
+            searchOptions.put("diskann.search.list_size", 
String.valueOf(listSize));
         }
     }
 
     private static void collectResults(
             float[] distances,
             long[] labels,
+            int offset,
             int count,
             int limit,
             PriorityQueue<ScoredRow> topK,
             LuminaVectorMetric metric) {
         for (int i = 0; i < count; i++) {
-            long rowId = labels[i];
+            int index = offset + i;
+            long rowId = labels[index];
             if (rowId < 0) {
                 continue;
             }
-            float score = convertDistanceToScore(distances[i], metric);
+            float score = convertDistanceToScore(distances[index], metric);
             if (topK.size() < limit) {
                 topK.offer(new ScoredRow(rowId, score));
             } else if (score > topK.peek().score) {
diff --git 
a/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexTest.java
 
b/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexTest.java
index b9ab7e1219..86bad585b4 100644
--- 
a/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexTest.java
+++ 
b/paimon-lumina/src/test/java/org/apache/paimon/lumina/index/LuminaVectorGlobalIndexTest.java
@@ -25,9 +25,11 @@ import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.globalindex.GlobalIndexIOMeta;
 import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataType;
@@ -48,6 +50,8 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Random;
 import java.util.UUID;
 import java.util.concurrent.ExecutorService;
@@ -749,6 +753,206 @@ public class LuminaVectorGlobalIndexTest {
                 .hasMessageContaining("-Infinity");
     }
 
+    @Test
+    public void testBatchVectorSearch() throws IOException {
+        int dimension = 2;
+        Options options = createDefaultOptions(dimension);
+
+        float[][] vectors =
+                new float[][] {
+                    new float[] {1.0f, 0.0f},
+                    new float[] {0.95f, 0.1f},
+                    new float[] {0.1f, 0.95f},
+                    new float[] {0.98f, 0.05f},
+                    new float[] {0.0f, 1.0f},
+                    new float[] {0.05f, 0.98f}
+                };
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        LuminaVectorIndexOptions indexOptions = new 
LuminaVectorIndexOptions(options);
+        LuminaVectorGlobalIndexWriter writer =
+                new LuminaVectorGlobalIndexWriter(fileWriter, vectorType, 
indexOptions);
+        Arrays.stream(vectors).forEach(writer::write);
+
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (LuminaVectorGlobalIndexReader reader =
+                new LuminaVectorGlobalIndexReader(
+                        fileReader, metas, vectorType, indexOptions, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {
+                        new float[] {1.0f, 0.0f},
+                        new float[] {0.0f, 1.0f},
+                        new float[] {0.7f, 0.7f}
+                    };
+            BatchVectorSearch batchSearch = new 
BatchVectorSearch(queryVectors, 2, fieldName);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            assertThat(batchResults).hasSize(3);
+
+            assertThat(batchResults.get(0)).isPresent();
+            
assertThat(batchResults.get(0).get().results().contains(0L)).isTrue();
+            
assertThat(batchResults.get(0).get().results().contains(3L)).isTrue();
+
+            assertThat(batchResults.get(1)).isPresent();
+            
assertThat(batchResults.get(1).get().results().contains(4L)).isTrue();
+            
assertThat(batchResults.get(1).get().results().contains(5L)).isTrue();
+
+            assertThat(batchResults.get(2)).isPresent();
+            
assertThat(batchResults.get(2).get().results().getLongCardinality()).isEqualTo(2);
+        }
+    }
+
+    @Test
+    public void testBatchVectorSearchWithFilter() throws IOException {
+        int dimension = 2;
+        Options options = createDefaultOptions(dimension);
+
+        float[][] vectors =
+                new float[][] {
+                    new float[] {1.0f, 0.0f},
+                    new float[] {0.95f, 0.1f},
+                    new float[] {0.1f, 0.95f},
+                    new float[] {0.0f, 1.0f},
+                };
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        LuminaVectorIndexOptions indexOptions = new 
LuminaVectorIndexOptions(options);
+        LuminaVectorGlobalIndexWriter writer =
+                new LuminaVectorGlobalIndexWriter(fileWriter, vectorType, 
indexOptions);
+        Arrays.stream(vectors).forEach(writer::write);
+
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (LuminaVectorGlobalIndexReader reader =
+                new LuminaVectorGlobalIndexReader(
+                        fileReader, metas, vectorType, indexOptions, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {new float[] {1.0f, 0.0f}, new float[] 
{0.0f, 1.0f}};
+
+            RoaringNavigableMap64 filter = new RoaringNavigableMap64();
+            filter.add(1L);
+            filter.add(2L);
+
+            BatchVectorSearch batchSearch =
+                    new BatchVectorSearch(queryVectors, 2, 
fieldName).withIncludeRowIds(filter);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            assertThat(batchResults).hasSize(2);
+
+            assertThat(batchResults.get(0)).isPresent();
+            
assertThat(batchResults.get(0).get().results().contains(1L)).isTrue();
+
+            assertThat(batchResults.get(1)).isPresent();
+            
assertThat(batchResults.get(1).get().results().contains(2L)).isTrue();
+        }
+    }
+
+    @Test
+    public void testBatchConsistentWithSingle() throws IOException {
+        int dimension = 32;
+        int numVectors = 100;
+        Options options = createDefaultOptions(dimension);
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        LuminaVectorIndexOptions indexOptions = new 
LuminaVectorIndexOptions(options);
+        LuminaVectorGlobalIndexWriter writer =
+                new LuminaVectorGlobalIndexWriter(fileWriter, vectorType, 
indexOptions);
+
+        List<float[]> testVectors = generateRandomVectors(numVectors, 
dimension);
+        testVectors.forEach(writer::write);
+
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (LuminaVectorGlobalIndexReader reader =
+                new LuminaVectorGlobalIndexReader(
+                        fileReader, metas, vectorType, indexOptions, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {testVectors.get(10), testVectors.get(50), 
testVectors.get(90)};
+            int limit = 5;
+
+            BatchVectorSearch batchSearch = new 
BatchVectorSearch(queryVectors, limit, fieldName);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            for (int i = 0; i < queryVectors.length; i++) {
+                VectorSearch singleSearch = new VectorSearch(queryVectors[i], 
limit, fieldName);
+                Optional<ScoredGlobalIndexResult> singleResult =
+                        reader.visitVectorSearch(singleSearch).join();
+
+                
assertThat(batchResults.get(i).isPresent()).isEqualTo(singleResult.isPresent());
+                if (singleResult.isPresent()) {
+                    
assertThat(batchResults.get(i).get().results().getIntCardinality())
+                            
.isEqualTo(singleResult.get().results().getIntCardinality());
+                    for (long rowId : singleResult.get().results()) {
+                        
assertThat(batchResults.get(i).get().results().contains(rowId)).isTrue();
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testBatchVectorSearchAppliesQueryOptions() throws IOException {
+        int dimension = 32;
+        int numVectors = 100;
+        Options options = createDefaultOptions(dimension);
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        LuminaVectorIndexOptions indexOptions = new 
LuminaVectorIndexOptions(options);
+        LuminaVectorGlobalIndexWriter writer =
+                new LuminaVectorGlobalIndexWriter(fileWriter, vectorType, 
indexOptions);
+
+        List<float[]> testVectors = generateRandomVectors(numVectors, 
dimension);
+        testVectors.forEach(writer::write);
+
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (LuminaVectorGlobalIndexReader reader =
+                new LuminaVectorGlobalIndexReader(
+                        fileReader, metas, vectorType, indexOptions, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {testVectors.get(10), testVectors.get(50), 
testVectors.get(90)};
+            int limit = 20;
+
+            // A query option must reach the native batch call as it does for 
single search;
+            // if the batch path dropped it, this batch-vs-single equality 
would break.
+            Map<String, String> queryOptions =
+                    Collections.singletonMap("diskann.search.list_size", "1");
+
+            BatchVectorSearch batchSearch =
+                    new BatchVectorSearch(queryVectors, limit, fieldName, 
queryOptions);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            for (int i = 0; i < queryVectors.length; i++) {
+                VectorSearch singleSearch =
+                        new VectorSearch(queryVectors[i], limit, fieldName, 
queryOptions);
+                Optional<ScoredGlobalIndexResult> singleResult =
+                        reader.visitVectorSearch(singleSearch).join();
+
+                
assertThat(batchResults.get(i).isPresent()).isEqualTo(singleResult.isPresent());
+                if (singleResult.isPresent()) {
+                    
assertThat(batchResults.get(i).get().results().getIntCardinality())
+                            
.isEqualTo(singleResult.get().results().getIntCardinality());
+                    for (long rowId : singleResult.get().results()) {
+                        
assertThat(batchResults.get(i).get().results().contains(rowId)).isTrue();
+                    }
+                }
+            }
+        }
+    }
+
     private Options createDefaultOptions(int dimension) {
         Options options = new Options();
         options.setInteger(LuminaVectorIndexOptions.DIMENSION.key(), 
dimension);
diff --git a/paimon-python/pypaimon/table/file_store_table.py 
b/paimon-python/pypaimon/table/file_store_table.py
index 04f972d9e8..8805806883 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -434,6 +434,11 @@ class FileStoreTable(Table):
             HybridSearchBuilderImpl
         return HybridSearchBuilderImpl(self)
 
+    def new_batch_vector_search_builder(self) -> 'BatchVectorSearchBuilder':
+        from pypaimon.table.source.batch_vector_search_builder import \
+            BatchVectorSearchBuilderImpl
+        return BatchVectorSearchBuilderImpl(self)
+
     def create_row_key_extractor(self) -> RowKeyExtractor:
         bucket_mode = self.bucket_mode()
         if bucket_mode == BucketMode.HASH_FIXED:
diff --git a/paimon-python/pypaimon/table/format/format_table.py 
b/paimon-python/pypaimon/table/format/format_table.py
index 795835f47a..1a1218153d 100644
--- a/paimon-python/pypaimon/table/format/format_table.py
+++ b/paimon-python/pypaimon/table/format/format_table.py
@@ -112,3 +112,6 @@ class FormatTable(Table):
 
     def new_hybrid_search_builder(self):
         raise NotImplementedError("Format table does not support hybrid 
search.")
+
+    def new_batch_vector_search_builder(self):
+        raise NotImplementedError("Format table does not support vector 
search.")
diff --git a/paimon-python/pypaimon/table/iceberg/iceberg_table.py 
b/paimon-python/pypaimon/table/iceberg/iceberg_table.py
index 51f099ee0f..8bf17e9544 100644
--- a/paimon-python/pypaimon/table/iceberg/iceberg_table.py
+++ b/paimon-python/pypaimon/table/iceberg/iceberg_table.py
@@ -117,3 +117,6 @@ class IcebergTable(Table):
 
     def new_hybrid_search_builder(self):
         raise NotImplementedError("IcebergTable does not support hybrid 
search.")
+
+    def new_batch_vector_search_builder(self):
+        raise NotImplementedError("IcebergTable does not support vector 
search.")
diff --git a/paimon-python/pypaimon/table/object/object_table.py 
b/paimon-python/pypaimon/table/object/object_table.py
index d12e00c616..f875b4d3f6 100644
--- a/paimon-python/pypaimon/table/object/object_table.py
+++ b/paimon-python/pypaimon/table/object/object_table.py
@@ -117,3 +117,8 @@ class ObjectTable(Table):
         raise NotImplementedError(
             "ObjectTable is read-only and does not support hybrid search."
         )
+
+    def new_batch_vector_search_builder(self):
+        raise NotImplementedError(
+            "ObjectTable is read-only and does not support vector search."
+        )
diff --git a/paimon-python/pypaimon/table/source/batch_vector_search_builder.py 
b/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
new file mode 100644
index 0000000000..03477ef73b
--- /dev/null
+++ b/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
@@ -0,0 +1,124 @@
+# 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.
+
+"""Builder to build batch vector search over multiple query vectors."""
+
+from abc import ABC, abstractmethod
+
+from pypaimon.table.source.vector_search_builder import (
+    AbstractVectorSearchBuilderImpl,
+)
+from pypaimon.table.source.vector_search_read import BatchVectorSearchReadImpl
+
+
+class BatchVectorSearchBuilder(ABC):
+    """Builder to build batch vector search; result ``i`` matches vector 
``i``."""
+
+    @abstractmethod
+    def with_limit(self, limit):
+        # type: (int) -> BatchVectorSearchBuilder
+        """The top k results to return per query vector."""
+        pass
+
+    @abstractmethod
+    def with_vector_column(self, name):
+        # type: (str) -> BatchVectorSearchBuilder
+        """The vector column to search."""
+        pass
+
+    @abstractmethod
+    def with_query_vectors(self, vectors):
+        # type: (list) -> BatchVectorSearchBuilder
+        """The query vectors (list of list of floats); result i matches 
vectors[i]."""
+        pass
+
+    def with_option(self, key, value):
+        # type: (str, str) -> BatchVectorSearchBuilder
+        """Option for vector indexes."""
+        raise NotImplementedError(
+            "%s does not support vector options."
+            % self.__class__.__name__)
+
+    def with_options(self, options):
+        # type: (dict) -> BatchVectorSearchBuilder
+        """Options for vector indexes."""
+        raise NotImplementedError(
+            "%s does not support vector options."
+            % self.__class__.__name__)
+
+    @abstractmethod
+    def with_filter(self, predicate):
+        # type: (Predicate) -> BatchVectorSearchBuilder
+        """Scalar predicate used to pre-filter rows before vector search."""
+        pass
+
+    @abstractmethod
+    def with_partition_filter(self, partition_filter):
+        # type: (Predicate) -> BatchVectorSearchBuilder
+        """Partition predicate used to prune index manifest entries."""
+        pass
+
+    @abstractmethod
+    def new_vector_search_scan(self):
+        # type: () -> VectorSearchScan
+        """Create vector search scan to scan index files."""
+        pass
+
+    @abstractmethod
+    def new_batch_vector_search_read(self):
+        # type: () -> BatchVectorSearchReadImpl
+        """Create batch vector search read to read index files."""
+        pass
+
+    def execute_batch_local(self):
+        # type: () -> List[GlobalIndexResult]
+        """Execute batch vector search locally; result i matches query vector 
i."""
+        return self.new_batch_vector_search_read().read_batch(
+            self.new_vector_search_scan().scan().splits()
+        )
+
+
+class BatchVectorSearchBuilderImpl(AbstractVectorSearchBuilderImpl,
+                                   BatchVectorSearchBuilder):
+    """Implementation for BatchVectorSearchBuilder."""
+
+    def __init__(self, table):
+        super().__init__(table)
+        self._query_vectors = None
+
+    def with_query_vectors(self, vectors):
+        # type: (list) -> BatchVectorSearchBuilder
+        self._query_vectors = vectors
+        return self
+
+    def new_batch_vector_search_read(self):
+        # type: () -> BatchVectorSearchReadImpl
+        if self._limit <= 0:
+            raise ValueError("Limit must be positive, set via with_limit()")
+        if self._vector_column is None:
+            raise ValueError("Vector column must be set via 
with_vector_column()")
+        if not self._query_vectors:
+            raise ValueError(
+                "Query vectors must be set via with_query_vectors()")
+        return BatchVectorSearchReadImpl(
+            self._table,
+            self._limit,
+            self._vector_column,
+            self._query_vectors,
+            filter_=self._filter,
+            options=self._options,
+        )
diff --git a/paimon-python/pypaimon/table/source/vector_search_builder.py 
b/paimon-python/pypaimon/table/source/vector_search_builder.py
index 7cb9eb0104..f985471929 100644
--- a/paimon-python/pypaimon/table/source/vector_search_builder.py
+++ b/paimon-python/pypaimon/table/source/vector_search_builder.py
@@ -91,14 +91,13 @@ class VectorSearchBuilder(ABC):
         )
 
 
-class VectorSearchBuilderImpl(VectorSearchBuilder):
-    """Implementation for VectorSearchBuilder."""
+class AbstractVectorSearchBuilderImpl:
+    """Shared state and filter/partition handling for the vector search 
builders."""
 
     def __init__(self, table):
         self._table = table
         self._limit = 0
         self._vector_column = None
-        self._query_vector = None
         self._filter = None
         self._partition_filter = None
         self._options = {}
@@ -116,11 +115,6 @@ class VectorSearchBuilderImpl(VectorSearchBuilder):
         self._vector_column = field_dict[name]
         return self
 
-    def with_query_vector(self, vector):
-        # type: (list) -> VectorSearchBuilder
-        self._query_vector = vector
-        return self
-
     def with_option(self, key, value):
         # type: (str, str) -> VectorSearchBuilder
         self._options[key] = value
@@ -224,6 +218,19 @@ class VectorSearchBuilderImpl(VectorSearchBuilder):
             partition_filter=self._partition_filter,
         )
 
+
+class VectorSearchBuilderImpl(AbstractVectorSearchBuilderImpl, 
VectorSearchBuilder):
+    """Implementation for VectorSearchBuilder."""
+
+    def __init__(self, table):
+        super().__init__(table)
+        self._query_vector = None
+
+    def with_query_vector(self, vector):
+        # type: (list) -> VectorSearchBuilder
+        self._query_vector = vector
+        return self
+
     def new_vector_search_read(self):
         # type: () -> VectorSearchRead
         if self._limit <= 0:
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 2abac3bed8..bd2c6f9287 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -58,11 +58,15 @@ class VectorSearchReadImpl(VectorSearchRead):
             return GlobalIndexResult.create_empty()
 
         pre_filter = self._pre_filter(splits)
+        return self._search_one(self._query_vector, splits, pre_filter)
 
+    def _search_one(self, query_vector, splits, pre_filter):
+        # type: (list, list, Optional[RoaringBitmap64]) -> GlobalIndexResult
+        """Search one query vector across all splits and merge per-split 
results."""
         futures = [
             self._eval(
                 split.row_range_start, split.row_range_end,
-                split.vector_index_files, pre_filter
+                split.vector_index_files, query_vector, pre_filter
             )
             for split in splits
         ]
@@ -112,7 +116,7 @@ class VectorSearchReadImpl(VectorSearchRead):
             scanner.close()
 
     def _eval(self, row_range_start, row_range_end, vector_index_files,
-              include_row_ids):
+              query_vector, include_row_ids):
         from pypaimon.globalindex.global_index_reader import _completed_future
 
         if not vector_index_files:
@@ -136,7 +140,7 @@ class VectorSearchReadImpl(VectorSearchRead):
         options = self._table.table_schema.options
 
         vector_search = VectorSearch(
-            vector=self._query_vector,
+            vector=query_vector,
             limit=self._limit,
             field_name=self._vector_column.name,
             options=self._options,
@@ -154,6 +158,27 @@ class VectorSearchReadImpl(VectorSearchRead):
         return future
 
 
+class BatchVectorSearchReadImpl(VectorSearchReadImpl):
+    """Batch vector search read; result ``i`` corresponds to query vector 
``i``."""
+
+    def __init__(self, table, limit, vector_column, query_vectors,
+                 filter_=None, options=None):
+        super().__init__(table, limit, vector_column, None,
+                         filter_=filter_, options=options)
+        self._query_vectors = list(query_vectors)
+
+    def read_batch(self, splits):
+        # type: (List[VectorSearchSplit]) -> List[GlobalIndexResult]
+        n = len(self._query_vectors)
+        if not splits:
+            return [GlobalIndexResult.create_empty() for _ in range(n)]
+
+        pre_filter = self._pre_filter(splits)
+        # result i corresponds to query_vectors[i], in input order.
+        return [self._search_one(vector, splits, pre_filter)
+                for vector in self._query_vectors]
+
+
 def _create_vector_reader(index_type, file_io, index_path, index_io_meta_list, 
options=None):
     """Create a global index reader for vector search."""
     from pypaimon.globalindex.lumina.lumina_vector_global_index_reader import (
diff --git a/paimon-python/pypaimon/table/system/system_table.py 
b/paimon-python/pypaimon/table/system/system_table.py
index 8432470076..7ace7f23f3 100644
--- a/paimon-python/pypaimon/table/system/system_table.py
+++ b/paimon-python/pypaimon/table/system/system_table.py
@@ -105,6 +105,9 @@ class SystemTable(Table):
     def new_hybrid_search_builder(self):
         raise NotImplementedError(_READ_ONLY_MESSAGE)
 
+    def new_batch_vector_search_builder(self):
+        raise NotImplementedError(_READ_ONLY_MESSAGE)
+
 
 class SystemReadBuilder:
     """ReadBuilder-shaped facade exposing the system table's data.
diff --git a/paimon-python/pypaimon/table/table.py 
b/paimon-python/pypaimon/table/table.py
index a89eab6e74..b650e7dbb1 100644
--- a/paimon-python/pypaimon/table/table.py
+++ b/paimon-python/pypaimon/table/table.py
@@ -19,6 +19,7 @@ from abc import ABC, abstractmethod
 
 from pypaimon.read.read_builder import ReadBuilder
 from pypaimon.read.stream_read_builder import StreamReadBuilder
+from pypaimon.table.source.batch_vector_search_builder import 
BatchVectorSearchBuilder
 from pypaimon.table.source.full_text_search_builder import 
FullTextSearchBuilder
 from pypaimon.table.source.hybrid_search_builder import HybridSearchBuilder
 from pypaimon.table.source.vector_search_builder import VectorSearchBuilder
@@ -55,3 +56,9 @@ class Table(ABC):
     @abstractmethod
     def new_hybrid_search_builder(self) -> HybridSearchBuilder:
         """Returns a new hybrid search builder."""
+
+    def new_batch_vector_search_builder(self) -> BatchVectorSearchBuilder:
+        """Returns a new batch vector search builder."""
+        raise NotImplementedError(
+            "%s does not support batch vector search."
+            % self.__class__.__name__)
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 90cb5bb0e4..5aa886220f 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -1595,5 +1595,88 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         mock.patch.stopall()
 
 
+class BatchVectorSearchTest(unittest.TestCase):
+    """Batch vector search returns one result per query vector, in input 
order."""
+
+    def test_batch_returns_per_query_results_in_order(self):
+        from pypaimon.globalindex.vector_search_result import (
+            DictBasedScoredIndexResult,
+        )
+        from pypaimon.table.source.batch_vector_search_builder import (
+            BatchVectorSearchBuilderImpl,
+        )
+
+        embedding_field = _field(1, "embedding", "FLOAT")
+        entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+                       file_name="vec.index",
+                       row_range_start=0, row_range_end=99)
+        table = _StubTable(fields=[embedding_field], entries=[entry])
+        _patch_snapshot(self, [entry])
+
+        # The fake reader routes each query vector to a distinct row id derived
+        # from the vector itself, so result i must reflect query_vectors[i].
+        def _fake_create(index_type, file_io, index_path,
+                         index_io_meta_list, options=None):
+            class _FakeReader:
+                def visit_vector_search(self_inner, vs):
+                    row_id = int(vs.vector[0])
+                    return _completed_future(
+                        DictBasedScoredIndexResult({row_id: 1.0}))
+
+                def close(self_inner):
+                    pass
+
+                def __enter__(self_inner):
+                    return self_inner
+
+                def __exit__(self_inner, *a):
+                    return False
+            return _FakeReader()
+
+        query_vectors = [[10.0], [20.0], [30.0]]
+        with mock.patch(
+                
"pypaimon.table.source.vector_search_read._create_vector_reader",
+                side_effect=_fake_create):
+            results = (
+                BatchVectorSearchBuilderImpl(table)
+                .with_vector_column("embedding")
+                .with_query_vectors(query_vectors)
+                .with_limit(5)
+                .execute_batch_local()
+            )
+
+        self.assertEqual(len(results), len(query_vectors))
+        for i, query_vector in enumerate(query_vectors):
+            expected_row = int(query_vector[0])
+            self.assertTrue(results[i].results().contains(expected_row))
+            self.assertEqual(results[i].results().cardinality(), 1)
+        # Different query vectors yield different results.
+        self.assertNotEqual(
+            list(results[0].results()), list(results[1].results()))
+
+    def test_batch_empty_splits_returns_empty_per_query(self):
+        from pypaimon.table.source.batch_vector_search_builder import (
+            BatchVectorSearchBuilderImpl,
+        )
+
+        embedding_field = _field(1, "embedding", "FLOAT")
+        table = _StubTable(fields=[embedding_field], entries=[])
+        _patch_snapshot(self, [])
+
+        results = (
+            BatchVectorSearchBuilderImpl(table)
+            .with_vector_column("embedding")
+            .with_query_vectors([[1.0], [2.0]])
+            .with_limit(5)
+            .execute_batch_local()
+        )
+        self.assertEqual(len(results), 2)
+        for result in results:
+            self.assertEqual(result.results().cardinality(), 0)
+
+    def tearDown(self):
+        mock.patch.stopall()
+
+
 if __name__ == "__main__":
     unittest.main()
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 525ed334a8..94e45316ce 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
@@ -45,6 +45,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 
 import static org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
+import static org.apache.paimon.utils.Preconditions.checkState;
 
 /**
  * Spark-aware {@link VectorReadImpl} that distributes grouped vector index 
evaluation across the
@@ -59,8 +60,8 @@ public class SparkVectorReadImpl extends VectorReadImpl {
             Predicate filter,
             int limit,
             DataField vectorColumn,
-            float[] vector) {
-        super(table, filter, limit, vectorColumn, vector);
+            float[][] vectors) {
+        super(table, filter, limit, vectorColumn, vectors);
     }
 
     public SparkVectorReadImpl(
@@ -68,13 +69,16 @@ public class SparkVectorReadImpl extends VectorReadImpl {
             Predicate filter,
             int limit,
             DataField vectorColumn,
-            float[] vector,
+            float[][] vectors,
             Map<String, String> options) {
-        super(table, filter, limit, vectorColumn, vector, options);
+        super(table, filter, limit, vectorColumn, vectors, options);
     }
 
     @Override
     public GlobalIndexResult read(List<VectorSearchSplit> splits) {
+        checkState(
+                vectors.length == 1,
+                "read() is single-vector only; use readBatch() for multiple 
vectors");
         if (splits.isEmpty()) {
             return GlobalIndexResult.createEmpty();
         }
@@ -113,12 +117,12 @@ public class SparkVectorReadImpl extends VectorReadImpl {
                     ExecutorService executor =
                             GlobalIndexReadThreadPool.getExecutorService(
                                     Math.min(parallelism, group.size()));
-                    List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> 
futures =
+                    
List<CompletableFuture<List<Optional<ScoredGlobalIndexResult>>>> futures =
                             new ArrayList<>(group.size());
                     for (byte[] bytes : group) {
                         VectorSearchSplit split = deserializeSplit(bytes);
                         futures.add(
-                                eval(
+                                evalBatch(
                                         globalIndexer,
                                         indexPathFactory,
                                         split.rowRangeStart(),
@@ -129,8 +133,9 @@ public class SparkVectorReadImpl extends VectorReadImpl {
                     }
                     CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).join();
                     ScoredGlobalIndexResult result = 
ScoredGlobalIndexResult.createEmpty();
-                    for (CompletableFuture<Optional<ScoredGlobalIndexResult>> 
f : futures) {
-                        Optional<ScoredGlobalIndexResult> next = f.join();
+                    for 
(CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> f : futures) {
+                        // Spark carries a single query vector, so the batch 
result has one element.
+                        Optional<ScoredGlobalIndexResult> next = 
f.join().get(0);
                         if (next.isPresent()) {
                             result = result.or(next.get());
                         }
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 bd19a9e565..91928b7f84 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
@@ -25,6 +25,8 @@ import org.apache.paimon.table.source.VectorSearchBuilderImpl;
 /**
  * Spark-aware {@link VectorSearchBuilderImpl} which produces a {@link 
SparkVectorReadImpl} so the
  * per-split vector index evaluation is dispatched through Spark instead of 
the local thread pool.
+ *
+ * <p>Single-vector only; batch search has no Spark-dispatched path yet (TODO).
  */
 public class SparkVectorSearchBuilderImpl extends VectorSearchBuilderImpl {
 
@@ -36,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, filter, limit, vectorColumn, new float[][] {vector}, 
options);
     }
 }
diff --git 
a/paimon-vector/paimon-vector-index/src/main/java/org/apache/paimon/vector/index/VectorGlobalIndexReader.java
 
b/paimon-vector/paimon-vector-index/src/main/java/org/apache/paimon/vector/index/VectorGlobalIndexReader.java
index 54f8532a22..cde8d2a83d 100644
--- 
a/paimon-vector/paimon-vector-index/src/main/java/org/apache/paimon/vector/index/VectorGlobalIndexReader.java
+++ 
b/paimon-vector/paimon-vector-index/src/main/java/org/apache/paimon/vector/index/VectorGlobalIndexReader.java
@@ -30,7 +30,9 @@ import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.index.vector.VectorIndexInput;
 import org.apache.paimon.index.vector.VectorIndexMetadata;
 import org.apache.paimon.index.vector.VectorIndexReader;
+import org.apache.paimon.index.vector.VectorSearchBatchResult;
 import org.apache.paimon.index.vector.VectorSearchResult;
+import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.types.ArrayType;
@@ -107,6 +109,74 @@ public class VectorGlobalIndexReader implements 
GlobalIndexReader {
                 executor);
     }
 
+    @Override
+    public CompletableFuture<List<Optional<ScoredGlobalIndexResult>>> 
visitBatchVectorSearch(
+            BatchVectorSearch batchVectorSearch) {
+        return CompletableFuture.supplyAsync(
+                () -> {
+                    try {
+                        ensureLoaded();
+                        return searchBatch(batchVectorSearch);
+                    } catch (IOException e) {
+                        throw new RuntimeException(
+                                String.format(
+                                        "Failed batch vector index search: 
field=%s, limit=%d, vectorCount=%d",
+                                        batchVectorSearch.fieldName(),
+                                        batchVectorSearch.limit(),
+                                        batchVectorSearch.vectorCount()),
+                                e);
+                    }
+                },
+                executor);
+    }
+
+    private List<Optional<ScoredGlobalIndexResult>> 
searchBatch(BatchVectorSearch batchVectorSearch)
+            throws IOException {
+        int n = batchVectorSearch.vectorCount();
+        // Single vector: reuse the scalar path; no batching benefit.
+        if (n == 1) {
+            List<Optional<ScoredGlobalIndexResult>> results = new 
ArrayList<>(1);
+            
results.add(Optional.ofNullable(search(batchVectorSearch.forIndex(0))));
+            return results;
+        }
+
+        float[][] vectors = batchVectorSearch.vectors();
+        for (float[] vector : vectors) {
+            validateSearchVector(vector);
+        }
+        int dim = nativeMeta.dimension();
+        int nprobe = nprobe(batchVectorSearch.options());
+        int efSearch = efSearch(batchVectorSearch.options());
+        String metric = nativeMeta.metric();
+
+        SearchScope scope =
+                resolveScope(batchVectorSearch.includeRowIds(), 
batchVectorSearch.limit());
+        if (scope == null) {
+            return emptyResults(n);
+        }
+
+        // Flatten query vectors into one contiguous array for a single native 
call.
+        float[] queries = new float[n * dim];
+        for (int i = 0; i < n; i++) {
+            System.arraycopy(vectors[i], 0, queries, i * dim, dim);
+        }
+
+        VectorSearchBatchResult batchResult =
+                scope.filterBytes != null
+                        ? vectorReader.searchBatch(
+                                queries, n, scope.effectiveK, nprobe, 
efSearch, scope.filterBytes)
+                        : vectorReader.searchBatch(queries, n, 
scope.effectiveK, nprobe, efSearch);
+
+        // result i corresponds to vectors[i], matching input order.
+        List<Optional<ScoredGlobalIndexResult>> results = new ArrayList<>(n);
+        for (int i = 0; i < n; i++) {
+            results.add(
+                    buildScoredResult(
+                            batchResult.idsForQuery(i), 
batchResult.distancesForQuery(i), metric));
+        }
+        return results;
+    }
+
     private ScoredGlobalIndexResult search(VectorSearch vectorSearch) throws 
IOException {
         validateSearchVector(vectorSearch.vector());
         float[] queryVector = vectorSearch.vector().clone();
@@ -115,26 +185,23 @@ public class VectorGlobalIndexReader implements 
GlobalIndexReader {
         int efSearch = efSearch(vectorSearch.options());
         String metric = nativeMeta.metric();
 
-        RoaringNavigableMap64 includeRowIds = vectorSearch.includeRowIds();
-        VectorSearchResult result;
-
-        if (includeRowIds != null) {
-            long cardinality = includeRowIds.getLongCardinality();
-            if (cardinality == 0) {
-                return null;
-            }
-            byte[] filterBytes = includeRowIds.serialize();
-            int effectiveK = (int) Math.min(limit, cardinality);
-            result = vectorReader.search(queryVector, effectiveK, nprobe, 
efSearch, filterBytes);
-        } else {
-            result = vectorReader.search(queryVector, limit, nprobe, efSearch);
+        SearchScope scope = resolveScope(vectorSearch.includeRowIds(), limit);
+        if (scope == null) {
+            return null;
         }
+        VectorSearchResult result =
+                scope.filterBytes != null
+                        ? vectorReader.search(
+                                queryVector, scope.effectiveK, nprobe, 
efSearch, scope.filterBytes)
+                        : vectorReader.search(queryVector, scope.effectiveK, 
nprobe, efSearch);
 
-        long[] ids = result.ids();
-        float[] distances = result.distances();
+        return buildScoredResult(result.ids(), result.distances(), 
metric).orElse(null);
+    }
 
+    private static Optional<ScoredGlobalIndexResult> buildScoredResult(
+            long[] ids, float[] distances, String metric) {
         if (ids.length == 0) {
-            return null;
+            return Optional.empty();
         }
 
         RoaringNavigableMap64 resultBitmap = new RoaringNavigableMap64();
@@ -151,21 +218,54 @@ public class VectorGlobalIndexReader implements 
GlobalIndexReader {
         }
 
         if (resultBitmap.isEmpty()) {
+            return Optional.empty();
+        }
+
+        return Optional.of(
+                ScoredGlobalIndexResult.create(
+                        resultBitmap,
+                        rowId -> {
+                            Float score = id2scores.get(rowId);
+                            if (score == null) {
+                                throw new IllegalArgumentException(
+                                        "No score found for rowId: "
+                                                + rowId
+                                                + ". Only rowIds present in 
results() are valid.");
+                            }
+                            return score;
+                        }));
+    }
+
+    private static List<Optional<ScoredGlobalIndexResult>> emptyResults(int n) 
{
+        List<Optional<ScoredGlobalIndexResult>> results = new ArrayList<>(n);
+        for (int i = 0; i < n; i++) {
+            results.add(Optional.empty());
+        }
+        return results;
+    }
+
+    /** Resolves filter bytes and effective top-K; returns null when the 
filter selects no rows. */
+    private static SearchScope resolveScope(RoaringNavigableMap64 
includeRowIds, int limit)
+            throws IOException {
+        if (includeRowIds == null) {
+            return new SearchScope(null, limit);
+        }
+        long cardinality = includeRowIds.getLongCardinality();
+        if (cardinality == 0) {
             return null;
         }
+        return new SearchScope(includeRowIds.serialize(), (int) 
Math.min(limit, cardinality));
+    }
 
-        return ScoredGlobalIndexResult.create(
-                resultBitmap,
-                rowId -> {
-                    Float score = id2scores.get(rowId);
-                    if (score == null) {
-                        throw new IllegalArgumentException(
-                                "No score found for rowId: "
-                                        + rowId
-                                        + ". Only rowIds present in results() 
are valid.");
-                    }
-                    return score;
-                });
+    /** Resolved filter state for a query. {@code filterBytes} is null when no 
filter is set. */
+    private static final class SearchScope {
+        private final byte[] filterBytes;
+        private final int effectiveK;
+
+        private SearchScope(byte[] filterBytes, int effectiveK) {
+            this.filterBytes = filterBytes;
+            this.effectiveK = effectiveK;
+        }
     }
 
     private static float convertDistanceToScore(float distance, String metric) 
{
diff --git 
a/paimon-vector/paimon-vector-index/src/test/java/org/apache/paimon/vector/index/VectorGlobalIndexTest.java
 
b/paimon-vector/paimon-vector-index/src/test/java/org/apache/paimon/vector/index/VectorGlobalIndexTest.java
index e82664f806..bd4fc35884 100644
--- 
a/paimon-vector/paimon-vector-index/src/test/java/org/apache/paimon/vector/index/VectorGlobalIndexTest.java
+++ 
b/paimon-vector/paimon-vector-index/src/test/java/org/apache/paimon/vector/index/VectorGlobalIndexTest.java
@@ -29,6 +29,7 @@ import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
 import org.apache.paimon.index.vector.NativeLoader;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.BatchVectorSearch;
 import org.apache.paimon.predicate.VectorSearch;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataType;
@@ -49,6 +50,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -371,6 +373,172 @@ public class VectorGlobalIndexTest {
         }
     }
 
+    @Test
+    public void testBatchVectorSearch() throws IOException {
+        Assumptions.assumeTrue(isNativeAvailable(), "Vector index native 
library not available");
+
+        int dimension = 2;
+        Options options = createDefaultOptions(dimension);
+        options.setInteger("ivf-pq.nlist", 2);
+        options.setInteger("ivf-pq.pq.m", 1);
+
+        float[][] vectors =
+                new float[][] {
+                    new float[] {1.0f, 0.0f},
+                    new float[] {0.95f, 0.1f},
+                    new float[] {0.1f, 0.95f},
+                    new float[] {0.98f, 0.05f},
+                    new float[] {0.0f, 1.0f},
+                    new float[] {0.05f, 0.98f}
+                };
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        VectorGlobalIndexWriter writer = createIvfPqWriter(fileWriter, 
vectorType, options);
+        Arrays.stream(vectors).forEach(writer::write);
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (VectorGlobalIndexReader reader =
+                new VectorGlobalIndexReader(fileReader, metas, vectorType, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {
+                        new float[] {1.0f, 0.0f},
+                        new float[] {0.0f, 1.0f},
+                        new float[] {0.7f, 0.7f}
+                    };
+            BatchVectorSearch batchSearch = new 
BatchVectorSearch(queryVectors, 3, fieldName);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            // result i corresponds to queryVectors[i], in input order.
+            assertThat(batchResults).hasSize(3);
+
+            assertThat(batchResults.get(0)).isPresent();
+            
assertThat(batchResults.get(0).get().results().contains(0L)).isTrue();
+
+            assertThat(batchResults.get(1)).isPresent();
+            
assertThat(batchResults.get(1).get().results().contains(4L)).isTrue();
+
+            assertThat(batchResults.get(2)).isPresent();
+            
assertThat(batchResults.get(2).get().results().getLongCardinality()).isEqualTo(3);
+        }
+    }
+
+    @Test
+    public void testBatchVectorSearchWithFilter() throws IOException {
+        Assumptions.assumeTrue(isNativeAvailable(), "Vector index native 
library not available");
+
+        int dimension = 2;
+        Options options = createDefaultOptions(dimension);
+        options.setInteger("ivf-pq.nlist", 2);
+        options.setInteger("ivf-pq.pq.m", 1);
+
+        float[][] vectors =
+                new float[][] {
+                    new float[] {1.0f, 0.0f},
+                    new float[] {0.95f, 0.1f},
+                    new float[] {0.9f, 0.2f},
+                    new float[] {-1.0f, 0.0f},
+                    new float[] {-0.95f, 0.1f},
+                    new float[] {-0.9f, 0.2f}
+                };
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        VectorGlobalIndexWriter writer = createIvfPqWriter(fileWriter, 
vectorType, options);
+        Arrays.stream(vectors).forEach(writer::write);
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (VectorGlobalIndexReader reader =
+                new VectorGlobalIndexReader(fileReader, metas, vectorType, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {new float[] {1.0f, 0.0f}, new float[] 
{-1.0f, 0.0f}};
+
+            // Both queries are scoped to rows {1, 4}.
+            RoaringNavigableMap64 filter = new RoaringNavigableMap64();
+            filter.add(1L);
+            filter.add(4L);
+
+            BatchVectorSearch batchSearch =
+                    new BatchVectorSearch(queryVectors, 6, 
fieldName).withIncludeRowIds(filter);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            assertThat(batchResults).hasSize(2);
+
+            assertThat(batchResults.get(0)).isPresent();
+            
assertThat(batchResults.get(0).get().results().getLongCardinality()).isEqualTo(2);
+            
assertThat(batchResults.get(0).get().results().contains(1L)).isTrue();
+            
assertThat(batchResults.get(0).get().results().contains(4L)).isTrue();
+
+            assertThat(batchResults.get(1)).isPresent();
+            
assertThat(batchResults.get(1).get().results().getLongCardinality()).isEqualTo(2);
+            
assertThat(batchResults.get(1).get().results().contains(1L)).isTrue();
+            
assertThat(batchResults.get(1).get().results().contains(4L)).isTrue();
+        }
+    }
+
+    @Test
+    public void testBatchConsistentWithSingle() throws IOException {
+        Assumptions.assumeTrue(isNativeAvailable(), "Vector index native 
library not available");
+
+        int dimension = 2;
+        Options options = createDefaultOptions(dimension);
+        options.setInteger("ivf-pq.nlist", 2);
+        options.setInteger("ivf-pq.pq.m", 1);
+
+        float[][] vectors =
+                new float[][] {
+                    new float[] {1.0f, 0.0f},
+                    new float[] {0.95f, 0.1f},
+                    new float[] {0.1f, 0.95f},
+                    new float[] {0.98f, 0.05f},
+                    new float[] {0.0f, 1.0f},
+                    new float[] {0.05f, 0.98f}
+                };
+
+        GlobalIndexFileWriter fileWriter = createFileWriter(indexPath);
+        VectorGlobalIndexWriter writer = createIvfPqWriter(fileWriter, 
vectorType, options);
+        Arrays.stream(vectors).forEach(writer::write);
+        List<ResultEntry> results = writer.finish();
+        List<GlobalIndexIOMeta> metas = toIOMetas(results, indexPath);
+
+        GlobalIndexFileReader fileReader = createFileReader(indexPath);
+        try (VectorGlobalIndexReader reader =
+                new VectorGlobalIndexReader(fileReader, metas, vectorType, 
executor)) {
+            float[][] queryVectors =
+                    new float[][] {
+                        new float[] {1.0f, 0.0f},
+                        new float[] {0.0f, 1.0f},
+                        new float[] {0.7f, 0.7f}
+                    };
+            int limit = 3;
+
+            // The batch path must return exactly what looping the single path 
returns, in order.
+            BatchVectorSearch batchSearch = new 
BatchVectorSearch(queryVectors, limit, fieldName);
+            List<Optional<ScoredGlobalIndexResult>> batchResults =
+                    reader.visitBatchVectorSearch(batchSearch).join();
+
+            assertThat(batchResults).hasSize(queryVectors.length);
+            for (int i = 0; i < queryVectors.length; i++) {
+                VectorSearch singleSearch = new VectorSearch(queryVectors[i], 
limit, fieldName);
+                Optional<ScoredGlobalIndexResult> singleResult =
+                        reader.visitVectorSearch(singleSearch).join();
+
+                
assertThat(batchResults.get(i).isPresent()).isEqualTo(singleResult.isPresent());
+                if (singleResult.isPresent()) {
+                    
assertThat(batchResults.get(i).get().results().getLongCardinality())
+                            
.isEqualTo(singleResult.get().results().getLongCardinality());
+                    for (long rowId : singleResult.get().results()) {
+                        
assertThat(batchResults.get(i).get().results().contains(rowId)).isTrue();
+                    }
+                }
+            }
+        }
+    }
+
     // =================== Helpers =====================
 
     private VectorGlobalIndexWriter createIvfPqWriter(

Reply via email to