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 21d8e0ca59 [core] Add primary-key vector index foundation (#8549)
21d8e0ca59 is described below

commit 21d8e0ca59ed29e3551729d4c3dbb74f06ae756a
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 11 22:39:29 2026 +0800

    [core] Add primary-key vector index foundation (#8549)
    
    Introduce the foundation for bucket-local vector indexes on primary-key
    tables. This PR defines the table-option and validation contract, ANN
    payload metadata, and ANN build/search primitives used by later write,
    compaction, manifest, and query integrations.
---
 docs/generated/core_configuration.html             |   6 +
 .../main/java/org/apache/paimon/CoreOptions.java   | 116 +++++++++
 .../testvector/TestVectorGlobalIndexer.java        |  19 +-
 .../testvector/TestVectorGlobalIndexerTest.java    |  41 ++++
 .../org/apache/paimon/index/GlobalIndexMeta.java   |  22 +-
 .../paimon/index/IndexFileMetaSerializer.java      |  16 +-
 .../index/pkvector/PkVectorAnnSegmentFile.java     | 269 ++++++++++++++++++++
 .../index/pkvector/PkVectorAnnSegmentSearcher.java | 272 +++++++++++++++++++++
 .../paimon/index/pkvector/PkVectorReader.java      |  33 +++
 .../paimon/index/pkvector/PkVectorSourceFile.java  |  61 +++++
 .../paimon/index/pkvector/PkVectorSourceMeta.java  |  91 +++++++
 .../manifest/IndexManifestEntrySerializer.java     |  16 +-
 .../org/apache/paimon/schema/SchemaManager.java    |  14 ++
 .../org/apache/paimon/schema/SchemaValidation.java |  71 ++++++
 .../paimon/index/IndexFileMetaSerializerTest.java  |  23 ++
 .../index/pkvector/PkVectorAnnSegmentFileTest.java | 237 ++++++++++++++++++
 .../index/pkvector/PkVectorSourceMetaTest.java     |  56 +++++
 .../pkvector/PrimaryKeyVectorIndexOptionsTest.java | 157 ++++++++++++
 .../manifest/IndexManifestEntrySerializerTest.java |  38 +++
 .../PrimaryKeyVectorIndexValidationTest.java       | 236 ++++++++++++++++++
 .../apache/paimon/schema/SchemaManagerTest.java    |  29 +++
 21 files changed, 1812 insertions(+), 11 deletions(-)

diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 401d451b86..d22fb24cdf 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1205,6 +1205,12 @@ This config option does not affect the default 
filesystem metastore.</td>
             <td>Boolean</td>
             <td>Enables clustering by non-primary key fields. When set to 
true, the physical sort order of data files is determined by the configured 
'clustering.columns' instead of the primary key, optimizing query performance 
for non-PK columns.</td>
         </tr>
+        <tr>
+            <td><h5>pk-vector.index.columns</h5></td>
+            <td style="word-wrap: break-word;">(none)</td>
+            <td>String</td>
+            <td>Comma-separated VECTOR columns indexed by primary-key vector 
indexes. Each column owns one index and must define 
fields.&lt;column&gt;.pk-vector.index.type. Index options and distance metric 
are also field-scoped. The first release supports exactly one column.</td>
+        </tr>
         <tr>
             <td><h5>postpone.batch-write-fixed-bucket</h5></td>
             <td style="word-wrap: break-word;">true</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index b353b56327..0dd64d8d25 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -33,6 +33,7 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.options.description.DescribedEnum;
 import org.apache.paimon.options.description.Description;
 import org.apache.paimon.options.description.InlineElement;
+import org.apache.paimon.utils.JsonSerdeUtil;
 import org.apache.paimon.utils.MathUtils;
 import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.StringUtils;
@@ -48,11 +49,13 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.UUID;
 import java.util.stream.Collectors;
 
@@ -2726,6 +2729,17 @@ public class CoreOptions implements Serializable {
                             "The batch size for lateral vector search. Each 
batch executes vector "
                                     + "topK search and table lookup for 
multiple query vectors.");
 
+    public static final ConfigOption<String> PK_VECTOR_INDEX_COLUMNS =
+            key("pk-vector.index.columns")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Comma-separated VECTOR columns indexed by 
primary-key vector indexes. "
+                                    + "Each column owns one index and must 
define "
+                                    + "fields.<column>.pk-vector.index.type. 
Index options and distance "
+                                    + "metric are also field-scoped. The first 
release supports exactly "
+                                    + "one column.");
+
     @Immutable
     public static final ConfigOption<Boolean> PK_CLUSTERING_OVERRIDE =
             key("pk-clustering-override")
@@ -4254,6 +4268,108 @@ public class CoreOptions implements Serializable {
         return options.get(VECTOR_SEARCH_LATERAL_JOIN_BATCH_SIZE);
     }
 
+    public boolean primaryKeyVectorIndexEnabled() {
+        return options.getOptional(PK_VECTOR_INDEX_COLUMNS).isPresent();
+    }
+
+    public List<String> primaryKeyVectorIndexColumns() {
+        String columns = options.get(PK_VECTOR_INDEX_COLUMNS);
+        if (columns == null) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(columns.split(",", 
-1)).map(String::trim).collect(Collectors.toList());
+    }
+
+    public String primaryKeyVectorIndexColumn() {
+        List<String> columns = primaryKeyVectorIndexColumns();
+        checkArgument(
+                columns.size() == 1,
+                "pk-vector.index.columns must contain exactly one column in 
the first release, but is %s.",
+                columns);
+        return columns.get(0);
+    }
+
+    @Nullable
+    public String primaryKeyVectorIndexType(String column) {
+        return options.get("fields." + column + ".pk-vector.index.type");
+    }
+
+    @Nullable
+    private String primaryKeyVectorIndexOptionsJson(String column) {
+        return options.get("fields." + column + ".pk-vector.index.options");
+    }
+
+    public Options primaryKeyVectorIndexOptions(String column) {
+        Options resolved = new Options(toConfiguration().toMap());
+        for (Map.Entry<String, String> option :
+                primaryKeyVectorAlgorithmOptions(column).entrySet()) {
+            resolved.setString(option.getKey(), option.getValue());
+        }
+        return resolved;
+    }
+
+    private Map<String, String> primaryKeyVectorAlgorithmOptions(String 
column) {
+        String indexTypeKey = "fields." + column + ".pk-vector.index.type";
+        String indexOptionsKey = "fields." + column + 
".pk-vector.index.options";
+        String algorithm = primaryKeyVectorIndexType(column);
+        checkArgument(
+                algorithm != null && !algorithm.trim().isEmpty(),
+                "%s must be configured before resolving index options.",
+                indexTypeKey);
+        TreeMap<String, String> algorithmOptions = new TreeMap<>();
+        String algorithmPrefix = algorithm + ".";
+        String fieldPrefix = "fields." + column + ".";
+        for (Map.Entry<String, String> entry : 
toConfiguration().toMap().entrySet()) {
+            if (entry.getKey().startsWith(algorithmPrefix)
+                    || (entry.getKey().startsWith(fieldPrefix)
+                            && !entry.getKey().startsWith(fieldPrefix + 
"pk-vector."))) {
+                algorithmOptions.put(entry.getKey(), entry.getValue());
+            }
+        }
+        String serialized = primaryKeyVectorIndexOptionsJson(column);
+        if (serialized != null && !serialized.trim().isEmpty()) {
+            LinkedHashMap<String, String> parsed;
+            try {
+                parsed = JsonSerdeUtil.parseJsonMap(serialized, String.class);
+            } catch (RuntimeException e) {
+                throw new IllegalArgumentException(
+                        indexOptionsKey + " must be a JSON object of option 
key-value pairs.", e);
+            }
+            for (Map.Entry<String, String> entry : parsed.entrySet()) {
+                String key = entry.getKey();
+                String value = entry.getValue();
+                checkArgument(
+                        key != null && !key.trim().isEmpty(),
+                        "%s contains an empty option key.",
+                        indexOptionsKey);
+                checkArgument(
+                        value != null,
+                        "%s value for key %s must not be null.",
+                        indexOptionsKey,
+                        key);
+                String qualifiedKey =
+                        key.startsWith(algorithmPrefix) || 
key.startsWith("fields.")
+                                ? key
+                                : algorithmPrefix + key;
+                String previous = algorithmOptions.put(qualifiedKey, value);
+                checkArgument(
+                        previous == null || previous.equals(value),
+                        "%s defines conflicting values for %s.",
+                        indexOptionsKey,
+                        qualifiedKey);
+            }
+        }
+        algorithmOptions.put(algorithmPrefix + "metric", 
primaryKeyVectorDistanceMetric(column));
+        return algorithmOptions;
+    }
+
+    public String primaryKeyVectorDistanceMetric(String column) {
+        String metric = options.get("fields." + column + 
".pk-vector.distance.metric");
+        return (metric == null ? "inner_product" : metric)
+                .toLowerCase(Locale.ROOT)
+                .replace('-', '_');
+    }
+
     /** Specifies the merge engine for table with primary key. */
     public enum MergeEngine implements DescribedEnum {
         DEDUPLICATE("deduplicate", "De-duplicate and keep the last row."),
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
index 0b6d8d9fd6..eb0365427a 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java
@@ -28,6 +28,7 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.FloatType;
+import org.apache.paimon.types.VectorType;
 
 import java.io.IOException;
 import java.util.List;
@@ -74,17 +75,27 @@ public class TestVectorGlobalIndexer implements 
VectorGlobalIndexer {
 
     public TestVectorGlobalIndexer(DataType fieldType, Options options) {
         checkArgument(
-                fieldType instanceof ArrayType
-                        && ((ArrayType) fieldType).getElementType() instanceof 
FloatType,
-                "TestVectorGlobalIndexer only supports ARRAY<FLOAT>, but got: 
" + fieldType);
+                isFloatVector(fieldType),
+                "TestVectorGlobalIndexer only supports VECTOR<FLOAT> or 
ARRAY<FLOAT>, but got: "
+                        + fieldType);
         this.fieldType = fieldType;
-        this.dimension = options.getInteger(OPT_DIMENSION, 0);
+        this.dimension =
+                fieldType instanceof VectorType
+                        ? ((VectorType) fieldType).getLength()
+                        : options.getInteger(OPT_DIMENSION, 0);
         this.metric = options.getString(OPT_METRIC, "l2");
         this.reverseScore = options.getBoolean(OPT_REVERSE_SCORE, false);
         this.requiredOptionKey = options.getString(OPT_REQUIRED_OPTION_KEY, 
null);
         this.requiredOptionValue = 
options.getString(OPT_REQUIRED_OPTION_VALUE, null);
     }
 
+    private static boolean isFloatVector(DataType fieldType) {
+        return (fieldType instanceof VectorType
+                        && ((VectorType) fieldType).getElementType() 
instanceof FloatType)
+                || (fieldType instanceof ArrayType
+                        && ((ArrayType) fieldType).getElementType() instanceof 
FloatType);
+    }
+
     @Override
     public GlobalIndexWriter createWriter(GlobalIndexFileWriter fileWriter) 
throws IOException {
         return new TestVectorGlobalIndexWriter(fileWriter, dimension);
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerTest.java
new file mode 100644
index 0000000000..7ab5c45cef
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerTest.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.globalindex.testvector;
+
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link TestVectorGlobalIndexer}. */
+class TestVectorGlobalIndexerTest {
+
+    @Test
+    void testUsesDimensionFromVectorType() {
+        Options options = new Options();
+        options.setString(TestVectorGlobalIndexer.OPT_DIMENSION, "99");
+
+        TestVectorGlobalIndexer indexer =
+                new TestVectorGlobalIndexer(DataTypes.VECTOR(2, 
DataTypes.FLOAT()), options);
+
+        assertThat(indexer.dimension()).isEqualTo(2);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java 
b/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java
index 83a6224f3e..727a6536e3 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java
@@ -43,13 +43,15 @@ public class GlobalIndexMeta {
                             new DataField(2, "_INDEX_FIELD_ID", new 
IntType(false)),
                             new DataField(
                                     3, "_EXTRA_FIELD_IDS", DataTypes.ARRAY(new 
IntType(false))),
-                            new DataField(4, "_INDEX_META", 
DataTypes.BYTES())));
+                            new DataField(4, "_INDEX_META", DataTypes.BYTES()),
+                            new DataField(5, "_SOURCE_META", 
DataTypes.BYTES())));
 
     private final long rowRangeStart;
     private final long rowRangeEnd;
     private final int indexFieldId;
     @Nullable private final int[] extraFieldIds;
     @Nullable private final byte[] indexMeta;
+    @Nullable private final byte[] sourceMeta;
 
     public GlobalIndexMeta(
             long rowRangeStart,
@@ -57,11 +59,22 @@ public class GlobalIndexMeta {
             int indexFieldId,
             @Nullable int[] extraFieldIds,
             @Nullable byte[] indexMeta) {
+        this(rowRangeStart, rowRangeEnd, indexFieldId, extraFieldIds, 
indexMeta, null);
+    }
+
+    public GlobalIndexMeta(
+            long rowRangeStart,
+            long rowRangeEnd,
+            int indexFieldId,
+            @Nullable int[] extraFieldIds,
+            @Nullable byte[] indexMeta,
+            @Nullable byte[] sourceMeta) {
         this.rowRangeStart = rowRangeStart;
         this.rowRangeEnd = rowRangeEnd;
         this.indexFieldId = indexFieldId;
         this.extraFieldIds = extraFieldIds;
         this.indexMeta = indexMeta;
+        this.sourceMeta = sourceMeta;
     }
 
     public long rowRangeStart() {
@@ -85,11 +98,18 @@ public class GlobalIndexMeta {
         return extraFieldIds;
     }
 
+    /** Metadata produced and consumed by the global-index implementation. */
     @Nullable
     public byte[] indexMeta() {
         return indexMeta;
     }
 
+    /** Metadata describing how index row ids map to their source data. */
+    @Nullable
+    public byte[] sourceMeta() {
+        return sourceMeta;
+    }
+
     /** All indexed field ids in order: the primary {@link #indexFieldId} 
followed by the rest. */
     public List<Integer> getIndexedFieldIds() {
         List<Integer> ids = new ArrayList<>();
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java
index 6d98e61248..a2acfd0ffc 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java
@@ -50,7 +50,8 @@ public class IndexFileMetaSerializer extends 
ObjectSerializer<IndexFileMeta> {
                                 globalIndexMeta.extraFieldIds() == null
                                         ? null
                                         : new 
GenericArray(globalIndexMeta.extraFieldIds()),
-                                globalIndexMeta.indexMeta());
+                                globalIndexMeta.indexMeta(),
+                                globalIndexMeta.sourceMeta());
         return GenericRow.of(
                 fromString(record.indexType()),
                 fromString(record.fileName()),
@@ -65,16 +66,25 @@ public class IndexFileMetaSerializer extends 
ObjectSerializer<IndexFileMeta> {
     public IndexFileMeta fromRow(InternalRow row) {
         GlobalIndexMeta globalIndexMeta = null;
         if (!row.isNullAt(6)) {
-            InternalRow globalIndexRow = row.getRow(6, 5);
+            InternalRow globalIndexRow = row.getRow(6, 6);
             Long rowRangeStart = globalIndexRow.getLong(0);
             Long rowRangeEnd = globalIndexRow.getLong(1);
             Integer indexFieldId = globalIndexRow.getInt(2);
             int[] extralFields =
                     globalIndexRow.isNullAt(3) ? null : 
globalIndexRow.getArray(3).toIntArray();
             byte[] indexMeta = globalIndexRow.isNullAt(4) ? null : 
globalIndexRow.getBinary(4);
+            byte[] sourceMeta =
+                    globalIndexRow.getFieldCount() <= 5 || 
globalIndexRow.isNullAt(5)
+                            ? null
+                            : globalIndexRow.getBinary(5);
             globalIndexMeta =
                     new GlobalIndexMeta(
-                            rowRangeStart, rowRangeEnd, indexFieldId, 
extralFields, indexMeta);
+                            rowRangeStart,
+                            rowRangeEnd,
+                            indexFieldId,
+                            extralFields,
+                            indexMeta,
+                            sourceMeta);
         }
         return new IndexFileMeta(
                 row.getString(0).toString(),
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFile.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFile.java
new file mode 100644
index 0000000000..9a3306734e
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFile.java
@@ -0,0 +1,269 @@
+/*
+ * 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.index.pkvector;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFile;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.VectorType;
+import org.apache.paimon.utils.IOUtils;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.LongPredicate;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Builds immutable ANN payloads whose index ids are source data-file row 
positions. */
+public class PkVectorAnnSegmentFile extends IndexFile {
+
+    public PkVectorAnnSegmentFile(FileIO fileIO, IndexPathFactory pathFactory) 
{
+        super(fileIO, pathFactory);
+    }
+
+    public IndexFileMeta build(
+            List<Source> sources,
+            DataField vectorField,
+            Options indexOptions,
+            String metric,
+            String indexType)
+            throws IOException {
+        checkArgument(!sources.isEmpty(), "An ANN segment must reference 
source files.");
+        long totalRowCount = 0;
+        List<PkVectorSourceFile> sourceFiles = new ArrayList<>(sources.size());
+        for (Source source : sources) {
+            totalRowCount = Math.addExact(totalRowCount, 
source.sourceFile.rowCount());
+            sourceFiles.add(source.sourceFile);
+        }
+        checkArgument(totalRowCount > 0, "An ANN segment must reference at 
least one source row.");
+
+        GlobalIndexer indexer = GlobalIndexer.create(indexType, vectorField, 
indexOptions);
+        checkArgument(
+                indexer instanceof VectorGlobalIndexer,
+                "Index algorithm %s does not implement VectorGlobalIndexer.",
+                indexType);
+        String indexerMetric = normalizeMetric(((VectorGlobalIndexer) 
indexer).metric());
+        checkArgument(
+                normalizeMetric(metric).equals(indexerMetric),
+                "Configured metric %s does not match index algorithm metric 
%s.",
+                metric,
+                indexerMetric);
+
+        SegmentFileWriter fileWriter = new SegmentFileWriter();
+        GlobalIndexWriter writer = null;
+        boolean success = false;
+        try {
+            writer = indexer.createWriter(fileWriter);
+            checkArgument(
+                    writer instanceof GlobalIndexSingleColumnWriter,
+                    "Index algorithm %s does not create a single-column 
writer.",
+                    indexType);
+            GlobalIndexSingleColumnWriter singleColumnWriter =
+                    (GlobalIndexSingleColumnWriter) writer;
+            long liveRowCount = 0;
+            long fileOffset = 0;
+            int dimension = -1;
+            for (Source source : sources) {
+                PkVectorReader vectors = source.openReader();
+                try {
+                    checkArgument(
+                            vectors.rowCount() == source.sourceFile.rowCount(),
+                            "Vector row count %s does not match source file %s 
row count %s.",
+                            vectors.rowCount(),
+                            source.sourceFile.fileName(),
+                            source.sourceFile.rowCount());
+                    if (dimension < 0) {
+                        dimension = vectors.dimension();
+                    }
+                    checkArgument(
+                            vectors.dimension() == dimension,
+                            "Vector source %s dimension %s does not match 
dimension %s.",
+                            source.sourceFile.fileName(),
+                            vectors.dimension(),
+                            dimension);
+                    if (vectorField.type() instanceof VectorType) {
+                        checkArgument(
+                                ((VectorType) vectorField.type()).getLength() 
== dimension,
+                                "Vector field dimension %s does not match 
source vector dimension %s.",
+                                ((VectorType) vectorField.type()).getLength(),
+                                dimension);
+                    }
+
+                    float[] vector = new float[dimension];
+                    for (long rowPosition = 0; rowPosition < 
vectors.rowCount(); rowPosition++) {
+                        boolean present = vectors.readNextVector(vector);
+                        if (!present || 
source.excludedPosition.test(rowPosition)) {
+                            continue;
+                        }
+                        singleColumnWriter.write(vector, fileOffset + 
rowPosition);
+                        liveRowCount++;
+                    }
+                } finally {
+                    source.closeReader(vectors);
+                }
+                fileOffset += source.sourceFile.rowCount();
+            }
+
+            List<ResultEntry> results = writer.finish();
+            checkArgument(
+                    results.size() == 1,
+                    "ANN segment build must produce exactly one payload file, 
but produced %s.",
+                    results.size());
+            ResultEntry result = results.get(0);
+            Path payloadPath = fileWriter.path(result.fileName());
+            byte[] payloadMetadata = result.meta() == null ? new byte[0] : 
result.meta();
+            IndexFileMeta segment =
+                    new IndexFileMeta(
+                            indexType,
+                            result.fileName(),
+                            fileIO.getFileSize(payloadPath),
+                            liveRowCount,
+                            new GlobalIndexMeta(
+                                    0,
+                                    totalRowCount - 1,
+                                    vectorField.id(),
+                                    null,
+                                    payloadMetadata,
+                                    new 
PkVectorSourceMeta(sourceFiles).serialize()),
+                            pathFactory.isExternalPath() ? 
payloadPath.toString() : null);
+            success = true;
+            return segment;
+        } finally {
+            if (writer instanceof AutoCloseable) {
+                IOUtils.closeQuietly((AutoCloseable) writer);
+            }
+            if (!success) {
+                fileWriter.deleteCreatedFiles();
+            }
+        }
+    }
+
+    private static PkVectorSourceFile sourceMetadata(DataFileMeta sourceFile) {
+        return new PkVectorSourceFile(sourceFile.fileName(), 
sourceFile.rowCount());
+    }
+
+    private static String normalizeMetric(String metric) {
+        return metric.toLowerCase(Locale.ROOT).replace('-', '_');
+    }
+
+    private class SegmentFileWriter implements GlobalIndexFileWriter {
+
+        private final Map<String, Path> createdFiles = new HashMap<>();
+
+        @Override
+        public String newFileName(String prefix) {
+            Path path = pathFactory.newPath();
+            createdFiles.put(path.getName(), path);
+            return path.getName();
+        }
+
+        @Override
+        public PositionOutputStream newOutputStream(String fileName) throws 
IOException {
+            return fileIO.newOutputStream(path(fileName), false);
+        }
+
+        private Path path(String fileName) {
+            Path path = createdFiles.get(fileName);
+            checkArgument(path != null, "ANN payload file %s was not 
allocated.", fileName);
+            return path;
+        }
+
+        private void deleteCreatedFiles() {
+            for (Path path : createdFiles.values()) {
+                fileIO.deleteQuietly(path);
+            }
+        }
+    }
+
+    /** One vector source used while building an ANN segment. */
+    public static class Source {
+
+        private final PkVectorSourceFile sourceFile;
+        @Nullable private final PkVectorReader vectors;
+        @Nullable private final ReaderFactory readerFactory;
+        private final LongPredicate excludedPosition;
+
+        public Source(DataFileMeta sourceFile, PkVectorReader vectors) {
+            this(sourceFile, vectors, position -> false);
+        }
+
+        public Source(
+                DataFileMeta sourceFile, PkVectorReader vectors, LongPredicate 
excludedPosition) {
+            this(sourceMetadata(sourceFile), vectors, excludedPosition);
+        }
+
+        Source(
+                PkVectorSourceFile sourceFile,
+                PkVectorReader vectors,
+                LongPredicate excludedPosition) {
+            this.sourceFile = sourceFile;
+            this.vectors = vectors;
+            this.readerFactory = null;
+            this.excludedPosition = excludedPosition;
+        }
+
+        private Source(
+                PkVectorSourceFile sourceFile,
+                ReaderFactory readerFactory,
+                LongPredicate excludedPosition) {
+            this.sourceFile = sourceFile;
+            this.vectors = null;
+            this.readerFactory = readerFactory;
+            this.excludedPosition = excludedPosition;
+        }
+
+        static Source lazy(PkVectorSourceFile sourceFile, ReaderFactory 
readerFactory) {
+            return new Source(sourceFile, readerFactory, position -> false);
+        }
+
+        private PkVectorReader openReader() throws IOException {
+            return vectors != null ? vectors : readerFactory.open();
+        }
+
+        private void closeReader(PkVectorReader reader) throws IOException {
+            if (vectors == null) {
+                reader.close();
+            }
+        }
+
+        @FunctionalInterface
+        interface ReaderFactory {
+            PkVectorReader open() throws IOException;
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
new file mode 100644
index 0000000000..e3b2fbba7f
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
@@ -0,0 +1,272 @@
+/*
+ * 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.index.pkvector;
+
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.VectorSearch;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Searches one ANN payload and maps its segment-local ids back to source row 
positions. */
+public class PkVectorAnnSegmentSearcher {
+
+    private static final Comparator<Candidate> BEST_FIRST =
+            Comparator.comparingDouble((Candidate candidate) -> 
candidate.distance)
+                    .thenComparing(candidate -> candidate.dataFileName)
+                    .thenComparingLong(candidate -> candidate.rowPosition);
+
+    private final FileIO fileIO;
+    private final PkVectorAnnSegmentFile annSegmentFile;
+    private final DataField vectorField;
+    private final Options indexOptions;
+    private final String metric;
+    private final ExecutorService executor;
+
+    public PkVectorAnnSegmentSearcher(
+            FileIO fileIO,
+            PkVectorAnnSegmentFile annSegmentFile,
+            DataField vectorField,
+            Options indexOptions,
+            String metric,
+            ExecutorService executor) {
+        this.fileIO = fileIO;
+        this.annSegmentFile = annSegmentFile;
+        this.vectorField = vectorField;
+        this.indexOptions = indexOptions;
+        this.metric = normalizeMetric(metric);
+        this.executor = executor;
+    }
+
+    public List<Candidate> search(
+            IndexFileMeta segment,
+            PkVectorSourceMeta sourceMeta,
+            float[] query,
+            int limit,
+            @Nullable DeletionVector deletionVector,
+            Map<String, String> searchOptions) {
+        Map<String, DeletionVector> deletionVectors = new HashMap<>();
+        if (deletionVector != null) {
+            checkArgument(
+                    sourceMeta.sourceFiles().size() == 1,
+                    "A single deletion vector can only search a single-source 
ANN segment.");
+            deletionVectors.put(sourceMeta.sourceFiles().get(0).fileName(), 
deletionVector);
+        }
+        return search(segment, sourceMeta, query, limit, deletionVectors, 
searchOptions);
+    }
+
+    public List<Candidate> search(
+            IndexFileMeta segment,
+            PkVectorSourceMeta sourceMeta,
+            float[] query,
+            int limit,
+            Map<String, DeletionVector> deletionVectors,
+            Map<String, String> searchOptions) {
+        checkArgument(limit > 0, "Vector search limit must be positive: %s.", 
limit);
+        GlobalIndexMeta globalIndexMeta = segment.globalIndexMeta();
+        checkArgument(
+                globalIndexMeta != null && globalIndexMeta.sourceMeta() != 
null,
+                "Vector segment %s has no source metadata.",
+                segment.fileName());
+        GlobalIndexer indexer =
+                GlobalIndexer.create(segment.indexType(), vectorField, 
indexOptions);
+        checkArgument(
+                indexer instanceof VectorGlobalIndexer,
+                "Index algorithm %s does not implement VectorGlobalIndexer.",
+                segment.indexType());
+        String readerMetric = normalizeMetric(((VectorGlobalIndexer) 
indexer).metric());
+        checkArgument(
+                metric.equals(readerMetric),
+                "ANN segment metric %s does not match index reader metric %s.",
+                metric,
+                readerMetric);
+
+        GlobalIndexIOMeta ioMeta =
+                new GlobalIndexIOMeta(
+                        annSegmentFile.path(segment),
+                        segment.fileSize(),
+                        globalIndexMeta.indexMeta());
+        GlobalIndexReader reader =
+                indexer.createReader(
+                        meta -> fileIO.newInputStream(meta.filePath()),
+                        Collections.singletonList(ioMeta),
+                        executor);
+        try {
+            VectorSearch search = new VectorSearch(query, limit, 
vectorField.name(), searchOptions);
+            RoaringNavigableMap64 liveRows =
+                    liveRowPositions(sourceMeta.sourceFiles(), 
deletionVectors);
+            if (liveRows != null) {
+                search.withIncludeRowIds(liveRows);
+            }
+            Optional<ScoredGlobalIndexResult> result = 
reader.visitVectorSearch(search).join();
+            if (!result.isPresent()) {
+                return Collections.emptyList();
+            }
+
+            long sourceRowCount = totalRowCount(sourceMeta.sourceFiles());
+            List<Candidate> candidates = new ArrayList<>();
+            ScoredGlobalIndexResult scored = result.get();
+            for (long ordinal : scored.results()) {
+                checkArgument(
+                        ordinal >= 0 && ordinal < sourceRowCount,
+                        "ANN segment %s returned ordinal %s outside [0, %s).",
+                        segment.fileName(),
+                        ordinal,
+                        sourceRowCount);
+                FilePosition filePosition = 
filePosition(sourceMeta.sourceFiles(), ordinal);
+                DeletionVector deletionVector = 
deletionVectors.get(filePosition.dataFileName);
+                checkArgument(
+                        deletionVector == null
+                                || 
!deletionVector.isDeleted(filePosition.rowPosition),
+                        "ANN segment %s returned snapshot-deleted row position 
%s.",
+                        segment.fileName(),
+                        filePosition.rowPosition);
+                candidates.add(
+                        new Candidate(
+                                filePosition.dataFileName,
+                                filePosition.rowPosition,
+                                
scoreToDistance(scored.scoreGetter().score(ordinal), metric)));
+            }
+            Collections.sort(candidates, BEST_FIRST);
+            return Collections.unmodifiableList(candidates);
+        } finally {
+            IOUtils.closeQuietly(reader);
+        }
+    }
+
+    @Nullable
+    private static RoaringNavigableMap64 liveRowPositions(
+            List<PkVectorSourceFile> sourceFiles, Map<String, DeletionVector> 
deletionVectors) {
+        if (deletionVectors.isEmpty()) {
+            return null;
+        }
+        RoaringNavigableMap64 live = new RoaringNavigableMap64();
+        RoaringNavigableMap64 deleted = new RoaringNavigableMap64();
+        long fileOffset = 0;
+        for (PkVectorSourceFile sourceFile : sourceFiles) {
+            if (sourceFile.rowCount() > 0) {
+                live.addRange(new Range(fileOffset, fileOffset + 
sourceFile.rowCount() - 1));
+            }
+            DeletionVector deletionVector = 
deletionVectors.get(sourceFile.fileName());
+            if (deletionVector != null) {
+                final long offset = fileOffset;
+                deletionVector.forEachDeletedPosition(position -> 
deleted.add(offset + position));
+            }
+            fileOffset += sourceFile.rowCount();
+        }
+        live.andNot(deleted);
+        return live;
+    }
+
+    private static long totalRowCount(List<PkVectorSourceFile> sourceFiles) {
+        long total = 0;
+        for (PkVectorSourceFile sourceFile : sourceFiles) {
+            total = Math.addExact(total, sourceFile.rowCount());
+        }
+        return total;
+    }
+
+    private static FilePosition filePosition(List<PkVectorSourceFile> 
sourceFiles, long ordinal) {
+        long fileOffset = 0;
+        for (PkVectorSourceFile sourceFile : sourceFiles) {
+            long nextOffset = fileOffset + sourceFile.rowCount();
+            if (ordinal < nextOffset) {
+                return new FilePosition(sourceFile.fileName(), ordinal - 
fileOffset);
+            }
+            fileOffset = nextOffset;
+        }
+        throw new IllegalArgumentException("ANN ordinal is outside source 
files: " + ordinal);
+    }
+
+    private static float scoreToDistance(float score, String metric) {
+        if ("l2".equals(metric)) {
+            return 1F / score - 1F;
+        } else if ("cosine".equals(metric)) {
+            return 1F - score;
+        } else if ("inner_product".equals(metric)) {
+            return -score;
+        }
+        throw new IllegalArgumentException("Unsupported ANN vector metric: " + 
metric);
+    }
+
+    private static String normalizeMetric(String metric) {
+        return metric.toLowerCase(Locale.ROOT).replace('-', '_');
+    }
+
+    /** One ANN candidate addressed by source-file row position. */
+    public static class Candidate {
+
+        private final long rowPosition;
+        private final float distance;
+        private final String dataFileName;
+
+        private Candidate(String dataFileName, long rowPosition, float 
distance) {
+            this.dataFileName = dataFileName;
+            this.rowPosition = rowPosition;
+            this.distance = distance;
+        }
+
+        public String dataFileName() {
+            return dataFileName;
+        }
+
+        public long rowPosition() {
+            return rowPosition;
+        }
+
+        public float distance() {
+            return distance;
+        }
+    }
+
+    private static class FilePosition {
+
+        private final String dataFileName;
+        private final long rowPosition;
+
+        private FilePosition(String dataFileName, long rowPosition) {
+            this.dataFileName = dataFileName;
+            this.rowPosition = rowPosition;
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorReader.java
new file mode 100644
index 0000000000..b173607ce0
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorReader.java
@@ -0,0 +1,33 @@
+/*
+ * 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.index.pkvector;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/** Sequential vectors in physical data-file row order. */
+public interface PkVectorReader extends Closeable {
+
+    int dimension();
+
+    long rowCount();
+
+    /** Reads the next physical row and returns whether it contains a non-null 
vector. */
+    boolean readNextVector(float[] reuse) throws IOException;
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceFile.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceFile.java
new file mode 100644
index 0000000000..87429d5694
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceFile.java
@@ -0,0 +1,61 @@
+/*
+ * 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.index.pkvector;
+
+import java.util.Objects;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Immutable source data-file identity captured when a vector segment is 
built. */
+public final class PkVectorSourceFile {
+
+    private final String fileName;
+    private final long rowCount;
+
+    public PkVectorSourceFile(String fileName, long rowCount) {
+        this.fileName = Objects.requireNonNull(fileName);
+        this.rowCount = rowCount;
+        checkArgument(rowCount >= 0, "Source file row count must not be 
negative.");
+    }
+
+    public String fileName() {
+        return fileName;
+    }
+
+    public long rowCount() {
+        return rowCount;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        PkVectorSourceFile that = (PkVectorSourceFile) o;
+        return rowCount == that.rowCount && Objects.equals(fileName, 
that.fileName);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(fileName, rowCount);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceMeta.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceMeta.java
new file mode 100644
index 0000000000..d0a6a8171c
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceMeta.java
@@ -0,0 +1,91 @@
+/*
+ * 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.index.pkvector;
+
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Ordered source data files for a primary-key vector index payload. */
+public final class PkVectorSourceMeta {
+
+    private static final int VERSION = 1;
+
+    private final List<PkVectorSourceFile> sourceFiles;
+
+    public PkVectorSourceMeta(List<PkVectorSourceFile> sourceFiles) {
+        this.sourceFiles = Collections.unmodifiableList(new 
ArrayList<>(sourceFiles));
+        checkArgument(!this.sourceFiles.isEmpty(), "A vector index must 
reference source files.");
+    }
+
+    public List<PkVectorSourceFile> sourceFiles() {
+        return sourceFiles;
+    }
+
+    public static PkVectorSourceMeta fromIndexFile(IndexFileMeta indexFile) {
+        GlobalIndexMeta globalIndexMeta = indexFile.globalIndexMeta();
+        checkArgument(
+                globalIndexMeta != null && globalIndexMeta.sourceMeta() != 
null,
+                "Vector index file %s has no source metadata.",
+                indexFile.fileName());
+        return deserialize(globalIndexMeta.sourceMeta());
+    }
+
+    public byte[] serialize() {
+        try {
+            DataOutputSerializer output = new DataOutputSerializer(128);
+            output.writeInt(VERSION);
+            output.writeInt(sourceFiles.size());
+            for (PkVectorSourceFile sourceFile : sourceFiles) {
+                output.writeUTF(sourceFile.fileName());
+                output.writeLong(sourceFile.rowCount());
+            }
+            return output.getCopyOfBuffer();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to serialize vector source 
metadata.", e);
+        }
+    }
+
+    public static PkVectorSourceMeta deserialize(byte[] bytes) {
+        try {
+            DataInputDeserializer input = new DataInputDeserializer(bytes);
+            int version = input.readInt();
+            checkArgument(version == VERSION, "Unsupported vector source 
version: %s.", version);
+            int sourceFileCount = input.readInt();
+            checkArgument(sourceFileCount > 0, "A vector index must reference 
source files.");
+            List<PkVectorSourceFile> sourceFiles = new 
ArrayList<>(sourceFileCount);
+            for (int i = 0; i < sourceFileCount; i++) {
+                sourceFiles.add(new PkVectorSourceFile(input.readUTF(), 
input.readLong()));
+            }
+            checkArgument(
+                    input.available() == 0, "Unexpected trailing bytes in 
vector source metadata.");
+            return new PkVectorSourceMeta(sourceFiles);
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Failed to deserialize vector 
source metadata.", e);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java
 
b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java
index 60113adff1..ef0b68d142 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java
@@ -60,7 +60,8 @@ public class IndexManifestEntrySerializer extends 
VersionedObjectSerializer<Inde
                                 globalIndexMeta.extraFieldIds() == null
                                         ? null
                                         : new 
GenericArray(globalIndexMeta.extraFieldIds()),
-                                globalIndexMeta.indexMeta());
+                                globalIndexMeta.indexMeta(),
+                                globalIndexMeta.sourceMeta());
         return GenericRow.of(
                 record.kind().toByteValue(),
                 serializeBinaryRow(record.partition()),
@@ -82,16 +83,25 @@ public class IndexManifestEntrySerializer extends 
VersionedObjectSerializer<Inde
 
         GlobalIndexMeta globalIndexMeta = null;
         if (!row.isNullAt(9)) {
-            InternalRow globalIndexRow = row.getRow(9, 5);
+            InternalRow globalIndexRow = row.getRow(9, 6);
             long rowRangeStart = globalIndexRow.getLong(0);
             long rowRangeEnd = globalIndexRow.getLong(1);
             int indexFieldId = globalIndexRow.getInt(2);
             int[] extralFields =
                     globalIndexRow.isNullAt(3) ? null : 
globalIndexRow.getArray(3).toIntArray();
             byte[] indexMeta = globalIndexRow.isNullAt(4) ? null : 
globalIndexRow.getBinary(4);
+            byte[] sourceMeta =
+                    globalIndexRow.getFieldCount() <= 5 || 
globalIndexRow.isNullAt(5)
+                            ? null
+                            : globalIndexRow.getBinary(5);
             globalIndexMeta =
                     new GlobalIndexMeta(
-                            rowRangeStart, rowRangeEnd, indexFieldId, 
extralFields, indexMeta);
+                            rowRangeStart,
+                            rowRangeEnd,
+                            indexFieldId,
+                            extralFields,
+                            indexMeta,
+                            sourceMeta);
         }
 
         return new IndexManifestEntry(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
index 0791c2539e..06f6a1e077 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
@@ -431,6 +431,7 @@ public class SchemaManager implements Serializable {
             } else if (change instanceof RenameColumn) {
                 RenameColumn rename = (RenameColumn) change;
                 assertNotUpdatingPartitionKeys(oldTableSchema, 
rename.fieldNames(), "rename");
+                assertNotRenamingPrimaryKeyVectorIndexColumn(oldTableSchema, 
rename.fieldNames());
                 assertNotRenamingBlobColumn(newFields, rename.fieldNames());
                 new NestedColumnModifier(rename.fieldNames(), lazyIdentifier) {
                     @Override
@@ -975,6 +976,19 @@ public class SchemaManager implements Serializable {
         }
     }
 
+    private static void assertNotRenamingPrimaryKeyVectorIndexColumn(
+            TableSchema schema, String[] fieldNames) {
+        if (fieldNames.length > 1) {
+            return;
+        }
+        String fieldName = fieldNames[0];
+        if (new 
CoreOptions(schema.options()).primaryKeyVectorIndexColumns().contains(fieldName))
 {
+            throw new UnsupportedOperationException(
+                    String.format(
+                            "Cannot rename primary-key vector index column: 
[%s]", fieldName));
+        }
+    }
+
     private static void assertNotChangingBlobColumnType(
             List<DataField> fields, String[] fieldNames, DataType newType) {
         if (fieldNames.length > 1) {
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 56b4676dca..2122c04ed5 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
@@ -350,6 +350,8 @@ public class SchemaValidation {
                 fieldNamesSpecifiedAsVector.isEmpty(),
                 "Some of the columns specified as vector-field are unknown.");
 
+        validatePrimaryKeyVectorIndex(schema, options);
+
         validateMergeFunctionFactory(schema);
 
         validateMapStorageLayout(schema, options);
@@ -883,6 +885,75 @@ public class SchemaValidation {
         }
     }
 
+    private static void validatePrimaryKeyVectorIndex(TableSchema schema, 
CoreOptions options) {
+        if (!options.primaryKeyVectorIndexEnabled()) {
+            return;
+        }
+
+        List<String> indexColumns = options.primaryKeyVectorIndexColumns();
+        checkArgument(
+                new HashSet<>(indexColumns).size() == indexColumns.size(),
+                "pk-vector.index.columns must not contain duplicate columns, 
but is %s.",
+                indexColumns);
+        checkArgument(
+                indexColumns.size() == 1,
+                "pk-vector.index.columns must contain exactly one column in 
the first release, but is %s.",
+                indexColumns);
+        String indexColumn = indexColumns.get(0);
+        String indexType = options.primaryKeyVectorIndexType(indexColumn);
+        checkArgument(
+                !StringUtils.isNullOrWhitespaceOnly(indexColumn),
+                "pk-vector.index.columns must contain a non-empty column.");
+        checkArgument(
+                !StringUtils.isNullOrWhitespaceOnly(indexType),
+                "fields.%s.pk-vector.index.type must be configured when a 
primary-key vector index is defined.",
+                indexColumn);
+        checkArgument(
+                !schema.primaryKeys().isEmpty(),
+                "Primary-key vector index requires a primary-key table.");
+        checkArgument(
+                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.",
+                options.mergeEngine());
+        checkArgument(
+                options.bucket() > 0,
+                "Primary-key vector index requires fixed bucket mode (bucket > 
0), but bucket is %s.",
+                options.bucket());
+        checkArgument(
+                !options.pkClusteringOverride(),
+                "Primary-key vector index does not support 
pk-clustering-override.");
+        options.primaryKeyVectorIndexOptions(indexColumn);
+
+        DataField vectorField =
+                schema.fields().stream()
+                        .filter(field -> field.name().equals(indexColumn))
+                        .findFirst()
+                        .orElse(null);
+        checkArgument(
+                vectorField != null && vectorField.type().getTypeRoot() == 
VECTOR,
+                "pk-vector.index.columns entry '%s' must reference a VECTOR 
column.",
+                indexColumn);
+        checkArgument(
+                ((VectorType) 
vectorField.type()).getElementType().getTypeRoot()
+                        == DataTypeRoot.FLOAT,
+                "pk-vector.index.columns entry '%s' must use FLOAT elements.",
+                indexColumn);
+        checkArgument(
+                Arrays.asList("l2", "cosine", "inner_product")
+                        
.contains(options.primaryKeyVectorDistanceMetric(indexColumn)),
+                "fields.%s.pk-vector.distance.metric must be one of l2, 
cosine, inner_product, but is %s.",
+                indexColumn,
+                options.primaryKeyVectorDistanceMetric(indexColumn));
+    }
+
     private static void validateSequenceField(TableSchema schema, CoreOptions 
options) {
         List<String> sequenceField = options.sequenceField();
         if (!sequenceField.isEmpty()) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java
index 7e4fe92c8d..00d97d5435 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java
@@ -22,12 +22,35 @@ import 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile;
 import org.apache.paimon.utils.ObjectSerializer;
 import org.apache.paimon.utils.ObjectSerializerTestBase;
 
+import org.junit.jupiter.api.Test;
+
 import java.util.LinkedHashMap;
 import java.util.Random;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
 /** Test for {@link org.apache.paimon.index.IndexFileMetaSerializer}. */
 public class IndexFileMetaSerializerTest extends 
ObjectSerializerTestBase<IndexFileMeta> {
 
+    @Test
+    void testGlobalIndexSourceMetaRoundTrip() {
+        IndexFileMetaSerializer serializer = new IndexFileMetaSerializer();
+        IndexFileMeta indexFile =
+                new IndexFileMeta(
+                        "ivf-pq",
+                        "index-file",
+                        100,
+                        10,
+                        new GlobalIndexMeta(0, 9, 7, null, new byte[] {3, 4}, 
new byte[] {1, 2}),
+                        null);
+
+        GlobalIndexMeta restored =
+                
serializer.fromRow(serializer.toRow(indexFile)).globalIndexMeta();
+
+        assertThat(restored.sourceMeta()).containsExactly(1, 2);
+        assertThat(restored.indexMeta()).containsExactly(3, 4);
+    }
+
     @Override
     protected ObjectSerializer<IndexFileMeta> serializer() {
         return new IndexFileMetaSerializer();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
new file mode 100644
index 0000000000..1a30e207f4
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
@@ -0,0 +1,237 @@
+/*
+ * 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.index.pkvector;
+
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests ANN construction from generic vector readers. */
+class PkVectorAnnSegmentFileTest {
+
+    @TempDir java.nio.file.Path tempPath;
+
+    @Test
+    void testBuildSkipsNullAndExcludedPhysicalRows() throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        IndexFileMeta segment =
+                annFile(fileIO)
+                        .build(
+                                Collections.singletonList(
+                                        new PkVectorAnnSegmentFile.Source(
+                                                dataFile("data-1", 3),
+                                                new ArrayReader(
+                                                        new float[][] {{0, 0}, 
null, {2, 0}}),
+                                                position -> position == 0)),
+                                vectorField(),
+                                indexOptions(),
+                                "l2",
+                                "test-vector-ann");
+
+        assertThat(segment.indexType()).isEqualTo("test-vector-ann");
+        assertThat(segment.rowCount()).isEqualTo(1);
+        PkVectorSourceMeta sourceMeta = 
PkVectorSourceMeta.fromIndexFile(segment);
+        assertThat(sourceMeta.sourceFiles())
+                .extracting(PkVectorSourceFile::fileName)
+                .containsExactly("data-1");
+    }
+
+    @Test
+    void testBuildsAndSearchesMultiSourceSegment() throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        PkVectorAnnSegmentFile annFile = annFile(fileIO);
+        IndexFileMeta segment =
+                annFile.build(
+                        Arrays.asList(
+                                new PkVectorAnnSegmentFile.Source(
+                                        dataFile("data-1", 2),
+                                        new ArrayReader(new float[][] {{5, 0}, 
{10, 0}})),
+                                new PkVectorAnnSegmentFile.Source(
+                                        dataFile("data-2", 2),
+                                        new ArrayReader(new float[][] {{0, 0}, 
{2, 0}}))),
+                        vectorField(),
+                        indexOptions(),
+                        "l2",
+                        "test-vector-ann");
+        assertThat(segment.globalIndexMeta().rowRangeStart()).isZero();
+        assertThat(segment.globalIndexMeta().rowRangeEnd()).isEqualTo(3);
+        PkVectorSourceMeta sourceMeta = 
PkVectorSourceMeta.fromIndexFile(segment);
+        BitmapDeletionVector data2Deletes = new BitmapDeletionVector();
+        data2Deletes.delete(0);
+        Map<String, org.apache.paimon.deletionvectors.DeletionVector> 
deletionVectors =
+                new HashMap<>();
+        deletionVectors.put("data-2", data2Deletes);
+
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        List<PkVectorAnnSegmentSearcher.Candidate> candidates;
+        try {
+            candidates =
+                    new PkVectorAnnSegmentSearcher(
+                                    fileIO, annFile, vectorField(), 
indexOptions(), "l2", executor)
+                            .search(
+                                    segment,
+                                    sourceMeta,
+                                    new float[] {0, 0},
+                                    3,
+                                    deletionVectors,
+                                    Collections.emptyMap());
+        } finally {
+            executor.shutdownNow();
+        }
+
+        assertThat(candidates)
+                .extracting(
+                        PkVectorAnnSegmentSearcher.Candidate::dataFileName,
+                        PkVectorAnnSegmentSearcher.Candidate::rowPosition)
+                .containsExactly(
+                        org.assertj.core.groups.Tuple.tuple("data-2", 1L),
+                        org.assertj.core.groups.Tuple.tuple("data-1", 0L),
+                        org.assertj.core.groups.Tuple.tuple("data-1", 1L));
+    }
+
+    @Test
+    void testRejectsSourcesWithoutRows() {
+        LocalFileIO fileIO = LocalFileIO.create();
+
+        assertThatThrownBy(
+                        () ->
+                                annFile(fileIO)
+                                        .build(
+                                                Collections.singletonList(
+                                                        new 
PkVectorAnnSegmentFile.Source(
+                                                                
dataFile("empty", 0),
+                                                                new 
ArrayReader(new float[0][]))),
+                                                vectorField(),
+                                                indexOptions(),
+                                                "l2",
+                                                "test-vector-ann"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("at least one source row");
+    }
+
+    private PkVectorAnnSegmentFile annFile(LocalFileIO fileIO) {
+        return new PkVectorAnnSegmentFile(fileIO, pathFactory());
+    }
+
+    private static DataField vectorField() {
+        return new DataField(7, "embedding", DataTypes.VECTOR(2, 
DataTypes.FLOAT()));
+    }
+
+    private static Options indexOptions() {
+        Options options = new Options();
+        options.setString("test.vector.dimension", "2");
+        options.setString("test.vector.metric", "l2");
+        return options;
+    }
+
+    private static DataFileMeta dataFile(String fileName, long rowCount) {
+        return DataFileMeta.forAppend(
+                fileName,
+                100,
+                rowCount,
+                SimpleStats.EMPTY_STATS,
+                0,
+                0,
+                1,
+                Collections.emptyList(),
+                null,
+                FileSource.COMPACT,
+                null,
+                null,
+                null,
+                null);
+    }
+
+    private IndexPathFactory pathFactory() {
+        Path directory = new Path(tempPath.toUri());
+        return new IndexPathFactory() {
+            @Override
+            public Path toPath(String fileName) {
+                return new Path(directory, fileName);
+            }
+
+            @Override
+            public Path newPath() {
+                return new Path(directory, UUID.randomUUID().toString());
+            }
+
+            @Override
+            public boolean isExternalPath() {
+                return false;
+            }
+        };
+    }
+
+    private static class ArrayReader implements PkVectorReader {
+
+        private final float[][] vectors;
+        private int position;
+
+        private ArrayReader(float[][] vectors) {
+            this.vectors = vectors;
+        }
+
+        @Override
+        public int dimension() {
+            return 2;
+        }
+
+        @Override
+        public long rowCount() {
+            return vectors.length;
+        }
+
+        @Override
+        public boolean readNextVector(float[] reuse) {
+            float[] vector = vectors[position++];
+            if (vector == null) {
+                return false;
+            }
+            System.arraycopy(vector, 0, reuse, 0, reuse.length);
+            return true;
+        }
+
+        @Override
+        public void close() throws IOException {}
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorSourceMetaTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorSourceMetaTest.java
new file mode 100644
index 0000000000..9d8e967267
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorSourceMetaTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.index.pkvector;
+
+import org.apache.paimon.io.DataOutputSerializer;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link PkVectorSourceMeta}. */
+class PkVectorSourceMetaTest {
+
+    @Test
+    void testRoundTrip() {
+        PkVectorSourceMeta metadata =
+                new PkVectorSourceMeta(
+                        Arrays.asList(
+                                new PkVectorSourceFile("data-1", 10),
+                                new PkVectorSourceFile("data-2", 20)));
+
+        PkVectorSourceMeta restored = 
PkVectorSourceMeta.deserialize(metadata.serialize());
+
+        assertThat(restored.sourceFiles()).isEqualTo(metadata.sourceFiles());
+    }
+
+    @Test
+    void testRejectsTruncatedSourceMetadata() throws Exception {
+        DataOutputSerializer output = new DataOutputSerializer(128);
+        output.writeInt(1);
+        output.writeInt(1);
+        output.writeUTF("data-1");
+
+        assertThatThrownBy(() -> 
PkVectorSourceMeta.deserialize(output.getCopyOfBuffer()))
+                .hasMessageContaining("Failed to deserialize vector source 
metadata");
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorIndexOptionsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorIndexOptionsTest.java
new file mode 100644
index 0000000000..449d640aa3
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorIndexOptionsTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.index.pkvector;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.options.Options;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for primary-key vector index options in {@link CoreOptions}. */
+class PrimaryKeyVectorIndexOptionsTest {
+
+    @Test
+    void testPluralFieldRegistryEnablesIndex() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+
+        assertThat(new 
CoreOptions(options).primaryKeyVectorIndexEnabled()).isTrue();
+    }
+
+    @Test
+    void testResolvesSingleIndexColumn() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+
+        assertThat(new 
CoreOptions(options).primaryKeyVectorIndexColumn()).isEqualTo("embedding");
+    }
+
+    @Test
+    void testFieldRegistryIsTheOnlyEnableSwitch() {
+        Map<String, String> options = new HashMap<>();
+        options.put("pk-vector.index.column", "embedding");
+        options.put("pk-vector.index.type", "ivf-pq");
+
+        assertThat(new 
CoreOptions(options).primaryKeyVectorIndexEnabled()).isFalse();
+    }
+
+    @Test
+    void testIndexTypeMustBeFieldScoped() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("pk-vector.index.type", "ivf-pq");
+
+        assertThat(new 
CoreOptions(options).primaryKeyVectorIndexType("embedding")).isNull();
+    }
+
+    @Test
+    void testIndexOptionsMustBeFieldScoped() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+        options.put("pk-vector.index.options", "{\"nlist\":64}");
+
+        assertThat(
+                        new CoreOptions(options)
+                                .primaryKeyVectorIndexOptions("embedding")
+                                .get("ivf-pq.nlist"))
+                .isNull();
+    }
+
+    @Test
+    void testDistanceMetricMustBeFieldScoped() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("pk-vector.distance.metric", "l2");
+
+        assertThat(new 
CoreOptions(options).primaryKeyVectorDistanceMetric("embedding"))
+                .isEqualTo("inner_product");
+    }
+
+    @Test
+    void testFieldScopedDistanceMetricOverridesTableDefault() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("pk-vector.distance.metric", "l2");
+        options.put("fields.embedding.pk-vector.distance.metric", "cosine");
+
+        assertThat(new 
CoreOptions(options).primaryKeyVectorDistanceMetric("embedding"))
+                .isEqualTo("cosine");
+    }
+
+    @Test
+    void testFieldScopedJsonOptionsOverrideTableDefault() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+        options.put("pk-vector.index.options", "{\"nlist\":64}");
+        options.put("fields.embedding.pk-vector.index.options", 
"{\"nlist\":128}");
+
+        Options resolved = new 
CoreOptions(options).primaryKeyVectorIndexOptions("embedding");
+
+        assertThat(resolved.get("ivf-pq.nlist")).isEqualTo("128");
+    }
+
+    @Test
+    void testResolvesShortAndQualifiedAlgorithmOptions() {
+        CoreOptions coreOptions =
+                coreOptions(
+                        "{\"nlist\":64,\"ivf-pq.pq.m\":\"8\"," + 
"\"fields.embedding.hnsw.m\":16}");
+
+        Options resolved = 
coreOptions.primaryKeyVectorIndexOptions("embedding");
+
+        assertThat(resolved.get("ivf-pq.nlist")).isEqualTo("64");
+        assertThat(resolved.get("ivf-pq.pq.m")).isEqualTo("8");
+        assertThat(resolved.get("fields.embedding.hnsw.m")).isEqualTo("16");
+        assertThat(resolved.get("ivf-pq.metric")).isEqualTo("l2");
+    }
+
+    @Test
+    void testRejectsNonObjectOptions() {
+        assertThatThrownBy(() -> 
coreOptions("[1,2]").primaryKeyVectorIndexOptions("embedding"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("pk-vector.index.options")
+                .hasMessageContaining("JSON object");
+    }
+
+    private static CoreOptions coreOptions(String indexOptions) {
+        return coreOptions(indexOptions, null, null);
+    }
+
+    private static CoreOptions coreOptions(
+            String indexOptions, String additionalKey, String additionalValue) 
{
+        Map<String, String> options = new HashMap<>();
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("fields.embedding.pk-vector.distance.metric", "l2");
+        if (indexOptions != null) {
+            options.put("fields.embedding.pk-vector.index.options", 
indexOptions);
+        }
+        if (additionalKey != null) {
+            options.put(additionalKey, additionalValue);
+        }
+        return new CoreOptions(options);
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java
index 0429b8dae3..0678ed787a 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java
@@ -18,17 +18,55 @@
 
 package org.apache.paimon.manifest;
 
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.utils.ObjectSerializer;
 import org.apache.paimon.utils.ObjectSerializerTestBase;
 
+import org.junit.jupiter.api.Test;
+
 import java.util.Random;
 
 import static 
org.apache.paimon.index.IndexFileMetaSerializerTest.randomIndexFile;
 import static org.apache.paimon.io.DataFileTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
 
 /** Test for {@link IndexManifestEntrySerializer}. */
 public class IndexManifestEntrySerializerTest extends 
ObjectSerializerTestBase<IndexManifestEntry> {
 
+    @Test
+    void testReadsGlobalIndexWithoutSourceMeta() {
+        IndexManifestEntrySerializer serializer = new 
IndexManifestEntrySerializer();
+        IndexManifestEntry entry =
+                new IndexManifestEntry(
+                        FileKind.ADD,
+                        BinaryRow.EMPTY_ROW,
+                        0,
+                        new IndexFileMeta(
+                                "btree",
+                                "index-file",
+                                100,
+                                10,
+                                new GlobalIndexMeta(0, 9, 7, null, new byte[] 
{1}),
+                                null));
+        GenericRow serialized = (GenericRow) serializer.convertTo(entry);
+        serialized.setField(9, GenericRow.of(0L, 9L, 7, null, new byte[] {1}));
+
+        InternalRow globalIndexRow = serialized.getRow(9, 5);
+        assertThat(globalIndexRow.getFieldCount()).isEqualTo(5);
+        GlobalIndexMeta restored =
+                serializer
+                        .convertFrom(serializer.getVersion(), serialized)
+                        .indexFile()
+                        .globalIndexMeta();
+
+        assertThat(restored.indexMeta()).containsExactly(1);
+        assertThat(restored.sourceMeta()).isNull();
+    }
+
     @Override
     protected ObjectSerializer<IndexManifestEntry> serializer() {
         return new IndexManifestEntrySerializer();
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
new file mode 100644
index 0000000000..0daa7bc5c4
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.schema;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.paimon.schema.SchemaValidation.validateTableSchema;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for primary-key vector index option validation. */
+class PrimaryKeyVectorIndexValidationTest {
+
+    @Test
+    void testValidPluralPrimaryKeyVectorIndexConfiguration() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), " embedding ");
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRejectsMultiplePrimaryKeyVectorIndexColumnsForFirstRelease() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), 
"embedding,other_embedding");
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("exactly one column")
+                .hasMessageContaining("pk-vector.index.columns");
+    }
+
+    @Test
+    void testRejectsDuplicatePrimaryKeyVectorIndexColumns() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), 
"embedding,embedding");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("pk-vector.index.columns")
+                .hasMessageContaining("duplicate");
+    }
+
+    @Test
+    void testValidPrimaryKeyVectorIndex() {
+        assertThatCode(() -> validateTableSchema(schema(enabledOptions())))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRequiresFieldScopedIndexType() {
+        Map<String, String> options = enabledOptions();
+        options.remove("fields.embedding.pk-vector.index.type");
+        options.put("pk-vector.index.type", "ivf-pq");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("fields.embedding.pk-vector.index.type");
+    }
+
+    @Test
+    void testRequiresPrimaryKeyTable() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.BUCKET_KEY.key(), "id");
+        assertThatThrownBy(
+                        () ->
+                                validateTableSchema(
+                                        new TableSchema(
+                                                0,
+                                                fields(),
+                                                0,
+                                                Collections.emptyList(),
+                                                Collections.emptyList(),
+                                                options,
+                                                "")))
+                .hasMessageContaining("requires a primary-key table");
+    }
+
+    @Test
+    void testRequiresDeletionVectors() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "false");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("requires deletion-vectors.enabled = 
true");
+    }
+
+    @Test
+    void testSupportsPartialUpdateMergeEngine() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testPartialUpdateRejectsDeletionVectorMergeOnRead() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+        options.put(CoreOptions.DELETION_VECTORS_MERGE_ON_READ.key(), "true");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining(
+                        "partial-update requires 
deletion-vectors.merge-on-read = false");
+    }
+
+    @Test
+    void testDeduplicateRejectsDeletionVectorMergeOnRead() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.DELETION_VECTORS_MERGE_ON_READ.key(), "true");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("requires deletion-vectors.merge-on-read 
= false");
+    }
+
+    @Test
+    void testRequiresFixedBucket() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.BUCKET.key(), "-1");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("requires fixed bucket mode");
+    }
+
+    @Test
+    void testRejectsPkClusteringOverride() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_CLUSTERING_OVERRIDE.key(), "true");
+        options.put(CoreOptions.CLUSTERING_COLUMNS.key(), "payload");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("does not support 
pk-clustering-override");
+    }
+
+    @Test
+    void testRequiresVectorColumn() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "payload");
+        options.put("fields.payload.pk-vector.index.type", "ivf-pq");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("must reference a VECTOR column");
+    }
+
+    @Test
+    void testIndexNameIsNotRequired() {
+        Map<String, String> options = enabledOptions();
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRequiresFloatVectorElements() {
+        TableSchema schema =
+                new TableSchema(
+                        0,
+                        Arrays.asList(
+                                new DataField(0, "id", 
DataTypes.INT().notNull()),
+                                new DataField(
+                                        1, "embedding", DataTypes.VECTOR(8, 
DataTypes.DOUBLE()))),
+                        1,
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        enabledOptions(),
+                        "");
+
+        assertThatThrownBy(() -> validateTableSchema(schema))
+                .hasMessageContaining("must use FLOAT elements");
+    }
+
+    @Test
+    void testRejectsUnsupportedDistanceMetric() {
+        Map<String, String> options = enabledOptions();
+        options.put("fields.embedding.pk-vector.distance.metric", "manhattan");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("pk-vector.distance.metric")
+                .hasMessageContaining("l2, cosine, inner_product");
+    }
+
+    private static Map<String, String> enabledOptions() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+        return options;
+    }
+
+    private static TableSchema schema(Map<String, String> options) {
+        return new TableSchema(
+                0,
+                fields(),
+                0,
+                Collections.emptyList(),
+                Collections.singletonList("id"),
+                options,
+                "");
+    }
+
+    private static java.util.List<DataField> fields() {
+        return Arrays.asList(
+                new DataField(0, "id", DataTypes.INT().notNull()),
+                new DataField(1, "embedding", DataTypes.VECTOR(8, 
DataTypes.FLOAT())),
+                new DataField(2, "payload", DataTypes.STRING()));
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
index 59a4f90f61..b982ff1df1 100644
--- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
@@ -170,6 +170,35 @@ public class SchemaManagerTest {
         assertThat(latest.get().options()).containsEntry("new_k", "new_v");
     }
 
+    @Test
+    public void testRejectRenamePrimaryKeyVectorIndexColumn() throws Exception 
{
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+        options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+        Schema schema =
+                new Schema(
+                        Arrays.asList(
+                                new DataField(0, "id", 
DataTypes.INT().notNull()),
+                                new DataField(
+                                        1, "embedding", DataTypes.VECTOR(8, 
DataTypes.FLOAT()))),
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        options,
+                        "");
+        SchemaManager manager = new SchemaManager(LocalFileIO.create(), path);
+        manager.createTable(schema);
+
+        assertThatThrownBy(
+                        () ->
+                                manager.commitChanges(
+                                        SchemaChange.renameColumn(
+                                                new String[] {"embedding"}, 
"renamed_embedding")))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Cannot rename primary-key vector index column: 
[embedding]");
+    }
+
     @Test
     public void testResetSequenceGroupForAggregateFunction() throws Exception {
         Map<String, String> options = new HashMap<>();


Reply via email to