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 3d463ba392 [spark][flink] Support primary-key vector search (#8581)
3d463ba392 is described below

commit 3d463ba392dabc28ce09676ac87144e80ddb74aa
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jul 13 08:29:18 2026 +0800

    [spark][flink] Support primary-key vector search (#8581)
    
    Add end-to-end Spark SQL and Flink procedure support for primary-key
    vector search backed by the bucket-local indexes introduced in #8579.
    The PR supports every primary-key merge engine, distributes sufficiently
    large Spark searches by bucket group, and documents table creation,
    index maintenance, compaction freshness, query APIs, and current
    limitations.
---
 docs/docs/primary-key-table/vector-index.md        | 210 ++++++++++++++++
 docs/sidebars.js                                   |   1 +
 .../apache/paimon/reader/ScoreRecordReader.java    |  31 +++
 .../globalindex/IndexedSplitRecordReader.java      |   3 +-
 .../org/apache/paimon/schema/SchemaValidation.java |   7 +-
 .../source/PrimaryKeyVectorPositionReader.java     |   4 +-
 .../paimon/table/source/PrimaryKeyVectorRead.java  |  99 +++++---
 .../table/source/VectorSearchBuilderImpl.java      |   2 +-
 .../PrimaryKeyVectorIndexValidationTest.java       |  27 +++
 .../table/source/PrimaryKeyVectorSearchTest.java   |  71 +++++-
 .../procedure/VectorSearchProcedureITCase.java     | 119 +++++++++
 ...Impl.java => SparkDataEvolutionVectorRead.java} |   4 +-
 .../spark/read/SparkPrimaryKeyVectorRead.java      | 133 ++++++++++
 .../spark/read/SparkVectorSearchBuilderImpl.java   |   9 +-
 .../paimon/spark/PaimonRecordReaderIterator.scala  |   5 +-
 .../apache/paimon/spark/PaimonScanBuilder.scala    |   5 +
 ....java => SparkDataEvolutionVectorReadTest.java} |  10 +-
 .../spark/sql/PrimaryKeyVectorSearchTest.scala     | 270 +++++++++++++++++++++
 .../paimon/spark/sql/VectorSearchOptionsTest.scala |   9 +
 19 files changed, 966 insertions(+), 53 deletions(-)

diff --git a/docs/docs/primary-key-table/vector-index.md 
b/docs/docs/primary-key-table/vector-index.md
new file mode 100644
index 0000000000..8ac875b29d
--- /dev/null
+++ b/docs/docs/primary-key-table/vector-index.md
@@ -0,0 +1,210 @@
+---
+title: "Vector Index"
+sidebar_position: 9
+---
+
+<!--
+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.
+-->
+
+# Vector Index
+
+Primary key tables can maintain a bucket-local approximate nearest neighbor 
(ANN) index together
+with their data. Unlike a global vector index created by 
`create_global_index`, a primary-key
+vector index is part of the normal write and compaction lifecycle. Paimon 
builds it synchronously
+when complete compact-output files are produced and commits the index changes 
together with those
+files.
+
+Use a primary-key vector index when vectors are frequently updated and the ANN 
index should follow
+the primary-key table's compaction lifecycle. For append-only or Data 
Evolution tables whose index
+is built separately from writes, see
+[Global Vector Index](../multimodal-table/global-index/vector).
+
+## Requirements
+
+A table with a primary-key vector index must satisfy all of the following:
+
+- It is a primary-key table in fixed-bucket mode (`bucket > 0`).
+- `deletion-vectors.enabled` is `true`, except for `first-row`, where it must 
be `false`.
+- Its merge engine is `deduplicate`, `partial-update`, `aggregation`, or 
`first-row`.
+- The indexed column is a `VECTOR` whose element type is `FLOAT`.
+- `pk-clustering-override` is disabled.
+- The configured vector index implementation is available on every writer and 
reader classpath.
+
+The first release supports exactly one indexed vector column per table. The 
option layout is
+field-scoped so that more independently indexed vector columns can be 
supported in a future
+release.
+
+## Create Table
+
+The following Flink SQL example creates a three-dimensional vector column and 
maintains an
+IVF-Flat index for it. Use the dimension produced by your embedding model in 
production.
+
+```sql
+CREATE TABLE item_embeddings (
+    id BIGINT,
+    payload STRING,
+    embedding ARRAY<FLOAT> COMMENT '__VECTOR_FIELD;3',
+    PRIMARY KEY (id) NOT ENFORCED
+) WITH (
+    'bucket' = '16',
+    'deletion-vectors.enabled' = 'true',
+    'pk-vector.index.columns' = 'embedding',
+    'fields.embedding.pk-vector.index.type' = 'ivf-flat',
+    'fields.embedding.pk-vector.distance.metric' = 'cosine',
+    'fields.embedding.pk-vector.index.options' = '{"nlist":"256"}'
+);
+```
+
+Use the same properties in Spark SQL:
+
+```sql
+CREATE TABLE item_embeddings (
+    id BIGINT,
+    payload STRING,
+    embedding ARRAY<FLOAT> COMMENT '__VECTOR_FIELD;3'
+) USING paimon
+TBLPROPERTIES (
+    'primary-key' = 'id',
+    'bucket' = '16',
+    'deletion-vectors.enabled' = 'true',
+    'pk-vector.index.columns' = 'embedding',
+    'fields.embedding.pk-vector.index.type' = 'ivf-flat',
+    'fields.embedding.pk-vector.distance.metric' = 'cosine',
+    'fields.embedding.pk-vector.index.options' = '{"nlist":"256"}'
+);
+```
+
+The vector comment directive converts the SQL `ARRAY<FLOAT>` column to 
Paimon's fixed-length
+`VECTOR<FLOAT>` type. Java API users can define the column directly with
+`DataTypes.VECTOR(3, DataTypes.FLOAT())`.
+
+### Options
+
+| Option | Required | Description |
+|---|---|---|
+| `pk-vector.index.columns` | Yes | Indexed vector column. Exactly one column 
is supported in the first release. |
+| `fields.<column>.pk-vector.index.type` | Yes | ANN implementation, such as 
`ivf-flat`, `ivf-pq`, `ivf-hnsw-flat`, `ivf-hnsw-sq`, or `lumina`. |
+| `fields.<column>.pk-vector.distance.metric` | No | `l2`, `cosine`, or 
`inner_product`. The default is `inner_product`. |
+| `fields.<column>.pk-vector.index.options` | No | JSON object containing 
build options for the selected ANN implementation. Unqualified keys are scoped 
to that implementation. |
+
+For algorithm-specific build and search options, see
+[Vector Index](../multimodal-table/global-index/vector).
+
+## Index Maintenance
+
+Paimon builds immutable ANN segments from complete compact-output data files 
inside each bucket.
+The index segment records the source data files and maps ANN ordinals back to 
their physical row
+positions. Compact-output data-file and index-file changes are committed 
atomically, so a reader
+never observes an index from a different compact-output snapshot.
+
+The maintenance behavior depends on the merge engine:
+
+- `deduplicate`: an update indexes the latest row and the deletion vector 
hides the replaced
+  physical row. A delete removes the old row from search results through the 
deletion vector.
+- `partial-update`: Paimon builds the vector index from the lookup-completed 
Level-1
+  compact-output row.
+- `aggregation`: Paimon builds the vector index from the aggregated Level-1 
compact-output row.
+- `first-row`: Paimon indexes the retained first row. Deletion vectors must be 
disabled because
+  later rows with the same primary key are ignored rather than deleting the 
retained row.
+
+When compaction replaces source data files, Paimon removes ANN segments that 
reference those files
+and creates replacement segments for the new compact-output files. Small 
outputs are indexed as
+well; there is no minimum-row threshold before a new segment can be built.
+
+The index follows compaction freshness. Newly appended level-0 files are not 
ANN sources, so a
+streaming write may not be searchable until compaction has produced and 
committed its complete
+level-1 output. Wait for that compaction when read-after-write vector-search 
visibility is
+required. Batch writes which wait for compaction can publish the data and its 
index together.
+
+## Search
+
+### Spark SQL
+
+Use the `vector_search` table-valued function. Spark exposes the ANN score 
through the
+`__paimon_search_score` metadata column.
+
+```sql
+SELECT id, payload, __paimon_search_score
+FROM vector_search(
+    'item_embeddings',
+    'embedding',
+    array(0.1f, 0.2f, 0.3f),
+    10,
+    map('ivf.nprobe', '32')
+);
+```
+
+The query vector dimension must match the indexed column dimension. For 
partitioned tables, Spark
+applies a partition predicate before running ANN and merging the global Top-K.
+When `spark.paimon.vector-search.distribute.enabled` is `true`, Spark 
distributes sufficiently
+large groups of bucket-local ANN searches across executors and merges their 
task-local Top-K
+results on the driver. Small plans stay local to avoid Spark job startup 
overhead.
+
+### Flink SQL
+
+Flink exposes vector search as a procedure and returns JSON-serialized rows. 
Use `projection` to
+avoid reading columns that are not needed.
+
+```sql
+CALL sys.vector_search(
+    `table` => 'default.item_embeddings',
+    vector_column => 'embedding',
+    query_vector => '0.1,0.2,0.3',
+    top_k => 10,
+    projection => 'id,payload',
+    options => 'ivf.nprobe=32'
+);
+```
+
+### Java API
+
+```java
+GlobalIndexResult result = table.newVectorSearchBuilder()
+        .withVectorColumn("embedding")
+        .withVector(queryVector)
+        .withLimit(10)
+        .withOption("ivf.nprobe", "32")
+        .executeLocal();
+
+ReadBuilder readBuilder = table.newReadBuilder();
+TableScan.Plan plan = 
readBuilder.newScan().withGlobalIndexResult(result).plan();
+try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(plan)) {
+    reader.forEachRemaining(row -> consume(row));
+}
+```
+
+## Query Planning
+
+A search captures one table snapshot, plans the active ANN segments for every 
selected bucket,
+searches those segments, and merges their candidates into one global Top-K. 
The returned candidates
+are materialized from the source data files by physical row position. Deletion 
vectors are applied
+while searching and reading, so stale versions and deleted rows are not 
returned.
+
+For low latency on object storage, cache data files and ANN payloads with a 
caching file system.
+The first query may still need to download index files; subsequent queries can 
search the local
+cached payloads and fetch only the selected data-file positions.
+
+## Limitations
+
+- Exactly one vector index column is supported per table in the first release.
+- Only `FLOAT` vectors are supported.
+- Dynamic-bucket and `pk-clustering-override` tables are not supported.
+- Flink's procedure returns rows but does not expose the ANN score as a 
separate column.
+- Vector search is snapshot-scoped batch reading; streaming search and lateral 
vector search for
+  primary-key tables are not supported.
diff --git a/docs/sidebars.js b/docs/sidebars.js
index 4223c1347f..7e3967dade 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -84,6 +84,7 @@ const sidebars = {
       "primary-key-table/sequence-rowkind",
       "primary-key-table/compaction",
       "primary-key-table/query-performance",
+      "primary-key-table/vector-index",
       "primary-key-table/chain-table",
       "primary-key-table/pk-clustering-override",
       {
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordReader.java 
b/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordReader.java
new file mode 100644
index 0000000000..69b9463325
--- /dev/null
+++ 
b/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordReader.java
@@ -0,0 +1,31 @@
+/*
+ * 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.reader;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+/** A {@link RecordReader} whose records expose vector-search scores and row 
identifiers. */
+public interface ScoreRecordReader<T> extends RecordReader<T> {
+
+    @Nullable
+    @Override
+    ScoreRecordIterator<T> readBatch() throws IOException;
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
index 3778f37cb9..3d5bc89e00 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
@@ -21,6 +21,7 @@ package org.apache.paimon.globalindex;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.reader.ScoreRecordReader;
 import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.ProjectedRow;
@@ -35,7 +36,7 @@ import java.util.Map;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** Return value with score. */
-public class IndexedSplitRecordReader implements RecordReader<InternalRow> {
+public class IndexedSplitRecordReader implements 
ScoreRecordReader<InternalRow> {
 
     private final RecordReader<InternalRow> reader;
     @Nullable private final Map<Long, Float> rowIdToScore;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index 2122c04ed5..09cb61c0bf 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -912,13 +912,8 @@ public class SchemaValidation {
                 !schema.primaryKeys().isEmpty(),
                 "Primary-key vector index requires a primary-key table.");
         checkArgument(
-                options.deletionVectorsEnabled(),
+                options.mergeEngine() == MergeEngine.FIRST_ROW || 
options.deletionVectorsEnabled(),
                 "Primary-key vector index requires deletion-vectors.enabled = 
true.");
-        checkArgument(
-                options.mergeEngine() == MergeEngine.DEDUPLICATE
-                        || options.mergeEngine() == MergeEngine.PARTIAL_UPDATE,
-                "Primary-key vector index only supports merge-engine = 
deduplicate or partial-update, but is %s.",
-                options.mergeEngine());
         checkArgument(
                 !options.deletionVectorsMergeOnRead(),
                 "Primary-key vector index with merge-engine = %s requires 
deletion-vectors.merge-on-read = false.",
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
index 6470fc26eb..52f7423a92 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
@@ -21,8 +21,8 @@ package org.apache.paimon.table.source;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.reader.FileRecordIterator;
 import org.apache.paimon.reader.FileRecordReader;
-import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.reader.ScoreRecordReader;
 import org.apache.paimon.utils.RoaringBitmap32;
 
 import javax.annotation.Nullable;
@@ -33,7 +33,7 @@ import java.util.function.IntFunction;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** Reads selected physical file positions and exposes their vector-search 
scores. */
-public class PrimaryKeyVectorPositionReader implements 
RecordReader<InternalRow> {
+public class PrimaryKeyVectorPositionReader implements 
ScoreRecordReader<InternalRow> {
 
     private final FileRecordReader<InternalRow> reader;
     private final RoaringBitmap32 rowPositions;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
index 68350ffb83..b70f19eb73 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
@@ -40,6 +40,7 @@ import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.VectorType;
 
 import java.io.IOException;
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -56,7 +57,9 @@ import static 
org.apache.paimon.globalindex.VectorSearchMetric.normalize;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** Executes bucket-local primary-key vector search and merges physical 
candidates globally. */
-public class PrimaryKeyVectorRead implements VectorRead {
+public class PrimaryKeyVectorRead implements VectorRead, Serializable {
+
+    private static final long serialVersionUID = 1L;
 
     private static final Comparator<Candidate> BEST_FIRST =
             (left, right) -> {
@@ -76,16 +79,13 @@ public class PrimaryKeyVectorRead implements VectorRead {
                 return fileName != 0 ? fileName : 
Long.compare(left.rowPosition, right.rowPosition);
             };
 
-    private final FileIO fileIO;
-    private final IndexFileHandler indexFileHandler;
-    private final KeyValueFileReaderFactory.Builder readerFactoryBuilder;
-    private final DataField vectorField;
+    protected final FileStoreTable table;
+    protected final DataField vectorField;
     private final String indexType;
     private final Options indexOptions;
-    private final ExecutorService executor;
     private final Map<String, String> searchOptions;
     private final float[] query;
-    private final int limit;
+    protected final int limit;
     private final String metric;
 
     public PrimaryKeyVectorRead(
@@ -102,15 +102,10 @@ public class PrimaryKeyVectorRead implements VectorRead {
                 query.length,
                 ((VectorType) vectorField.type()).getLength());
         checkArgument(limit > 0, "Vector search limit must be positive: %s.", 
limit);
-        this.fileIO = table.fileIO();
-        this.indexFileHandler = table.store().newIndexFileHandler();
-        this.readerFactoryBuilder = 
keyValueStore(table).newReaderFactoryBuilder();
+        this.table = table;
         this.vectorField = vectorField;
         this.indexType = 
table.coreOptions().primaryKeyVectorIndexType(vectorField.name());
         this.indexOptions = 
table.coreOptions().primaryKeyVectorIndexOptions(vectorField.name());
-        this.executor =
-                GlobalIndexReadThreadPool.getExecutorService(
-                        
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
         this.searchOptions = Collections.unmodifiableMap(new 
HashMap<>(searchOptions));
         this.query = query.clone();
         this.limit = limit;
@@ -131,26 +126,52 @@ public class PrimaryKeyVectorRead implements VectorRead {
 
     @Override
     public GlobalIndexResult read(VectorScan.Plan plan) {
+        PrimaryKeyVectorScan.Plan primaryKeyPlan = primaryKeyPlan(plan);
+        return createResult(primaryKeyPlan, 
searchBuckets(bucketSplits(primaryKeyPlan)));
+    }
+
+    protected PrimaryKeyVectorScan.Plan primaryKeyPlan(VectorScan.Plan plan) {
         checkArgument(
                 plan instanceof PrimaryKeyVectorScan.Plan,
                 "Primary-key vector read requires a PrimaryKeyVectorScan 
plan.");
         PrimaryKeyVectorScan.Plan primaryKeyPlan = (PrimaryKeyVectorScan.Plan) 
plan;
+        for (VectorSearchSplit searchSplit : primaryKeyPlan.splits()) {
+            BucketVectorSearchSplit split = (BucketVectorSearchSplit) 
searchSplit;
+            checkArgument(
+                    split.dataSplit().snapshotId() == 
primaryKeyPlan.snapshotId(),
+                    "Vector bucket split snapshot does not match its plan.");
+        }
+        return primaryKeyPlan;
+    }
+
+    protected List<BucketVectorSearchSplit> 
bucketSplits(PrimaryKeyVectorScan.Plan plan) {
+        List<BucketVectorSearchSplit> splits = new 
ArrayList<>(plan.splits().size());
+        for (VectorSearchSplit split : plan.splits()) {
+            splits.add((BucketVectorSearchSplit) split);
+        }
+        return splits;
+    }
+
+    protected List<Candidate> searchBuckets(List<BucketVectorSearchSplit> 
splits) {
         try {
+            SearchContext context = new SearchContext(table);
             List<Candidate> candidates = new ArrayList<>();
-            for (VectorSearchSplit searchSplit : primaryKeyPlan.splits()) {
-                BucketVectorSearchSplit split = (BucketVectorSearchSplit) 
searchSplit;
-                checkArgument(
-                        split.dataSplit().snapshotId() == 
primaryKeyPlan.snapshotId(),
-                        "Vector bucket split snapshot does not match its 
plan.");
-                candidates.addAll(search(split));
+            for (BucketVectorSearchSplit split : splits) {
+                candidates.addAll(search(split, context));
             }
-            return new PrimaryKeyVectorResult(primaryKeyPlan, topK(candidates, 
limit), metric);
+            return topK(candidates, limit);
         } catch (IOException e) {
             throw new RuntimeException("Failed to search primary-key vector 
index.", e);
         }
     }
 
-    private List<Candidate> search(BucketVectorSearchSplit split) throws 
IOException {
+    protected GlobalIndexResult createResult(
+            PrimaryKeyVectorScan.Plan plan, List<Candidate> candidates) {
+        return new PrimaryKeyVectorResult(plan, topK(candidates, limit), 
metric);
+    }
+
+    private List<Candidate> search(BucketVectorSearchSplit split, 
SearchContext context)
+            throws IOException {
         DataSplit dataSplit = split.dataSplit();
         List<DataFileMeta> activeFiles =
                 dataSplit.dataFiles().stream()
@@ -159,23 +180,23 @@ public class PrimaryKeyVectorRead implements VectorRead {
         PkVectorBucketIndexState state =
                 PkVectorBucketIndexState.fromActivePayloads(
                         vectorField.id(), indexType, split.payloadFiles());
-        Map<String, DeletionVector> deletionVectors = 
deletionVectors(dataSplit);
+        Map<String, DeletionVector> deletionVectors = 
deletionVectors(dataSplit, context.fileIO);
         PkVectorDataFileReader.Factory readerFactory =
                 new PkVectorDataFileReader.Factory(
-                        readerFactoryBuilder,
+                        context.readerFactoryBuilder,
                         dataSplit.partition(),
                         dataSplit.bucket(),
                         vectorField,
                         ((VectorType) vectorField.type()).getLength());
         PkVectorAnnSegmentSearcher annSearcher =
                 new PkVectorAnnSegmentSearcher(
-                        fileIO,
-                        indexFileHandler.pkVectorAnnSegment(
+                        context.fileIO,
+                        context.indexFileHandler.pkVectorAnnSegment(
                                 dataSplit.partition(), dataSplit.bucket()),
                         vectorField,
                         indexOptions,
                         metric,
-                        executor);
+                        context.executor);
         PrimaryKeyVectorBucketSearch bucketSearch =
                 new PrimaryKeyVectorBucketSearch(readerFactory, annSearcher, 
searchOptions, metric);
         List<Candidate> candidates = new ArrayList<>();
@@ -192,7 +213,8 @@ public class PrimaryKeyVectorRead implements VectorRead {
         return candidates;
     }
 
-    private Map<String, DeletionVector> deletionVectors(DataSplit split) 
throws IOException {
+    private Map<String, DeletionVector> deletionVectors(DataSplit split, 
FileIO fileIO)
+            throws IOException {
         DeletionVector.Factory factory =
                 DeletionVector.factory(
                         fileIO, split.dataFiles(), 
split.deletionFiles().orElse(null));
@@ -206,7 +228,7 @@ public class PrimaryKeyVectorRead implements VectorRead {
         return result;
     }
 
-    static List<Candidate> topK(List<Candidate> candidates, int limit) {
+    protected static List<Candidate> topK(List<Candidate> candidates, int 
limit) {
         checkArgument(limit > 0, "Vector search limit must be positive: %s.", 
limit);
         PriorityQueue<Candidate> nearest = new PriorityQueue<>(limit, 
BEST_FIRST.reversed());
         for (Candidate candidate : candidates) {
@@ -234,7 +256,9 @@ public class PrimaryKeyVectorRead implements VectorRead {
     }
 
     /** Snapshot-scoped physical row candidate. */
-    public static class Candidate {
+    public static class Candidate implements Serializable {
+
+        private static final long serialVersionUID = 1L;
 
         private final BinaryRow partition;
         private final int bucket;
@@ -275,4 +299,21 @@ public class PrimaryKeyVectorRead implements VectorRead {
             return distance;
         }
     }
+
+    private static class SearchContext {
+
+        private final FileIO fileIO;
+        private final IndexFileHandler indexFileHandler;
+        private final KeyValueFileReaderFactory.Builder readerFactoryBuilder;
+        private final ExecutorService executor;
+
+        private SearchContext(FileStoreTable table) {
+            this.fileIO = table.fileIO();
+            this.indexFileHandler = table.store().newIndexFileHandler();
+            this.readerFactoryBuilder = 
keyValueStore(table).newReaderFactoryBuilder();
+            this.executor =
+                    GlobalIndexReadThreadPool.getExecutorService(
+                            
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+        }
+    }
 }
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 f2bcb7af65..e706e40ba6 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
@@ -151,7 +151,7 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
                 table, partitionFilter, filter, limit, vectorColumn, vector, 
options);
     }
 
-    private boolean isPrimaryKeyVectorSearch() {
+    protected boolean isPrimaryKeyVectorSearch() {
         return vectorColumn != null
                 && 
table.coreOptions().primaryKeyVectorIndexColumns().contains(vectorColumn.name());
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
index 0daa7bc5c4..6f69b2a70c 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
@@ -123,6 +123,33 @@ class PrimaryKeyVectorIndexValidationTest {
         assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
     }
 
+    @Test
+    void testSupportsAggregationMergeEngine() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.MERGE_ENGINE.key(), "aggregation");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testSupportsFirstRowWithoutDeletionVectors() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.MERGE_ENGINE.key(), "first-row");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "false");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRejectsFirstRowWithDeletionVectors() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.MERGE_ENGINE.key(), "first-row");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining(
+                        "First row merge engine does not need deletion vectors 
because there is no deletion of old data in this merge engine");
+    }
+
     @Test
     void testPartialUpdateRejectsDeletionVectorMergeOnRead() {
         Map<String, String> options = enabledOptions();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
index 090488de9e..5c6558fdfe 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
@@ -46,12 +46,19 @@ class PrimaryKeyVectorSearchTest extends TableTestBase {
 
     @Override
     protected Schema schemaDefault() {
+        return vectorSchema("deduplicate", true);
+    }
+
+    private Schema vectorSchema(String mergeEngine, boolean 
deletionVectorsEnabled) {
         return Schema.newBuilder()
                 .column("id", DataTypes.INT())
                 .column("embedding", DataTypes.VECTOR(2, DataTypes.FLOAT()))
                 .primaryKey("id")
                 .option(CoreOptions.BUCKET.key(), "1")
-                .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true")
+                .option(CoreOptions.MERGE_ENGINE.key(), mergeEngine)
+                .option(
+                        CoreOptions.DELETION_VECTORS_ENABLED.key(),
+                        Boolean.toString(deletionVectorsEnabled))
                 .option(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding")
                 .option(
                         "fields.embedding.pk-vector.index.type",
@@ -94,4 +101,66 @@ class PrimaryKeyVectorSearchTest extends TableTestBase {
 
         assertThat(ids).containsExactly(2, 3);
     }
+
+    @Test
+    void testFirstRowVectorSearch() throws Exception {
+        catalog.createTable(identifier(), vectorSchema("first-row", false), 
false);
+        FileStoreTable table = getTableDefault();
+
+        write(
+                table,
+                ioManager,
+                GenericRow.of(1, BinaryVector.fromPrimitiveArray(new float[] 
{3, 0})),
+                GenericRow.of(2, BinaryVector.fromPrimitiveArray(new float[] 
{1, 0})));
+        write(
+                table,
+                ioManager,
+                GenericRow.of(1, BinaryVector.fromPrimitiveArray(new float[] 
{0.5f, 0})));
+
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVectorColumn("embedding")
+                        .withVector(new float[] {0, 0})
+                        .withLimit(1)
+                        .executeLocal();
+        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)));
+        }
+
+        assertThat(ids).containsExactly(2);
+    }
+
+    @Test
+    void testAggregationVectorSearch() throws Exception {
+        catalog.createTable(identifier(), vectorSchema("aggregation", true), 
false);
+        FileStoreTable table = getTableDefault();
+
+        write(
+                table,
+                ioManager,
+                GenericRow.of(1, BinaryVector.fromPrimitiveArray(new float[] 
{3, 0})),
+                GenericRow.of(2, BinaryVector.fromPrimitiveArray(new float[] 
{1, 0})));
+        write(
+                table,
+                ioManager,
+                GenericRow.of(1, BinaryVector.fromPrimitiveArray(new float[] 
{0.5f, 0})));
+
+        GlobalIndexResult result =
+                table.newVectorSearchBuilder()
+                        .withVectorColumn("embedding")
+                        .withVector(new float[] {0, 0})
+                        .withLimit(1)
+                        .executeLocal();
+        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)));
+        }
+
+        assertThat(ids).containsExactly(1);
+    }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
index d71de22d2a..bc29716356 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
@@ -54,6 +54,67 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
     private static final String VECTOR_FIELD = "vec";
     private static final int DIMENSION = 2;
 
+    @Test
+    public void testPrimaryKeyVectorSearch() throws Exception {
+        createPrimaryKeyVectorTable("PK_T");
+
+        sql(
+                "INSERT INTO PK_T VALUES "
+                        + "(1, ARRAY[CAST(3.0 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(2, ARRAY[CAST(1.0 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(3, ARRAY[CAST(2.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+
+        List<Row> result = searchPrimaryKeyVectorTable("PK_T", 2, "id");
+
+        assertThat(result)
+                .extracting(row -> row.getField(0).toString())
+                .containsExactlyInAnyOrder("{\"id\":\"2\"}", "{\"id\":\"3\"}");
+    }
+
+    @Test
+    public void testPrimaryKeyVectorSearchAfterUpdateAndDelete() throws 
Exception {
+        createPrimaryKeyVectorTable("PK_UPDATE_T");
+
+        sql(
+                "INSERT INTO PK_UPDATE_T VALUES "
+                        + "(1, ARRAY[CAST(3.0 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(2, ARRAY[CAST(1.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+        sql(
+                "INSERT INTO PK_UPDATE_T VALUES "
+                        + "(1, ARRAY[CAST(0.5 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+
+        List<Row> updated = searchPrimaryKeyVectorTable("PK_UPDATE_T", 1, 
"id");
+        assertThat(updated)
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"1\"}");
+
+        sql("DELETE FROM PK_UPDATE_T WHERE id = 1");
+
+        List<Row> afterDelete = searchPrimaryKeyVectorTable("PK_UPDATE_T", 1, 
"id");
+        assertThat(afterDelete)
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"2\"}");
+    }
+
+    @Test
+    public void testPartialUpdatePrimaryKeyVectorSearch() throws Exception {
+        createPartialUpdatePrimaryKeyVectorTable("PK_PARTIAL_T");
+
+        sql(
+                "INSERT INTO PK_PARTIAL_T VALUES "
+                        + "(1, 'keep', ARRAY[CAST(3.0 AS FLOAT), CAST(0.0 AS 
FLOAT)]), "
+                        + "(2, 'other', ARRAY[CAST(1.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+        sql(
+                "INSERT INTO PK_PARTIAL_T (id, vec) VALUES "
+                        + "(1, ARRAY[CAST(0.5 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+
+        List<Row> result = searchPrimaryKeyVectorTable("PK_PARTIAL_T", 1, 
"id,payload");
+
+        assertThat(result)
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"1\",\"payload\":\"keep\"}");
+    }
+
     @Test
     public void testVectorSearchBasic() throws Exception {
         createVectorTable("T");
@@ -202,6 +263,64 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
                 tableName, DIMENSION, formattedExtraOptions);
     }
 
+    private void createPrimaryKeyVectorTable(String tableName) {
+        sql(
+                "CREATE TABLE %s ("
+                        + "id INT, "
+                        + "vec ARRAY<FLOAT>, "
+                        + "PRIMARY KEY (id) NOT ENFORCED"
+                        + ") WITH ("
+                        + "'bucket' = '2', "
+                        + "'file.format' = 'json', "
+                        + "'file.compression' = 'none', "
+                        + "'deletion-vectors.enabled' = 'true', "
+                        + "'vector-field' = 'vec', "
+                        + "'field.vec.vector-dim' = '%d', "
+                        + "'pk-vector.index.columns' = 'vec', "
+                        + "'fields.vec.pk-vector.index.type' = '%s', "
+                        + "'fields.vec.pk-vector.distance.metric' = 'l2', "
+                        + "'test.vector.dimension' = '%d', "
+                        + "'test.vector.metric' = 'l2'"
+                        + ")",
+                tableName, DIMENSION, 
TestVectorGlobalIndexerFactory.IDENTIFIER, DIMENSION);
+    }
+
+    private List<Row> searchPrimaryKeyVectorTable(String tableName, int topK, 
String projection) {
+        return sql(
+                "CALL sys.vector_search("
+                        + "`table` => 'default.%s', "
+                        + "vector_column => 'vec', "
+                        + "query_vector => '0.0,0.0', "
+                        + "top_k => %d, "
+                        + "projection => '%s')",
+                tableName, topK, projection);
+    }
+
+    private void createPartialUpdatePrimaryKeyVectorTable(String tableName) {
+        sql(
+                "CREATE TABLE %s ("
+                        + "id INT, "
+                        + "payload STRING, "
+                        + "vec ARRAY<FLOAT>, "
+                        + "PRIMARY KEY (id) NOT ENFORCED"
+                        + ") WITH ("
+                        + "'bucket' = '1', "
+                        + "'file.format' = 'json', "
+                        + "'file.compression' = 'none', "
+                        + "'merge-engine' = 'partial-update', "
+                        + "'deletion-vectors.enabled' = 'true', "
+                        + "'deletion-vectors.merge-on-read' = 'false', "
+                        + "'vector-field' = 'vec', "
+                        + "'field.vec.vector-dim' = '%d', "
+                        + "'pk-vector.index.columns' = 'vec', "
+                        + "'fields.vec.pk-vector.index.type' = '%s', "
+                        + "'fields.vec.pk-vector.distance.metric' = 'l2', "
+                        + "'test.vector.dimension' = '%d', "
+                        + "'test.vector.metric' = 'l2'"
+                        + ")",
+                tableName, DIMENSION, 
TestVectorGlobalIndexerFactory.IDENTIFIER, DIMENSION);
+    }
+
     private void writeVectors(FileStoreTable table, float[][] vectors) throws 
Exception {
         BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
         try (BatchTableWrite write = writeBuilder.newWrite();
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/SparkDataEvolutionVectorRead.java
similarity index 99%
rename from 
paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkVectorReadImpl.java
rename to 
paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorRead.java
index 3c13fb4c0c..ddac704b66 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/SparkDataEvolutionVectorRead.java
@@ -54,11 +54,11 @@ import static 
org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
  * Spark-aware {@link DataEvolutionVectorRead} that distributes grouped vector 
index evaluation
  * across the Spark cluster instead of evaluating them with the local thread 
pool.
  */
-public class SparkVectorReadImpl extends DataEvolutionVectorRead {
+public class SparkDataEvolutionVectorRead extends DataEvolutionVectorRead {
 
     private static final long serialVersionUID = 1L;
 
-    public SparkVectorReadImpl(
+    public SparkDataEvolutionVectorRead(
             FileStoreTable table,
             @Nullable PartitionPredicate partitionFilter,
             @Nullable Predicate filter,
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkPrimaryKeyVectorRead.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkPrimaryKeyVectorRead.java
new file mode 100644
index 0000000000..053abc9677
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkPrimaryKeyVectorRead.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.read;
+
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.BucketVectorSearchSplit;
+import org.apache.paimon.table.source.PrimaryKeyVectorRead;
+import org.apache.paimon.table.source.PrimaryKeyVectorScan;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.InstantiationUtil;
+import org.apache.paimon.utils.SerializableFunction;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
+
+/** Spark-aware {@link PrimaryKeyVectorRead}. */
+public class SparkPrimaryKeyVectorRead extends PrimaryKeyVectorRead {
+
+    private static final long serialVersionUID = 1L;
+
+    public SparkPrimaryKeyVectorRead(
+            FileStoreTable table,
+            DataField vectorField,
+            float[] query,
+            int limit,
+            Map<String, String> searchOptions) {
+        super(table, vectorField, query, limit, searchOptions);
+    }
+
+    @Override
+    public GlobalIndexResult read(VectorScan.Plan plan) {
+        PrimaryKeyVectorScan.Plan primaryKeyPlan = primaryKeyPlan(plan);
+        List<BucketVectorSearchSplit> splits = bucketSplits(primaryKeyPlan);
+        int parallelism = sparkParallelism();
+        if (splits.size() < parallelism * 2) {
+            return super.read(plan);
+        }
+
+        List<byte[]> serializedSplits = new ArrayList<>(splits.size());
+        for (BucketVectorSearchSplit split : splits) {
+            try {
+                serializedSplits.add(InstantiationUtil.serializeObject(split));
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to serialize primary-key 
vector split.", e);
+            }
+        }
+        List<List<byte[]>> groups = splitGroups(serializedSplits, parallelism);
+        SerializableFunction<List<byte[]>, byte[]> task =
+                group -> {
+                    List<BucketVectorSearchSplit> taskSplits = new 
ArrayList<>(group.size());
+                    for (byte[] bytes : group) {
+                        taskSplits.add(deserializeSplit(bytes));
+                    }
+                    try {
+                        return 
InstantiationUtil.serializeObject(searchBuckets(taskSplits));
+                    } catch (IOException e) {
+                        throw new RuntimeException(
+                                "Failed to serialize primary-key vector 
candidates.", e);
+                    }
+                };
+        List<byte[]> groupResults = mapInSpark(groups, task, groups.size());
+        List<Candidate> candidates = new ArrayList<>();
+        for (byte[] groupResult : groupResults) {
+            candidates.addAll(deserializeCandidates(groupResult));
+        }
+        return createResult(primaryKeyPlan, candidates);
+    }
+
+    protected int sparkParallelism() {
+        return Math.max(1, 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+    }
+
+    protected SparkEngineContext createEngineContext() {
+        return new SparkEngineContext();
+    }
+
+    protected <I, O> List<O> mapInSpark(
+            List<I> data, SerializableFunction<I, O> function, int 
parallelism) {
+        return createEngineContext().map(data, function, parallelism);
+    }
+
+    private List<List<byte[]>> splitGroups(List<byte[]> splits, int 
parallelism) {
+        List<List<byte[]>> groups = new ArrayList<>(parallelism);
+        int groupSize = (splits.size() + parallelism - 1) / parallelism;
+        for (int start = 0; start < splits.size(); start += groupSize) {
+            groups.add(
+                    new ArrayList<>(
+                            splits.subList(start, Math.min(start + groupSize, 
splits.size()))));
+        }
+        return groups;
+    }
+
+    private BucketVectorSearchSplit deserializeSplit(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize primary-key 
vector split.", e);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Candidate> deserializeCandidates(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize primary-key 
vector candidates.", e);
+        }
+    }
+}
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 8704486258..7638f57269 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
@@ -23,8 +23,8 @@ import org.apache.paimon.table.source.VectorRead;
 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.
+ * Spark-aware {@link VectorSearchBuilderImpl} which produces Spark-specific 
vector readers so
+ * data-evolution splits and primary-key bucket groups can be evaluated across 
the Spark cluster.
  *
  * <p>Single-vector only; batch search has no Spark-dispatched path yet (TODO).
  */
@@ -38,7 +38,10 @@ public class SparkVectorSearchBuilderImpl extends 
VectorSearchBuilderImpl {
 
     @Override
     public VectorRead newVectorRead() {
-        return new SparkVectorReadImpl(
+        if (isPrimaryKeyVectorSearch()) {
+            return new SparkPrimaryKeyVectorRead(table, vectorColumn, vector, 
limit, options);
+        }
+        return new SparkDataEvolutionVectorRead(
                 table, partitionFilter, filter, limit, vectorColumn, vector, 
options);
     }
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
index 64b0b5166c..93256df2c0 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
@@ -20,8 +20,7 @@ package org.apache.paimon.spark
 
 import org.apache.paimon.data.{BinaryString, GenericRow, InternalRow => 
PaimonInternalRow, JoinedRow}
 import org.apache.paimon.fs.Path
-import org.apache.paimon.globalindex.IndexedSplitRecordReader
-import org.apache.paimon.reader.{FileRecordIterator, RecordReader, 
ScoreRecordIterator}
+import org.apache.paimon.reader.{FileRecordIterator, RecordReader, 
ScoreRecordIterator, ScoreRecordReader}
 import org.apache.paimon.spark.schema.PaimonMetadataColumn
 import 
org.apache.paimon.spark.schema.PaimonMetadataColumn.{PARTITION_AND_BUCKET_META_COLUMNS,
 PATH_AND_INDEX_META_COLUMNS, VECTOR_SEARCH_META_COLUMN_NAMES}
 import org.apache.paimon.table.source.{DataSplit, Split}
@@ -49,7 +48,7 @@ case class PaimonRecordReaderIterator(
   private val needMetadata = metadataColumns.nonEmpty
   private val needPathAndIndexMetadata =
     metadataColumns.exists(c => PATH_AND_INDEX_META_COLUMNS.contains(c.name))
-  private val needVectorSearchMetadata = 
reader.isInstanceOf[IndexedSplitRecordReader] &&
+  private val needVectorSearchMetadata = 
reader.isInstanceOf[ScoreRecordReader[_]] &&
     metadataColumns.exists(c => 
VECTOR_SEARCH_META_COLUMN_NAMES.contains(c.name))
 
   Preconditions.checkArgument(
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
index 6d7f894bea..844c4de52b 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark
 
+import org.apache.paimon.CoreOptions
 import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.predicate._
 import org.apache.paimon.predicate.SortValue.{NullOrdering, SortDirection}
@@ -141,6 +142,10 @@ class PaimonScanBuilder(val table: InnerTable)
 
         if (
           vectorSearch.isDefined &&
+          !CoreOptions
+            .fromMap(actualTable.options)
+            .primaryKeyVectorIndexColumns()
+            .contains(vectorSearch.get.fieldName()) &&
           
VectorSearchResultUtils.isVectorSearchMetaOnly(requiredSchema.fieldNames.toSeq)
         ) {
           val result = PaimonBaseScan.evalVectorSearch(
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkVectorReadImplTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
similarity index 97%
rename from 
paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkVectorReadImplTest.java
rename to 
paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
index a88d2d9cbf..756dff2cb6 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkVectorReadImplTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
@@ -56,8 +56,8 @@ import java.util.stream.Collectors;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
-/** Tests for {@link SparkVectorReadImpl}. */
-public class SparkVectorReadImplTest {
+/** Tests for {@link SparkDataEvolutionVectorRead}. */
+public class SparkDataEvolutionVectorReadTest {
 
     @Test
     public void testRawSearchUsesSparkPath() {
@@ -116,7 +116,7 @@ public class SparkVectorReadImplTest {
         return splits;
     }
 
-    private static class TestingSparkVectorRead extends SparkVectorReadImpl {
+    private static class TestingSparkVectorRead extends 
SparkDataEvolutionVectorRead {
 
         private boolean rawSparkPathUsed;
 
@@ -158,7 +158,7 @@ public class SparkVectorReadImplTest {
         }
     }
 
-    private static class DistributedRefineSparkVectorRead extends 
SparkVectorReadImpl {
+    private static class DistributedRefineSparkVectorRead extends 
SparkDataEvolutionVectorRead {
 
         private int sparkParallelism;
         private List<Long> rawSearchCandidateRows = Collections.emptyList();
@@ -228,7 +228,7 @@ public class SparkVectorReadImplTest {
         }
     }
 
-    private static class RecordingSparkVectorRead extends SparkVectorReadImpl {
+    private static class RecordingSparkVectorRead extends 
SparkDataEvolutionVectorRead {
 
         private final AtomicInteger nextTask = new AtomicInteger();
         private final List<Range> rawSearchRanges =
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
new file mode 100644
index 0000000000..84e896ae50
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.sql
+
+import org.apache.paimon.globalindex.testvector.TestVectorGlobalIndexerFactory
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.spark.read.{SparkPrimaryKeyVectorRead, 
SparkVectorSearchBuilderImpl}
+import org.apache.paimon.table.source.DataSplit
+
+import scala.collection.JavaConverters._
+
+/** End-to-end tests for primary-key vector search through Spark SQL. */
+class PrimaryKeyVectorSearchTest extends PaimonSparkTestBase {
+
+  test("distributed primary-key vector search selects Spark reader") {
+    withTable("T") {
+      createVectorTable()
+
+      val builder = new SparkVectorSearchBuilderImpl(loadTable("T"))
+      builder
+        .withVectorColumn("embedding")
+        .withVector(Array(0.0f, 0.0f))
+        .withLimit(1)
+
+      assert(builder.newVectorRead().isInstanceOf[SparkPrimaryKeyVectorRead])
+    }
+  }
+
+  test("distributed primary-key vector search evaluates buckets in Spark") {
+    withTable("T") {
+      createVectorTable(bucket = 2, extraOptions = 
Seq("global-index.thread-num" -> "1"))
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, array(1.0f, 0.0f)),
+                  |  (2, array(2.0f, 0.0f)),
+                  |  (3, array(3.0f, 0.0f)),
+                  |  (4, array(4.0f, 0.0f))
+                  |""".stripMargin)
+
+      val builder = new SparkVectorSearchBuilderImpl(loadTable("T"))
+      builder
+        .withVectorColumn("embedding")
+        .withVector(Array(0.0f, 0.0f))
+        .withLimit(2)
+
+      val jobGroup = s"primary-key-vector-${System.nanoTime()}"
+      spark.sparkContext.setJobGroup(jobGroup, jobGroup)
+      try {
+        builder.newVectorRead().read(builder.newVectorScan().scan())
+      } finally {
+        spark.sparkContext.clearJobGroup()
+      }
+
+      
assert(spark.sparkContext.statusTracker.getJobIdsForGroup(jobGroup).nonEmpty)
+    }
+  }
+
+  test("primary-key vector search uses bucket-local indexes") {
+    withTable("T") {
+      createVectorTable()
+
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, array(3.0f, 0.0f)),
+                  |  (2, array(1.0f, 0.0f)),
+                  |  (3, array(2.0f, 0.0f))
+                  |""".stripMargin)
+
+      withSparkSQLConf("spark.paimon.vector-search.distribute.enabled" -> 
"true") {
+        val rows = spark
+          .sql("""
+                 |SELECT id, __paimon_search_score
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 2)
+                 |""".stripMargin)
+          .collect()
+
+        assert(rows.map(_.getInt(0)).toSet == Set(2, 3))
+        assert(rows.forall(!_.isNullAt(1)))
+
+        val scores = spark
+          .sql("""
+                 |SELECT __paimon_search_score
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 2)
+                 |""".stripMargin)
+          .collect()
+        assert(scores.length == 2)
+        assert(scores.forall(!_.isNullAt(0)))
+      }
+    }
+  }
+
+  test("primary-key vector search merges top k across buckets") {
+    withTable("T") {
+      createVectorTable(bucket = 4, extraOptions = 
Seq("global-index.thread-num" -> "2"))
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, array(1.0f, 0.0f)),
+                  |  (2, array(2.0f, 0.0f)),
+                  |  (3, array(3.0f, 0.0f)),
+                  |  (4, array(4.0f, 0.0f)),
+                  |  (5, array(5.0f, 0.0f)),
+                  |  (6, array(6.0f, 0.0f)),
+                  |  (7, array(7.0f, 0.0f)),
+                  |  (8, array(8.0f, 0.0f)),
+                  |  (9, array(9.0f, 0.0f)),
+                  |  (10, array(10.0f, 0.0f)),
+                  |  (11, array(11.0f, 0.0f)),
+                  |  (12, array(12.0f, 0.0f)),
+                  |  (13, array(13.0f, 0.0f)),
+                  |  (14, array(14.0f, 0.0f)),
+                  |  (15, array(15.0f, 0.0f)),
+                  |  (16, array(16.0f, 0.0f))
+                  |""".stripMargin)
+
+      val buckets = loadTable("T")
+        .newReadBuilder()
+        .newScan()
+        .plan()
+        .splits()
+        .asScala
+        .map(_.asInstanceOf[DataSplit].bucket())
+        .toSet
+      assert(buckets == Set(0, 1, 2, 3))
+
+      withSparkSQLConf("spark.paimon.vector-search.distribute.enabled" -> 
"true") {
+        val ids = spark
+          .sql("""
+                 |SELECT id
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 3)
+                 |""".stripMargin)
+          .collect()
+          .map(_.getInt(0))
+          .toSet
+        assert(ids == Set(1, 2, 3))
+      }
+    }
+  }
+
+  test("primary-key vector search prunes partitions before top k") {
+    withTable("T") {
+      createVectorTable(
+        columns = "id INT, embedding ARRAY<FLOAT>, dt STRING",
+        primaryKey = "id,dt",
+        partitionedBy = Some("dt"))
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, array(1.0f, 0.0f), 'A'),
+                  |  (2, array(2.0f, 0.0f), 'A'),
+                  |  (3, array(0.1f, 0.0f), 'B')
+                  |""".stripMargin)
+
+      withSparkSQLConf("spark.paimon.vector-search.distribute.enabled" -> 
"true") {
+        val ids = spark
+          .sql("""
+                 |SELECT id
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 2)
+                 |WHERE dt = 'A'
+                 |""".stripMargin)
+          .collect()
+          .map(_.getInt(0))
+          .toSet
+        assert(ids == Set(1, 2))
+      }
+    }
+  }
+
+  test("deduplicate updates and deletes primary-key vector results") {
+    withTable("T") {
+      createVectorTable()
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, array(3.0f, 0.0f)),
+                  |  (2, array(1.0f, 0.0f))
+                  |""".stripMargin)
+      spark.sql("INSERT INTO T VALUES (1, array(0.5f, 0.0f))")
+
+      withSparkSQLConf("spark.paimon.vector-search.distribute.enabled" -> 
"true") {
+        val updated = spark
+          .sql("""
+                 |SELECT id
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 1)
+                 |""".stripMargin)
+          .collect()
+        assert(updated.map(_.getInt(0)).toSeq == Seq(1))
+
+        spark.sql("DELETE FROM T WHERE id = 1")
+
+        val afterDelete = spark
+          .sql("""
+                 |SELECT id
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 1)
+                 |""".stripMargin)
+          .collect()
+        assert(afterDelete.map(_.getInt(0)).toSeq == Seq(2))
+      }
+    }
+  }
+
+  test("partial update completes rows before publishing vector results") {
+    withTable("T") {
+      createVectorTable(
+        columns = "id INT, payload STRING, embedding ARRAY<FLOAT>",
+        extraOptions =
+          Seq("merge-engine" -> "partial-update", 
"deletion-vectors.merge-on-read" -> "false")
+      )
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, 'keep', array(3.0f, 0.0f)),
+                  |  (2, 'other', array(1.0f, 0.0f))
+                  |""".stripMargin)
+      spark.sql("INSERT INTO T (id, embedding) VALUES (1, array(0.5f, 0.0f))")
+
+      withSparkSQLConf("spark.paimon.vector-search.distribute.enabled" -> 
"true") {
+        val rows = spark
+          .sql("""
+                 |SELECT id, payload
+                 |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 1)
+                 |""".stripMargin)
+          .collect()
+        assert(rows.length == 1)
+        assert(rows.head.getInt(0) == 1)
+        assert(rows.head.getString(1) == "keep")
+      }
+    }
+  }
+
+  private def createVectorTable(
+      columns: String = "id INT, embedding ARRAY<FLOAT>",
+      primaryKey: String = "id",
+      bucket: Int = 1,
+      extraOptions: Seq[(String, String)] = Seq.empty,
+      partitionedBy: Option[String] = None): Unit = {
+    val properties = (Seq(
+      "primary-key" -> primaryKey,
+      "bucket" -> bucket.toString,
+      "deletion-vectors.enabled" -> "true",
+      "vector-field" -> "embedding",
+      "field.embedding.vector-dim" -> "2",
+      "pk-vector.index.columns" -> "embedding",
+      "fields.embedding.pk-vector.index.type" -> 
TestVectorGlobalIndexerFactory.IDENTIFIER,
+      "fields.embedding.pk-vector.distance.metric" -> "l2",
+      "test.vector.dimension" -> "2",
+      "test.vector.metric" -> "l2"
+    ) ++ extraOptions)
+      .map { case (key, value) => s"'$key' = '$value'" }
+      .mkString(",\n")
+    val partitioning = partitionedBy.map(column => s"PARTITIONED BY 
($column)").getOrElse("")
+    spark.sql(s"""
+                 |CREATE TABLE T ($columns)
+                 |$partitioning
+                 |TBLPROPERTIES ($properties)
+                 |""".stripMargin)
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/VectorSearchOptionsTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/VectorSearchOptionsTest.scala
index 79b78a6146..2531fffad7 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/VectorSearchOptionsTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/VectorSearchOptionsTest.scala
@@ -65,6 +65,15 @@ class VectorSearchOptionsTest extends PaimonSparkTestBase {
         .collect()
 
       assert(result.length == 1)
+
+      val scores = spark
+        .sql("""
+               |SELECT __paimon_search_score FROM vector_search(
+               |  'T', 'v', array(1.0f, 0.0f), 1, map('ivf.nprobe', '16'))
+               |""".stripMargin)
+        .collect()
+      assert(scores.length == 1)
+      assert(!scores.head.isNullAt(0))
     }
   }
 }

Reply via email to