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 50b0f70452 [core] Add bucket-level primary-key vector index 
maintenance (#8562)
50b0f70452 is described below

commit 50b0f7045265cb1968a89b0d3b8400b0f6706b61
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 11 23:27:45 2026 +0800

    [core] Add bucket-level primary-key vector index maintenance (#8562)
    
    Builds the bucket-local maintenance layer for primary-key vector indexes
    on top of #8549. The maintainer restores active ANN state, reads
    complete vectors from compact data files, and produces index-file
    increments for compaction transitions without activating the write or
    query paths yet.
---
 .../org/apache/paimon/index/IndexFileHandler.java  |   5 +
 .../pkvector/BucketedVectorIndexMaintainer.java    | 353 +++++++++++++++++++++
 .../index/pkvector/PkVectorBucketIndexState.java   | 103 ++++++
 .../index/pkvector/PkVectorDataFileReader.java     | 132 ++++++++
 .../index/pkvector/PkVectorSourcePolicy.java       |  38 +++
 .../paimon/io/KeyValueFileReaderFactory.java       |   6 +
 .../paimon/manifest/IndexManifestFileHandler.java  |   1 +
 .../BucketedVectorIndexMaintainerTest.java         | 233 ++++++++++++++
 .../pkvector/PkVectorBucketIndexStateTest.java     |  95 ++++++
 .../index/pkvector/PkVectorDataFileReaderTest.java | 160 ++++++++++
 .../manifest/IndexManifestFileHandlerTest.java     |  56 ++++
 11 files changed, 1182 insertions(+)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java 
b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
index e848ed205e..92909a618d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
@@ -25,6 +25,7 @@ import org.apache.paimon.deletionvectors.DeletionVector;
 import org.apache.paimon.deletionvectors.DeletionVectorsIndexFile;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.index.pkvector.PkVectorAnnSegmentFile;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.manifest.IndexManifestEntrySerializer;
 import org.apache.paimon.manifest.IndexManifestFile;
@@ -84,6 +85,10 @@ public class IndexFileHandler {
                 fileIO, pathFactories.get(partition, bucket), 
dvTargetFileSize, dvBitmap64);
     }
 
+    public PkVectorAnnSegmentFile pkVectorAnnSegment(BinaryRow partition, int 
bucket) {
+        return new PkVectorAnnSegmentFile(fileIO, pathFactories.get(partition, 
bucket));
+    }
+
     public Optional<IndexFileMeta> scanHashIndex(
             Snapshot snapshot, BinaryRow partition, int bucket) {
         List<IndexFileMeta> result = scan(snapshot, HASH_INDEX, partition, 
bucket);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/BucketedVectorIndexMaintainer.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/BucketedVectorIndexMaintainer.java
new file mode 100644
index 0000000000..56ba5f9cfc
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/BucketedVectorIndexMaintainer.java
@@ -0,0 +1,353 @@
+/*
+ * 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.data.BinaryRow;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.VectorType;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Maintains bucket-local ANN payloads from complete compact-output data 
files. */
+public class BucketedVectorIndexMaintainer {
+
+    private final int vectorFieldId;
+    private final PkVectorAnnSegmentFile annSegmentFile;
+    private final DataField vectorField;
+    private final Options indexOptions;
+    private final String metric;
+    private final String algorithm;
+    private final PkVectorDataFileReader.Factory vectorReaderFactory;
+    private final List<IndexFileMeta> annSegments;
+    private final Map<String, DataFileMeta> activeSourceFiles;
+
+    BucketedVectorIndexMaintainer(
+            int vectorFieldId,
+            PkVectorAnnSegmentFile annSegmentFile,
+            DataField vectorField,
+            Options indexOptions,
+            String metric,
+            String algorithm,
+            PkVectorDataFileReader.Factory vectorReaderFactory,
+            List<DataFileMeta> restoredDataFiles,
+            List<IndexFileMeta> restoredPayloads) {
+        this.vectorFieldId = vectorFieldId;
+        this.annSegmentFile = annSegmentFile;
+        this.vectorField = vectorField;
+        this.indexOptions = indexOptions;
+        this.metric = metric;
+        this.algorithm = algorithm;
+        this.vectorReaderFactory = vectorReaderFactory;
+        PkVectorBucketIndexState restoredState =
+                new PkVectorBucketIndexState(vectorFieldId, algorithm, 
restoredPayloads);
+        this.annSegments = new ArrayList<>(restoredState.annSegments());
+        this.activeSourceFiles = new LinkedHashMap<>();
+        for (DataFileMeta file : restoredDataFiles) {
+            if (PkVectorSourcePolicy.shouldRead(file)) {
+                activeSourceFiles.put(file.fileName(), file);
+            }
+        }
+        validateCoverage(annSegments, activeSourceFiles);
+    }
+
+    /** Produces ANN changes in the same compact increment as their source 
data-file transition. */
+    public synchronized VectorIndexCommit prepareCommit(
+            DataIncrement appendIncrement, CompactIncrement compactIncrement) {
+        checkArgument(
+                eligibleFiles(appendIncrement.newFiles()).isEmpty(),
+                "Append files must not be primary-key vector index sources.");
+
+        Map<String, DataFileMeta> nextSources = new 
LinkedHashMap<>(activeSourceFiles);
+        Set<String> deletedFiles = new HashSet<>();
+        for (DataFileMeta file : compactIncrement.compactBefore()) {
+            if (!containsFile(compactIncrement.compactAfter(), 
file.fileName())) {
+                deletedFiles.add(file.fileName());
+                nextSources.remove(file.fileName());
+            }
+        }
+        for (DataFileMeta file : compactIncrement.compactAfter()) {
+            if (PkVectorSourcePolicy.shouldRead(file)) {
+                nextSources.put(file.fileName(), file);
+            }
+        }
+
+        List<IndexFileMeta> retained = new ArrayList<>();
+        List<IndexFileMeta> removed = new ArrayList<>();
+        for (IndexFileMeta ann : annSegments) {
+            if (referencesAny(ann, deletedFiles)) {
+                removed.add(ann);
+            } else {
+                retained.add(ann);
+            }
+        }
+
+        Set<String> covered = coveredSources(retained);
+        List<DataFileMeta> uncovered = new ArrayList<>();
+        for (DataFileMeta file : nextSources.values()) {
+            if (!covered.contains(file.fileName())) {
+                uncovered.add(file);
+            }
+        }
+        uncovered.sort(Comparator.comparing(DataFileMeta::fileName));
+
+        List<IndexFileMeta> created = new ArrayList<>();
+        try {
+            if (!uncovered.isEmpty()) {
+                created.add(buildAnnSegment(uncovered));
+            }
+            List<IndexFileMeta> nextAnn = new ArrayList<>(retained);
+            nextAnn.addAll(created);
+            validateCoverage(nextAnn, nextSources);
+
+            activeSourceFiles.clear();
+            activeSourceFiles.putAll(nextSources);
+            annSegments.clear();
+            annSegments.addAll(nextAnn);
+            Optional<VectorIndexIncrement> compactChange =
+                    created.isEmpty() && removed.isEmpty()
+                            ? Optional.empty()
+                            : Optional.of(new VectorIndexIncrement(created, 
removed));
+            return new VectorIndexCommit(Optional.empty(), compactChange);
+        } catch (RuntimeException e) {
+            deleteAnnSegments(created);
+            throw e;
+        }
+    }
+
+    private IndexFileMeta buildAnnSegment(List<DataFileMeta> files) {
+        try {
+            List<PkVectorAnnSegmentFile.Source> sources = new 
ArrayList<>(files.size());
+            for (DataFileMeta file : files) {
+                PkVectorSourceFile sourceFile =
+                        new PkVectorSourceFile(file.fileName(), 
file.rowCount());
+                sources.add(
+                        PkVectorAnnSegmentFile.Source.lazy(
+                                sourceFile, () -> 
vectorReaderFactory.create(file)));
+            }
+            return annSegmentFile.build(sources, vectorField, indexOptions, 
metric, algorithm);
+        } catch (IOException e) {
+            throw new UncheckedIOException("Failed to build an ANN vector 
segment.", e);
+        }
+    }
+
+    private void validateCoverage(
+            List<IndexFileMeta> candidateAnn, Map<String, DataFileMeta> 
sourceFiles) {
+        PkVectorBucketIndexState state =
+                new PkVectorBucketIndexState(vectorFieldId, algorithm, 
candidateAnn);
+        for (Map.Entry<String, IndexFileMeta> entry : 
state.sourceFileToAnnSegment().entrySet()) {
+            DataFileMeta file = sourceFiles.get(entry.getKey());
+            checkArgument(
+                    file != null,
+                    "ANN segment %s references inactive data file %s.",
+                    entry.getValue().fileName(),
+                    entry.getKey());
+            PkVectorSourceMeta sourceMeta = sourceMeta(entry.getValue());
+            for (PkVectorSourceFile source : sourceMeta.sourceFiles()) {
+                if (source.fileName().equals(entry.getKey())) {
+                    checkArgument(
+                            source.rowCount() == file.rowCount(),
+                            "ANN source %s row count does not match its active 
data file.",
+                            source.fileName());
+                }
+            }
+        }
+    }
+
+    public synchronized List<IndexFileMeta> segments() {
+        return Collections.unmodifiableList(new ArrayList<>(annSegments));
+    }
+
+    public synchronized PkVectorBucketIndexState state() {
+        return new PkVectorBucketIndexState(vectorFieldId, algorithm, 
annSegments);
+    }
+
+    private void deleteAnnSegments(List<IndexFileMeta> segments) {
+        for (IndexFileMeta segment : segments) {
+            annSegmentFile.delete(segment);
+        }
+    }
+
+    private static List<DataFileMeta> eligibleFiles(List<DataFileMeta> files) {
+        List<DataFileMeta> eligible = new ArrayList<>();
+        for (DataFileMeta file : files) {
+            if (PkVectorSourcePolicy.shouldRead(file)) {
+                eligible.add(file);
+            }
+        }
+        return eligible;
+    }
+
+    private static boolean containsFile(List<DataFileMeta> files, String 
fileName) {
+        for (DataFileMeta file : files) {
+            if (file.fileName().equals(fileName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean referencesAny(IndexFileMeta ann, Set<String> 
dataFiles) {
+        for (PkVectorSourceFile source : sourceMeta(ann).sourceFiles()) {
+            if (dataFiles.contains(source.fileName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static Set<String> coveredSources(List<IndexFileMeta> segments) {
+        Set<String> sources = new HashSet<>();
+        for (IndexFileMeta ann : segments) {
+            for (PkVectorSourceFile source : sourceMeta(ann).sourceFiles()) {
+                sources.add(source.fileName());
+            }
+        }
+        return sources;
+    }
+
+    private static PkVectorSourceMeta sourceMeta(IndexFileMeta ann) {
+        return PkVectorSourceMeta.fromIndexFile(ann);
+    }
+
+    /** Vector index changes for Paimon's append snapshot followed by its 
compact snapshot. */
+    public static class VectorIndexCommit {
+
+        private final Optional<VectorIndexIncrement> appendIncrement;
+        private final Optional<VectorIndexIncrement> compactIncrement;
+
+        private VectorIndexCommit(
+                Optional<VectorIndexIncrement> appendIncrement,
+                Optional<VectorIndexIncrement> compactIncrement) {
+            this.appendIncrement = appendIncrement;
+            this.compactIncrement = compactIncrement;
+        }
+
+        public Optional<VectorIndexIncrement> appendIncrement() {
+            return appendIncrement;
+        }
+
+        public Optional<VectorIndexIncrement> compactIncrement() {
+            return compactIncrement;
+        }
+    }
+
+    /** Index-file additions and deletions emitted by one bucket state update. 
*/
+    public static class VectorIndexIncrement {
+
+        private final List<IndexFileMeta> newIndexFiles;
+        private final List<IndexFileMeta> deletedIndexFiles;
+
+        private VectorIndexIncrement(
+                List<IndexFileMeta> newIndexFiles, List<IndexFileMeta> 
deletedIndexFiles) {
+            this.newIndexFiles = Collections.unmodifiableList(new 
ArrayList<>(newIndexFiles));
+            this.deletedIndexFiles =
+                    Collections.unmodifiableList(new 
ArrayList<>(deletedIndexFiles));
+        }
+
+        public List<IndexFileMeta> newIndexFiles() {
+            return newIndexFiles;
+        }
+
+        public List<IndexFileMeta> deletedIndexFiles() {
+            return deletedIndexFiles;
+        }
+    }
+
+    /** Factory to restore a bucket maintainer from data files and active ANN 
metadata. */
+    public static class Factory {
+
+        private final IndexFileHandler handler;
+        private final KeyValueFileReaderFactory.Builder readerFactoryBuilder;
+        private final DataField vectorField;
+        private final int vectorDimension;
+        private final String metric;
+        private final String algorithm;
+        private Options indexOptions;
+
+        public Factory(
+                IndexFileHandler handler,
+                KeyValueFileReaderFactory.Builder readerFactoryBuilder,
+                DataField vectorField,
+                String metric,
+                String algorithm) {
+            this.handler = handler;
+            this.readerFactoryBuilder = readerFactoryBuilder;
+            this.vectorField = vectorField;
+            checkArgument(
+                    vectorField.type() instanceof VectorType,
+                    "Primary-key vector field must have VECTOR type.");
+            this.vectorDimension = ((VectorType) 
vectorField.type()).getLength();
+            this.metric = metric;
+            this.algorithm = algorithm;
+        }
+
+        public Factory withIndexOptions(Options indexOptions) {
+            this.indexOptions = new Options(indexOptions.toMap());
+            return this;
+        }
+
+        public IndexFileHandler indexFileHandler() {
+            return handler;
+        }
+
+        public BucketedVectorIndexMaintainer create(
+                BinaryRow partition,
+                int bucket,
+                @Nullable List<DataFileMeta> restoredDataFiles,
+                @Nullable List<IndexFileMeta> restoredPayloads) {
+            checkArgument(indexOptions != null, "ANN index options are not 
configured.");
+            List<DataFileMeta> dataFiles =
+                    restoredDataFiles == null ? Collections.emptyList() : 
restoredDataFiles;
+            List<IndexFileMeta> payloads =
+                    restoredPayloads == null ? Collections.emptyList() : 
restoredPayloads;
+            return new BucketedVectorIndexMaintainer(
+                    vectorField.id(),
+                    handler.pkVectorAnnSegment(partition, bucket),
+                    vectorField,
+                    indexOptions,
+                    metric,
+                    algorithm,
+                    new PkVectorDataFileReader.Factory(
+                            readerFactoryBuilder, partition, bucket, 
vectorField, vectorDimension),
+                    dataFiles,
+                    payloads);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorBucketIndexState.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorBucketIndexState.java
new file mode 100644
index 0000000000..12eb770b0e
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorBucketIndexState.java
@@ -0,0 +1,103 @@
+/*
+ * 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.IndexFileMeta;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Immutable primary-key vector-index state derived from one bucket's active 
payload metadata. */
+public final class PkVectorBucketIndexState {
+
+    private final int vectorFieldId;
+    private final String indexType;
+    private final List<IndexFileMeta> annSegments;
+    private final Map<String, IndexFileMeta> sourceFileToAnnSegment;
+
+    public static PkVectorBucketIndexState fromActivePayloads(
+            int vectorFieldId, String indexType, List<IndexFileMeta> 
activePayloads) {
+        return new PkVectorBucketIndexState(vectorFieldId, indexType, 
activePayloads);
+    }
+
+    public PkVectorBucketIndexState(
+            int vectorFieldId, String indexType, List<IndexFileMeta> 
activePayloads) {
+        this.vectorFieldId = vectorFieldId;
+        this.indexType = indexType;
+
+        Map<String, IndexFileMeta> payloadsByName = new LinkedHashMap<>();
+        Map<String, IndexFileMeta> annBySource = new LinkedHashMap<>();
+        for (IndexFileMeta payload : activePayloads) {
+            checkArgument(
+                    payloadsByName.put(payload.fileName(), payload) == null,
+                    "Active vector payload %s appears more than once in the 
index manifest.",
+                    payload.fileName());
+            PkVectorSourceMeta sourceMeta = validatedSourceMeta(payload);
+            for (PkVectorSourceFile sourceFile : sourceMeta.sourceFiles()) {
+                IndexFileMeta previous = 
annBySource.put(sourceFile.fileName(), payload);
+                checkArgument(
+                        previous == null,
+                        "Source data file %s is covered by both ANN segments 
%s and %s.",
+                        sourceFile.fileName(),
+                        previous == null ? "" : previous.fileName(),
+                        payload.fileName());
+            }
+        }
+
+        this.annSegments = Collections.unmodifiableList(new 
java.util.ArrayList<>(activePayloads));
+        this.sourceFileToAnnSegment = Collections.unmodifiableMap(annBySource);
+    }
+
+    public int vectorFieldId() {
+        return vectorFieldId;
+    }
+
+    public String indexType() {
+        return indexType;
+    }
+
+    public List<IndexFileMeta> annSegments() {
+        return annSegments;
+    }
+
+    public Map<String, IndexFileMeta> sourceFileToAnnSegment() {
+        return sourceFileToAnnSegment;
+    }
+
+    private PkVectorSourceMeta validatedSourceMeta(IndexFileMeta payload) {
+        validateIdentity(payload);
+        return PkVectorSourceMeta.fromIndexFile(payload);
+    }
+
+    private void validateIdentity(IndexFileMeta payload) {
+        checkArgument(
+                indexType.equals(payload.indexType()),
+                "Vector payload %s has a different index type.",
+                payload.fileName());
+        checkArgument(
+                payload.globalIndexMeta() != null
+                        && vectorFieldId == 
payload.globalIndexMeta().indexFieldId(),
+                "Vector payload %s has a different vector field.",
+                payload.fileName());
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorDataFileReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorDataFileReader.java
new file mode 100644
index 0000000000..e136fe7ef1
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorDataFileReader.java
@@ -0,0 +1,132 @@
+/*
+ * 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.KeyValue;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.InternalArray;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.reader.RecordReaderIterator;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Reads one projected vector value from every physical row of a data file. */
+public class PkVectorDataFileReader implements PkVectorReader {
+
+    private final DataFileMeta dataFile;
+    private final int dimension;
+    private final RecordReaderIterator<KeyValue> iterator;
+    private long rowsRead;
+
+    public PkVectorDataFileReader(
+            KeyValueFileReaderFactory readerFactory, DataFileMeta dataFile, 
int dimension)
+            throws IOException {
+        checkArgument(dimension > 0, "Vector dimension must be positive.");
+        this.dataFile = dataFile;
+        this.dimension = dimension;
+        this.iterator = new 
RecordReaderIterator<>(readerFactory.createRecordReader(dataFile));
+    }
+
+    @Override
+    public int dimension() {
+        return dimension;
+    }
+
+    @Override
+    public long rowCount() {
+        return dataFile.rowCount();
+    }
+
+    @Override
+    public boolean readNextVector(float[] reuse) {
+        checkArgument(reuse.length == dimension, "Vector buffer dimension does 
not match reader.");
+        checkArgument(
+                rowsRead < rowCount(), "Read past data file %s row count.", 
dataFile.fileName());
+        checkArgument(
+                iterator.hasNext(),
+                "Data file %s ended before its declared row count.",
+                dataFile.fileName());
+        KeyValue keyValue = iterator.next();
+        rowsRead++;
+        if (rowsRead == rowCount()) {
+            checkArgument(
+                    !iterator.hasNext(),
+                    "Data file %s contains more rows than declared.",
+                    dataFile.fileName());
+        }
+        InternalRow value = keyValue.value();
+        if (value.isNullAt(0)) {
+            return false;
+        }
+        InternalArray vector = value.getVector(0);
+        checkArgument(
+                vector.size() == dimension,
+                "Vector in data file %s has dimension %s instead of %s.",
+                dataFile.fileName(),
+                vector.size(),
+                dimension);
+        for (int i = 0; i < dimension; i++) {
+            reuse[i] = vector.getFloat(i);
+        }
+        return true;
+    }
+
+    @Override
+    public void close() throws IOException {
+        try {
+            iterator.close();
+        } catch (IOException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IOException("Failed to close vector reader for " + 
dataFile.fileName(), e);
+        }
+    }
+
+    /** Bucket-scoped factory with vector-only value projection and no 
deletion-vector filtering. */
+    public static class Factory {
+
+        private final KeyValueFileReaderFactory readerFactory;
+        private final int dimension;
+
+        public Factory(
+                KeyValueFileReaderFactory.Builder readerFactoryBuilder,
+                BinaryRow partition,
+                int bucket,
+                DataField vectorField,
+                int dimension) {
+            this.readerFactory =
+                    readerFactoryBuilder
+                            .copyWithoutProjection()
+                            .withReadKeyType(RowType.of())
+                            .withReadValueType(RowType.of(vectorField))
+                            .buildWithoutDeletionVector(partition, bucket);
+            this.dimension = dimension;
+        }
+
+        public PkVectorDataFileReader create(DataFileMeta dataFile) throws 
IOException {
+            return new PkVectorDataFileReader(readerFactory, dataFile, 
dimension);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourcePolicy.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourcePolicy.java
new file mode 100644
index 0000000000..9db854e11a
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourcePolicy.java
@@ -0,0 +1,38 @@
+/*
+ * 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.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+
+/** Selects data files whose complete rows can be used to build vector index 
payloads. */
+public final class PkVectorSourcePolicy {
+
+    private PkVectorSourcePolicy() {}
+
+    public static boolean shouldWrite(FileSource fileSource, int level) {
+        return fileSource == FileSource.COMPACT && level > 0;
+    }
+
+    public static boolean shouldRead(DataFileMeta file) {
+        return file.fileSource()
+                .map(fileSource -> shouldWrite(fileSource, file.level()))
+                .orElse(false);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileReaderFactory.java 
b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileReaderFactory.java
index 17aa99866f..c594e4e082 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileReaderFactory.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileReaderFactory.java
@@ -309,6 +309,12 @@ public class KeyValueFileReaderFactory implements 
FileReaderFactory<KeyValue> {
             return build(partition, bucket, dvFactory, true, 
Collections.emptyList());
         }
 
+        /** Builds a reader which preserves every physical row position. */
+        public KeyValueFileReaderFactory buildWithoutDeletionVector(
+                BinaryRow partition, int bucket) {
+            return build(partition, bucket, ignored -> Optional.empty());
+        }
+
         public KeyValueFileReaderFactory build(
                 BinaryRow partition,
                 int bucket,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
 
b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
index f992780855..486f39fef4 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
@@ -241,6 +241,7 @@ public class IndexManifestFileHandler {
                 for (IndexManifestEntry added : addedIndexFiles) {
                     GlobalIndexMeta addedMeta = 
added.indexFile().globalIndexMeta();
                     if (addedMeta == null
+                            || (retainedMeta.sourceMeta() != null && 
addedMeta.sourceMeta() != null)
                             || retainedMeta.indexFieldId() != 
addedMeta.indexFieldId()
                             || (Arrays.equals(
                                             retainedMeta.extraFieldIds(), 
addedMeta.extraFieldIds())
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/BucketedVectorIndexMaintainerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/BucketedVectorIndexMaintainerTest.java
new file mode 100644
index 0000000000..31b4437151
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/BucketedVectorIndexMaintainerTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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.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.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+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.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests ANN maintenance over compact data-file sources. */
+class BucketedVectorIndexMaintainerTest {
+
+    @TempDir java.nio.file.Path tempPath;
+
+    @Test
+    void testPartialCompactionRebuildsMultiSourceAnnFromDataFiles() throws 
Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        PkVectorAnnSegmentFile annFile = new PkVectorAnnSegmentFile(fileIO, 
pathFactory());
+        DataField vectorField =
+                new DataField(7, "embedding", DataTypes.VECTOR(2, 
DataTypes.FLOAT()));
+        Options options = indexOptions();
+        DataFileMeta data1 = dataFile("data-1");
+        DataFileMeta data2 = dataFile("data-2");
+        DataFileMeta data3 = dataFile("data-3");
+        IndexFileMeta initialAnn =
+                annFile.build(
+                        Arrays.asList(
+                                new PkVectorAnnSegmentFile.Source(
+                                        data1, new ArrayReader(new float[][] 
{{1, 0}})),
+                                new PkVectorAnnSegmentFile.Source(
+                                        data2, new ArrayReader(new float[][] 
{{2, 0}}))),
+                        vectorField,
+                        options,
+                        "l2",
+                        "test-vector-ann");
+        PkVectorDataFileReader.Factory readerFactory = 
mock(PkVectorDataFileReader.Factory.class);
+        PkVectorDataFileReader reader2 = reader(new float[][] {{2, 0}});
+        PkVectorDataFileReader reader3 = reader(new float[][] {{3, 0}});
+        when(readerFactory.create(data2)).thenReturn(reader2);
+        when(readerFactory.create(data3)).thenReturn(reader3);
+        BucketedVectorIndexMaintainer maintainer =
+                new BucketedVectorIndexMaintainer(
+                        7,
+                        annFile,
+                        vectorField,
+                        options,
+                        "l2",
+                        "test-vector-ann",
+                        readerFactory,
+                        Arrays.asList(data1, data2),
+                        Collections.singletonList(initialAnn));
+
+        BucketedVectorIndexMaintainer.VectorIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(),
+                        new CompactIncrement(
+                                Collections.singletonList(data1),
+                                Collections.singletonList(data3),
+                                Collections.emptyList()));
+
+        BucketedVectorIndexMaintainer.VectorIndexIncrement increment =
+                commit.compactIncrement().get();
+        assertThat(increment.deletedIndexFiles()).containsExactly(initialAnn);
+        assertThat(increment.newIndexFiles()).hasSize(1);
+        IndexFileMeta repaired = increment.newIndexFiles().get(0);
+        assertThat(repaired.indexType()).isEqualTo("test-vector-ann");
+        assertThat(PkVectorSourceMeta.fromIndexFile(repaired).sourceFiles())
+                .extracting(PkVectorSourceFile::fileName)
+                .containsExactly("data-2", "data-3");
+    }
+
+    @Test
+    void testBuildsAnnForSingleCompactSource() throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        PkVectorAnnSegmentFile annFile = new PkVectorAnnSegmentFile(fileIO, 
pathFactory());
+        DataField vectorField =
+                new DataField(7, "embedding", DataTypes.VECTOR(2, 
DataTypes.FLOAT()));
+        DataFileMeta data = dataFile("data");
+        PkVectorDataFileReader.Factory readerFactory = 
mock(PkVectorDataFileReader.Factory.class);
+        PkVectorDataFileReader dataReader = reader(new float[][] {{1, 0}});
+        when(readerFactory.create(data)).thenReturn(dataReader);
+        BucketedVectorIndexMaintainer maintainer =
+                new BucketedVectorIndexMaintainer(
+                        7,
+                        annFile,
+                        vectorField,
+                        indexOptions(),
+                        "l2",
+                        "test-vector-ann",
+                        readerFactory,
+                        Collections.emptyList(),
+                        Collections.emptyList());
+
+        BucketedVectorIndexMaintainer.VectorIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(),
+                        new CompactIncrement(
+                                Collections.emptyList(),
+                                Collections.singletonList(data),
+                                Collections.emptyList()));
+
+        assertThat(commit.compactIncrement()).isPresent();
+        assertThat(commit.compactIncrement().get().newIndexFiles()).hasSize(1);
+    }
+
+    private static Options indexOptions() {
+        Options options = new Options();
+        options.setString("test.vector.dimension", "2");
+        options.setString("test.vector.metric", "l2");
+        return options;
+    }
+
+    private static PkVectorDataFileReader reader(float[][] vectors) throws 
IOException {
+        PkVectorDataFileReader reader = mock(PkVectorDataFileReader.class);
+        when(reader.dimension()).thenReturn(2);
+        when(reader.rowCount()).thenReturn((long) vectors.length);
+        final int[] position = {0};
+        
when(reader.readNextVector(org.mockito.ArgumentMatchers.any(float[].class)))
+                .thenAnswer(
+                        invocation -> {
+                            float[] vector = vectors[position[0]++];
+                            System.arraycopy(
+                                    vector, 0, invocation.getArgument(0), 0, 
vector.length);
+                            return true;
+                        });
+        return reader;
+    }
+
+    private static DataFileMeta dataFile(String fileName) {
+        return DataFileMeta.forAppend(
+                        fileName,
+                        100,
+                        1,
+                        SimpleStats.EMPTY_STATS,
+                        0,
+                        0,
+                        1,
+                        Collections.emptyList(),
+                        null,
+                        FileSource.COMPACT,
+                        null,
+                        null,
+                        null,
+                        null)
+                .upgrade(1);
+    }
+
+    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++];
+            System.arraycopy(vector, 0, reuse, 0, vector.length);
+            return true;
+        }
+
+        @Override
+        public void close() throws IOException {}
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorBucketIndexStateTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorBucketIndexStateTest.java
new file mode 100644
index 0000000000..6fd35521fe
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorBucketIndexStateTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link PkVectorBucketIndexState}. */
+class PkVectorBucketIndexStateTest {
+
+    @Test
+    void testDerivesAnnCoverage() {
+        IndexFileMeta ann = segment("ann", "data-1", "test-vector-ann");
+
+        PkVectorBucketIndexState state =
+                PkVectorBucketIndexState.fromActivePayloads(
+                        7, "test-vector-ann", Collections.singletonList(ann));
+
+        assertThat(state.vectorFieldId()).isEqualTo(7);
+        
assertThat(state.annSegments()).extracting(IndexFileMeta::fileName).containsExactly("ann");
+        assertThat(state.sourceFileToAnnSegment()).containsOnlyKeys("data-1");
+    }
+
+    @Test
+    void testRejectsDuplicateAnnSource() {
+        assertThatThrownBy(
+                        () ->
+                                PkVectorBucketIndexState.fromActivePayloads(
+                                        7,
+                                        "test-vector-ann",
+                                        java.util.Arrays.asList(
+                                                segment("ann-1", "data", 
"test-vector-ann"),
+                                                segment("ann-2", "data", 
"test-vector-ann"))))
+                .hasMessageContaining("both ANN segments");
+    }
+
+    @Test
+    void testRejectsDifferentIndexType() {
+        assertThatThrownBy(
+                        () ->
+                                PkVectorBucketIndexState.fromActivePayloads(
+                                        7,
+                                        "test-vector-ann",
+                                        Collections.singletonList(
+                                                segment("ann", "data", 
"other-vector-ann"))))
+                .hasMessageContaining("different index type");
+    }
+
+    @Test
+    void testEmptyPayloadsProduceEmptyState() {
+        PkVectorBucketIndexState state =
+                PkVectorBucketIndexState.fromActivePayloads(
+                        7, "test-vector-ann", Collections.emptyList());
+
+        assertThat(state.vectorFieldId()).isEqualTo(7);
+        assertThat(state.annSegments()).isEmpty();
+    }
+
+    private static IndexFileMeta segment(
+            String segmentFileName, String sourceFileName, String indexType) {
+        PkVectorSourceFile sourceFile = new PkVectorSourceFile(sourceFileName, 
10);
+        byte[] sourceMeta =
+                new 
PkVectorSourceMeta(Collections.singletonList(sourceFile)).serialize();
+        return new IndexFileMeta(
+                indexType,
+                segmentFileName,
+                100,
+                10,
+                new GlobalIndexMeta(0, 10, 7, null, new byte[] {1}, 
sourceMeta),
+                null);
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorDataFileReaderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorDataFileReaderTest.java
new file mode 100644
index 0000000000..420e0d5ed5
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorDataFileReaderTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.KeyValue;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.BinaryVector;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.format.FlushingFileFormat;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.io.KeyValueFileWriterFactory;
+import org.apache.paimon.io.RollingFileWriter;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.schema.KeyValueFieldsExtractor;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.options.MemorySize.VALUE_128_MB;
+import static 
org.apache.paimon.utils.FileStorePathFactoryTest.createNonPartFactory;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests reading physical vector positions from compact data files. */
+class PkVectorDataFileReaderTest {
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @Test
+    void testReadsProjectedVectorsAndPreservesNullPositions() throws Exception 
{
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        DataField keyField = new DataField(0, "id", DataTypes.INT());
+        DataField vectorField =
+                new DataField(1, "embedding", DataTypes.VECTOR(2, 
DataTypes.FLOAT()));
+        DataField payloadField = new DataField(2, "payload", 
DataTypes.STRING());
+        RowType keyType = RowType.of(keyField);
+        RowType valueType = RowType.of(vectorField, payloadField);
+        List<DataField> fields = Arrays.asList(keyField, vectorField, 
payloadField);
+        TableSchema schema =
+                new TableSchema(
+                        0,
+                        fields,
+                        2,
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        Collections.emptyMap(),
+                        "");
+        SchemaManager schemaManager = new SchemaManager(fileIO, tablePath);
+        assertThat(schemaManager.commit(schema)).isTrue();
+        FileStorePathFactory pathFactory = createNonPartFactory(tablePath);
+        FlushingFileFormat format = new FlushingFileFormat("avro");
+        CoreOptions options = new CoreOptions(new Options());
+
+        KeyValueFileWriterFactory writerFactory =
+                KeyValueFileWriterFactory.builder(
+                                fileIO,
+                                schema.id(),
+                                keyType,
+                                valueType,
+                                format,
+                                ignored -> pathFactory,
+                                VALUE_128_MB.getBytes())
+                        .build(BinaryRow.EMPTY_ROW, 0, options);
+        RollingFileWriter<KeyValue, DataFileMeta> writer =
+                writerFactory.createRollingMergeTreeFileWriter(1, 
FileSource.COMPACT);
+        try {
+            writer.write(keyValue(1, BinaryVector.fromPrimitiveArray(new 
float[] {1, 2}), "first"));
+            writer.write(keyValue(2, null, "second"));
+            writer.write(keyValue(3, BinaryVector.fromPrimitiveArray(new 
float[] {3, 4}), "third"));
+        } finally {
+            writer.close();
+        }
+        DataFileMeta dataFile = writer.result().get(0);
+
+        KeyValueFileReaderFactory.Builder readerFactoryBuilder =
+                KeyValueFileReaderFactory.builder(
+                        fileIO,
+                        schemaManager,
+                        schema,
+                        keyType,
+                        valueType,
+                        ignored -> format,
+                        pathFactory,
+                        fieldsExtractor(keyType, valueType),
+                        options);
+        PkVectorDataFileReader reader =
+                new PkVectorDataFileReader.Factory(
+                                readerFactoryBuilder, BinaryRow.EMPTY_ROW, 0, 
vectorField, 2)
+                        .create(dataFile);
+        float[] reuse = new float[2];
+        try {
+            assertThat(reader.rowCount()).isEqualTo(3);
+            assertThat(reader.readNextVector(reuse)).isTrue();
+            assertThat(reuse).containsExactly(1, 2);
+            assertThat(reader.readNextVector(reuse)).isFalse();
+            assertThat(reader.readNextVector(reuse)).isTrue();
+            assertThat(reuse).containsExactly(3, 4);
+            assertThatThrownBy(() -> reader.readNextVector(reuse))
+                    .hasMessageContaining("Read past data file");
+        } finally {
+            reader.close();
+        }
+    }
+
+    private static KeyValue keyValue(int key, BinaryVector vector, String 
payload) {
+        return new KeyValue()
+                .replace(
+                        GenericRow.of(key),
+                        RowKind.INSERT,
+                        GenericRow.of(vector, 
BinaryString.fromString(payload)));
+    }
+
+    private static KeyValueFieldsExtractor fieldsExtractor(RowType keyType, 
RowType valueType) {
+        return new KeyValueFieldsExtractor() {
+            @Override
+            public List<DataField> keyFields(TableSchema schema) {
+                return keyType.getFields();
+            }
+
+            @Override
+            public List<DataField> valueFields(TableSchema schema) {
+                return valueType.getFields();
+            }
+        };
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
index 2eae202b30..cfcc896525 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
@@ -202,6 +202,48 @@ public class IndexManifestFileHandlerTest {
         assertThat(entries).containsExactlyInAnyOrder(previous, added);
     }
 
+    @Test
+    public void testPrimaryKeyVectorSegmentsAllowFileLocalOverlappingRanges() 
throws Exception {
+        TestAppendFileStore fileStore =
+                TestAppendFileStore.createAppendStore(tempDir, new 
HashMap<>());
+        IndexManifestFile indexManifestFile = 
createIndexManifestFile(fileStore);
+        IndexManifestFileHandler handler =
+                new IndexManifestFileHandler(indexManifestFile, 
BucketMode.HASH_FIXED);
+        IndexManifestEntry first = pkVectorEntry("test-vector-ann", "ann-1");
+        String firstManifest = handler.write(null, Arrays.asList(first));
+        IndexManifestEntry second = pkVectorEntry("test-vector-ann", "ann-2");
+
+        String secondManifest = handler.write(firstManifest, 
Arrays.asList(second));
+
+        
assertThat(indexManifestFile.read(secondManifest)).containsExactlyInAnyOrder(first,
 second);
+
+        IndexManifestEntry ann = pkVectorEntry("test-vector-ann", "ann-3");
+        String annManifest =
+                handler.write(
+                        secondManifest,
+                        Arrays.asList(first.toDeleteEntry(), 
second.toDeleteEntry(), ann));
+        assertThat(indexManifestFile.read(annManifest)).containsExactly(ann);
+    }
+
+    @Test
+    public void testSourceMetaDoesNotDisableRangeValidationForOtherFields() 
throws Exception {
+        TestAppendFileStore fileStore =
+                TestAppendFileStore.createAppendStore(tempDir, new 
HashMap<>());
+        IndexManifestFile indexManifestFile = 
createIndexManifestFile(fileStore);
+        IndexManifestFileHandler handler =
+                new IndexManifestFileHandler(indexManifestFile, 
BucketMode.HASH_FIXED);
+        IndexManifestEntry sourceBacked = pkVectorEntry("btree", 
"source-backed");
+        IndexManifestEntry previousRange = globalIndexEntry("previous-range", 
0, 99, 2);
+        String manifest = handler.write(null, Arrays.asList(sourceBacked, 
previousRange));
+        IndexManifestEntry overlappingRange = 
globalIndexEntry("overlapping-range", 50, 149, 2);
+
+        assertThatThrownBy(() -> handler.write(manifest, 
Arrays.asList(overlappingRange)))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("previous-range")
+                .hasMessageContaining("overlapping-range")
+                .hasMessageContaining("overlapping row range");
+    }
+
     private IndexManifestFile createIndexManifestFile(TestAppendFileStore 
fileStore) {
         return new IndexManifestFile.Factory(
                         fileStore.fileIO(),
@@ -226,4 +268,18 @@ public class IndexManifestFileHandlerTest {
                         new GlobalIndexMeta(rowRangeStart, rowRangeEnd, 
indexFieldId, null, null),
                         null));
     }
+
+    private IndexManifestEntry pkVectorEntry(String indexType, String 
fileName) {
+        return new IndexManifestEntry(
+                FileKind.ADD,
+                BinaryRow.EMPTY_ROW,
+                0,
+                new IndexFileMeta(
+                        indexType,
+                        fileName,
+                        1L,
+                        1L,
+                        new GlobalIndexMeta(0, 1, 1, null, null, new byte[] 
{1}),
+                        null));
+    }
 }

Reply via email to