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 5f6876c025 [flink] Support distributed vector search (#8834)
5f6876c025 is described below

commit 5f6876c02523e2ccdd56767cf4f14f5950047d29
Author: liangjie <[email protected]>
AuthorDate: Tue Jul 28 08:24:46 2026 +0800

    [flink] Support distributed vector search (#8834)
---
 docs/docs/flink/procedures.md                      |  12 +-
 docs/docs/multimodal-table/global-index/vector.mdx |   4 +-
 docs/docs/primary-key-table/global-index.mdx       |   5 +-
 .../table/source/VectorSearchBuilderImpl.java      |   2 +-
 .../flink/procedure/VectorSearchProcedure.java     | 429 +++++++++++--
 .../vectorsearch/FlinkDataEvolutionVectorRead.java | 590 ++++++++++++++++++
 .../vectorsearch/FlinkPrimaryKeyVectorRead.java    | 193 ++++++
 .../vectorsearch/FlinkVectorSearchBuilderImpl.java |  51 ++
 .../procedure/VectorSearchProcedureITCase.java     | 674 ++++++++++++++++++++-
 .../FlinkDataEvolutionVectorReadTest.java          | 555 +++++++++++++++++
 .../FlinkPrimaryKeyVectorReadTest.java             | 163 +++++
 11 files changed, 2602 insertions(+), 76 deletions(-)

diff --git a/docs/docs/flink/procedures.md b/docs/docs/flink/procedures.md
index eb91819554..927bfe231b 100644
--- a/docs/docs/flink/procedures.md
+++ b/docs/docs/flink/procedures.md
@@ -1177,8 +1177,10 @@ All available procedures are listed below.
             vector_column => 'columnName',<br/>
             query_vector => 'v1,v2,...',<br/>
             top_k => topK,<br/>
-            projection => 'col1,col2',<br/>
-            options => 'key1=value1;key2=value2')<br/>
+            projection => 'col1,col2,__paimon_search_score',<br/>
+            options => 'key1=value1;key2=value2',<br/>
+            `where` => 'predicate',<br/>
+            partitions => 'pt1=v1,pt2=v2;pt1=v3,pt2=v4')<br/>
       </td>
       <td>
          To perform vector similarity search on a table with a global vector 
index. Returns JSON-serialized rows. Arguments:
@@ -1188,6 +1190,8 @@ All available procedures are listed below.
             <li>top_k(required): the number of nearest neighbors to 
return.</li>
             <li>projection(optional): comma-separated column names to include 
in the result. If omitted, all columns are returned.</li>
             <li>options(optional): additional dynamic options of the 
table.</li>
+            <li>where(optional): a SQL predicate applied before Top-K.</li>
+            <li>partitions(optional): semicolon-separated specs for a 
partitioned table.</li>
       </td>
       <td>
          CALL sys.vector_search(<br/>
@@ -1200,7 +1204,9 @@ All available procedures are listed below.
             vector_column => 'embedding',<br/>
             query_vector => '1.0,2.0,3.0',<br/>
             top_k => 5,<br/>
-            projection => 'id,name')
+            projection => 'id,name,__paimon_search_score',<br/>
+            options => 
'vector-search.distribute.enabled=true;ivf.nprobe=32',<br/>
+            `where` => 'status = ''active''')
       </td>
    </tr>
    </tbody>
diff --git a/docs/docs/multimodal-table/global-index/vector.mdx 
b/docs/docs/multimodal-table/global-index/vector.mdx
index 6399d3c258..d29d076769 100644
--- a/docs/docs/multimodal-table/global-index/vector.mdx
+++ b/docs/docs/multimodal-table/global-index/vector.mdx
@@ -335,7 +335,9 @@ CALL sys.vector_search(
     vector_column => 'embedding',
     query_vector => '1.0,2.0,3.0',
     top_k => 5,
-    projection => 'id,name'
+    projection => 'id,name,__paimon_search_score',
+    options => 'ivf.nprobe=32',
+    `where` => 'id > 100'
 );
 ```
 
diff --git a/docs/docs/primary-key-table/global-index.mdx 
b/docs/docs/primary-key-table/global-index.mdx
index e76845e6c3..04008c2db5 100644
--- a/docs/docs/primary-key-table/global-index.mdx
+++ b/docs/docs/primary-key-table/global-index.mdx
@@ -349,8 +349,9 @@ CALL sys.vector_search(
     vector_column => 'embedding',
     query_vector => '0.1,0.2,0.3',
     top_k => 10,
-    projection => 'id,status',
-    options => 'ivf.nprobe=32'
+    projection => 'id,status,__paimon_search_score',
+    options => 'ivf.nprobe=32',
+    `where` => 'status = ''active'''
 );
 ```
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
index 4b59d43812..fb0c972365 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
@@ -155,7 +155,7 @@ public class VectorSearchBuilderImpl implements 
VectorSearchBuilder {
                 && 
table.coreOptions().primaryKeyVectorIndexColumns().contains(vectorColumn.name());
     }
 
-    VectorSearchBuilderImpl withSnapshot(Snapshot snapshot) {
+    public VectorSearchBuilderImpl withSnapshot(Snapshot snapshot) {
         this.pinnedSnapshot = snapshot;
         return this;
     }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
index 8a75b8f9d4..d49810c9dd 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
@@ -18,17 +18,34 @@
 
 package org.apache.paimon.flink.procedure;
 
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.flink.predicate.SimpleSqlPredicateConvertor;
+import org.apache.paimon.flink.vectorsearch.FlinkVectorSearchBuilderImpl;
 import org.apache.paimon.format.json.JsonFormatWriter;
 import org.apache.paimon.format.json.JsonOptions;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.table.source.VectorSearchBuilder;
+import org.apache.paimon.table.source.VectorSearchBuilderImpl;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.InternalRowUtils;
+import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.StringUtils;
 
 import org.apache.flink.table.annotation.ArgumentHint;
@@ -36,40 +53,32 @@ import org.apache.flink.table.annotation.DataTypeHint;
 import org.apache.flink.table.annotation.ProcedureHint;
 import org.apache.flink.table.procedure.ProcedureContext;
 
+import javax.annotation.Nullable;
+
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
+import java.util.Optional;
+import java.util.Set;
 
-/**
- * Vector search procedure. This procedure takes one vector and searches for 
topK nearest vectors.
- * Usage:
- *
- * <pre><code>
- *  CALL sys.vector_search(
- *    `table` => 'tableId',
- *    vector_column => 'v',
- *    query_vector => '1.0,2.0,3.0',
- *    top_k => 5
- *  )
- *
- *  -- with projection and options
- *  CALL sys.vector_search(
- *    `table` => 'tableId',
- *    vector_column => 'v',
- *    query_vector => '1.0,2.0,3.0',
- *    top_k => 5,
- *    projection => 'id,name',
- *    options => 'k1=v1;k2=v2'
- *  )
- * </code></pre>
- */
+import static 
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates;
+import static org.apache.paimon.predicate.PredicateVisitor.collectFieldNames;
+import static org.apache.paimon.utils.ParameterUtils.getPartitions;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Procedure for local or distributed vector search with optional filtering 
and scores. */
 public class VectorSearchProcedure extends ProcedureBase {
 
     public static final String IDENTIFIER = "vector_search";
+    public static final String SEARCH_SCORE = "__paimon_search_score";
+
+    private static final int MAX_TOP_K = 10_000;
+    private static final DataField SEARCH_SCORE_FIELD =
+            new DataField(Integer.MAX_VALUE, SEARCH_SCORE, DataTypes.FLOAT());
 
     @ProcedureHint(
             argument = {
@@ -81,7 +90,12 @@ public class VectorSearchProcedure extends ProcedureBase {
                         name = "projection",
                         type = @DataTypeHint("STRING"),
                         isOptional = true),
-                @ArgumentHint(name = "options", type = 
@DataTypeHint("STRING"), isOptional = true)
+                @ArgumentHint(name = "options", type = 
@DataTypeHint("STRING"), isOptional = true),
+                @ArgumentHint(name = "where", type = @DataTypeHint("STRING"), 
isOptional = true),
+                @ArgumentHint(
+                        name = "partitions",
+                        type = @DataTypeHint("STRING"),
+                        isOptional = true)
             })
     public String[] call(
             ProcedureContext procedureContext,
@@ -90,89 +104,370 @@ public class VectorSearchProcedure extends ProcedureBase {
             String queryVectorStr,
             Integer topK,
             String projection,
-            String options)
+            String options,
+            String where,
+            String partitions)
             throws Exception {
-        Table table = table(tableId);
+        validateSearch(vectorColumn, queryVectorStr, topK);
 
+        Table table = table(tableId);
         Map<String, String> optionsMap = optionalConfigMap(options);
+        String queryAuthOption = CoreOptions.QUERY_AUTH_ENABLED.key();
+        checkArgument(
+                !optionsMap.containsKey(queryAuthOption),
+                "Option '%s' is not allowed",
+                queryAuthOption);
         if (!optionsMap.isEmpty()) {
             table = table.copy(optionsMap);
         }
+        checkArgument(
+                table instanceof FileStoreTable, "Vector search requires a 
file store table.");
+
+        FileStoreTable fileStoreTable = (FileStoreTable) table;
+        checkArgument(
+                !fileStoreTable.coreOptions().queryAuthEnabled(),
+                "Vector search does not support tables with query auth 
enabled.");
+        RowType tableType = fileStoreTable.rowType();
+        checkArgument(
+                tableType.containsField(vectorColumn),
+                "Vector column '%s' does not exist in table '%s'.",
+                vectorColumn,
+                tableId);
+        checkArgument(
+                tableType.notContainsField(SEARCH_SCORE),
+                "Table column '%s' conflicts with vector-search metadata.",
+                SEARCH_SCORE);
 
         float[] queryVector = parseVector(queryVectorStr);
+        Predicate filter = parseFilter(where, tableType);
+        PartitionPredicate partitionFilter = parsePartitions(partitions, 
fileStoreTable);
+        Projection parsedProjection = Projection.parse(projection, tableType, 
filter);
+        FilterParts filterParts = FilterParts.from(filter, fileStoreTable);
+        validatePrimaryKeyFilter(fileStoreTable, vectorColumn, filterParts);
 
-        GlobalIndexResult result =
-                table.newVectorSearchBuilder()
+        Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest(fileStoreTable);
+        if (snapshot == null) {
+            return new String[0];
+        }
+
+        VectorSearchBuilder builder =
+                newVectorSearchBuilder(procedureContext, fileStoreTable)
+                        .withSnapshot(snapshot)
                         .withVector(queryVector)
                         .withVectorColumn(vectorColumn)
                         .withLimit(topK)
-                        .withOptions(optionsMap)
-                        .executeLocal();
+                        .withOptions(optionsMap);
+        if (filter != null) {
+            builder.withFilter(filter);
+        }
+        if (partitionFilter != null) {
+            builder.withPartitionFilter(partitionFilter);
+        }
 
-        RowType tableRowType = table.rowType();
-        int[] projectionIndices = parseProjection(projection, tableRowType);
+        VectorScan.Plan vectorPlan = builder.newVectorScan().scan();
+        GlobalIndexResult result = builder.newVectorRead().read(vectorPlan);
 
-        ReadBuilder readBuilder = table.newReadBuilder();
-        if (projectionIndices != null) {
-            readBuilder.withProjection(projectionIndices);
+        ReadBuilder readBuilder = fileStoreTable.newReadBuilder();
+        if (filter != null) {
+            readBuilder.withFilter(filter);
+        }
+        PartitionPredicate effectivePartitionFilter =
+                filterParts.mergePartitionFilter(partitionFilter);
+        if (effectivePartitionFilter != null) {
+            readBuilder.withPartitionFilter(effectivePartitionFilter);
+        }
+        if (parsedProjection.readProjection != null) {
+            readBuilder.withProjection(parsedProjection.readProjection);
         }
+        TableScan.Plan readPlan = 
readBuilder.newScan().withGlobalIndexResult(result).plan();
+        return readRows(readBuilder, readPlan, parsedProjection);
+    }
 
-        TableScan.Plan plan = 
readBuilder.newScan().withGlobalIndexResult(result).plan();
+    private static VectorSearchBuilderImpl newVectorSearchBuilder(
+            ProcedureContext context, FileStoreTable table) {
+        if (!table.coreOptions().vectorSearchDistributeEnabled()) {
+            return (VectorSearchBuilderImpl) table.newVectorSearchBuilder();
+        }
+        return new FlinkVectorSearchBuilderImpl(table, 
context.getExecutionEnvironment());
+    }
+
+    private static void validateSearch(
+            String vectorColumn, String queryVector, @Nullable Integer topK) {
+        checkArgument(
+                !StringUtils.isNullOrWhitespaceOnly(vectorColumn),
+                "vector_column must not be blank");
+        checkArgument(
+                !StringUtils.isNullOrWhitespaceOnly(queryVector), 
"query_vector must not be blank");
+        checkArgument(topK != null && topK > 0, "top_k must be positive");
+        checkArgument(topK <= MAX_TOP_K, "top_k must not exceed %s", 
MAX_TOP_K);
+    }
 
-        RowType readType =
-                projectionIndices != null ? 
tableRowType.project(projectionIndices) : tableRowType;
+    private static float[] parseVector(String vectorStr) {
+        String[] parts = vectorStr.split(",", -1);
+        float[] vector = new float[parts.length];
+        for (int i = 0; i < parts.length; i++) {
+            String part = parts[i].trim();
+            checkArgument(!part.isEmpty(), "query_vector contains an empty 
value");
+            try {
+                vector[i] = Float.parseFloat(part);
+            } catch (NumberFormatException e) {
+                throw new IllegalArgumentException(
+                        String.format("Invalid query vector value: '%s'", 
part), e);
+            }
+            checkArgument(
+                    !Float.isNaN(vector[i]) && !Float.isInfinite(vector[i]),
+                    "query_vector values must be finite");
+        }
+        return vector;
+    }
+
+    @Nullable
+    private static Predicate parseFilter(@Nullable String where, RowType 
tableType)
+            throws Exception {
+        if (StringUtils.isNullOrWhitespaceOnly(where)) {
+            return null;
+        }
+        return new 
SimpleSqlPredicateConvertor(tableType).convertSqlToPredicate(where);
+    }
+
+    @Nullable
+    private static PartitionPredicate parsePartitions(
+            @Nullable String partitions, FileStoreTable table) {
+        if (StringUtils.isNullOrWhitespaceOnly(partitions)) {
+            return null;
+        }
+
+        List<String> partitionKeys = table.partitionKeys();
+        checkArgument(
+                !partitionKeys.isEmpty(),
+                "partitions can only be used with a partitioned table '%s'.",
+                table.name());
+        List<Map<String, String>> specs = getPartitions(partitions.split(";"));
+        Set<String> expectedKeys = new HashSet<>(partitionKeys);
+        for (Map<String, String> spec : specs) {
+            checkArgument(
+                    spec.keySet().equals(expectedKeys),
+                    "Partition spec for table '%s' must exactly match 
partition keys %s, but was %s.",
+                    table.name(),
+                    partitionKeys,
+                    spec);
+        }
+        return PartitionPredicate.fromMaps(
+                table.schema().logicalPartitionType(),
+                specs,
+                table.coreOptions().partitionDefaultName());
+    }
+
+    private static void validatePrimaryKeyFilter(
+            FileStoreTable table, String vectorColumn, FilterParts 
filterParts) {
+        if 
(!table.coreOptions().primaryKeyVectorIndexColumns().contains(vectorColumn)
+                || !filterParts.hasDataFilter()) {
+            return;
+        }
+        checkArgument(
+                table.coreOptions().deletionVectorsEnabled()
+                        && !table.coreOptions().deletionVectorsMergeOnRead(),
+                "Primary-key vector where filter on non-partition columns 
requires "
+                        + "deletion-vectors.enabled = true and "
+                        + "deletion-vectors.merge-on-read = false.");
+    }
 
+    private static String[] readRows(
+            ReadBuilder readBuilder, TableScan.Plan plan, Projection 
projection)
+            throws IOException {
         ByteArrayOutputStream byteOut = new ByteArrayOutputStream(1024);
         JsonOptions jsonOptions = new JsonOptions(new Options());
+        List<Float> scores = new ArrayList<>();
         try (JsonFormatWriter jsonWriter =
                         new JsonFormatWriter(
                                 new ByteArrayPositionOutputStream(byteOut),
-                                readType,
+                                projection.outputType,
                                 jsonOptions,
                                 "none");
-                RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(plan)) {
-            reader.forEachRemaining(
-                    row -> {
-                        try {
-                            jsonWriter.addElement(row);
-                        } catch (Exception e) {
-                            throw new RuntimeException("Failed to convert row 
to JSON string", e);
-                        }
-                    });
+                RecordReader<InternalRow> reader =
+                        
readBuilder.newRead().executeFilter().createReader(plan)) {
+            RecordReader.RecordIterator<InternalRow> batch;
+            while ((batch = reader.readBatch()) != null) {
+                try {
+                    checkArgument(
+                            batch instanceof ScoreRecordIterator,
+                            "Vector-search reader did not expose search 
scores.");
+                    ScoreRecordIterator<InternalRow> scoredBatch =
+                            (ScoreRecordIterator<InternalRow>) batch;
+                    InternalRow row;
+                    while ((row = scoredBatch.next()) != null) {
+                        float score = scoredBatch.returnedScore();
+                        checkArgument(
+                                !Float.isNaN(score),
+                                "Vector-search reader returned a missing 
score.");
+                        jsonWriter.addElement(projection.output(row, score));
+                        scores.add(score);
+                    }
+                } finally {
+                    batch.releaseBatch();
+                }
+            }
         }
 
         String[] lines =
                 StringUtils.split(byteOut.toString("UTF-8"), 
jsonOptions.getLineDelimiter());
-        List<String> rows = new ArrayList<>(lines.length);
+        List<ScoredJson> rows = new ArrayList<>(lines.length);
+        int scoreIndex = 0;
         for (String line : lines) {
             String trimmed = line.trim();
             if (!trimmed.isEmpty()) {
-                rows.add(trimmed);
+                checkArgument(
+                        scoreIndex < scores.size(),
+                        "Vector-search JSON rows and scores are misaligned.");
+                rows.add(new ScoredJson(trimmed, scores.get(scoreIndex++)));
             }
         }
-        return rows.toArray(new String[0]);
+        checkArgument(
+                scoreIndex == scores.size(), "Vector-search JSON rows and 
scores are misaligned.");
+        Collections.sort(
+                rows,
+                (left, right) -> {
+                    int scoreOrder = Float.compare(right.score, left.score);
+                    return scoreOrder != 0 ? scoreOrder : 
left.json.compareTo(right.json);
+                });
+        return rows.stream().map(row -> row.json).toArray(String[]::new);
     }
 
-    private static float[] parseVector(String vectorStr) {
-        String[] parts = StringUtils.split(vectorStr, ",");
-        float[] vector = new float[parts.length];
-        for (int i = 0; i < parts.length; i++) {
-            vector[i] = Float.parseFloat(parts[i].trim());
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    private static class Projection {
+
+        private final RowType readType;
+        private final RowType outputType;
+        @Nullable private final int[] readProjection;
+        @Nullable private final int[] outputToRead;
+
+        private Projection(
+                RowType readType,
+                RowType outputType,
+                @Nullable int[] readProjection,
+                @Nullable int[] outputToRead) {
+            this.readType = readType;
+            this.outputType = outputType;
+            this.readProjection = readProjection;
+            this.outputToRead = outputToRead;
+        }
+
+        private static Projection parse(
+                @Nullable String projection, RowType tableType, @Nullable 
Predicate filter) {
+            if (StringUtils.isNullOrWhitespaceOnly(projection)) {
+                return new Projection(tableType, tableType, null, null);
+            }
+
+            String[] names = projection.split(",", -1);
+            List<Integer> readIndices = new ArrayList<>();
+            List<DataField> outputFields = new ArrayList<>();
+            int[] outputToRead = new int[names.length];
+            Set<String> seen = new HashSet<>();
+            for (int i = 0; i < names.length; i++) {
+                String name = names[i].trim();
+                checkArgument(!name.isEmpty(), "Projection column must not be 
blank");
+                checkArgument(seen.add(name), "Duplicate projection column: 
%s", name);
+                if (SEARCH_SCORE.equals(name)) {
+                    outputFields.add(SEARCH_SCORE_FIELD);
+                    outputToRead[i] = -1;
+                    continue;
+                }
+
+                int tableIndex = tableType.getFieldIndex(name);
+                checkArgument(tableIndex >= 0, "Unknown projection column: 
%s", name);
+                outputFields.add(tableType.getFields().get(tableIndex));
+                outputToRead[i] = readIndices.size();
+                readIndices.add(tableIndex);
+            }
+
+            if (filter != null) {
+                Set<String> filterFields = collectFieldNames(filter);
+                Set<Integer> included = new HashSet<>(readIndices);
+                List<String> tableFields = tableType.getFieldNames();
+                for (int i = 0; i < tableFields.size(); i++) {
+                    if (filterFields.contains(tableFields.get(i)) && 
included.add(i)) {
+                        readIndices.add(i);
+                    }
+                }
+            }
+
+            int[] readProjection = 
readIndices.stream().mapToInt(Integer::intValue).toArray();
+            return new Projection(
+                    tableType.project(readProjection),
+                    new RowType(outputFields),
+                    readProjection,
+                    outputToRead);
+        }
+
+        private InternalRow output(InternalRow row, float score) {
+            if (outputToRead == null) {
+                return row;
+            }
+            GenericRow output = new GenericRow(outputToRead.length);
+            for (int i = 0; i < outputToRead.length; i++) {
+                int readIndex = outputToRead[i];
+                output.setField(
+                        i,
+                        readIndex < 0
+                                ? score
+                                : InternalRowUtils.get(
+                                        row, readIndex, 
readType.getTypeAt(readIndex)));
+            }
+            return output;
         }
-        return vector;
     }
 
-    private static int[] parseProjection(String projection, RowType rowType) {
-        if (StringUtils.isNullOrWhitespaceOnly(projection)) {
-            return null;
+    private static class FilterParts {
+
+        @Nullable private final PartitionPredicate partitionFilter;
+        private final boolean hasDataFilter;
+
+        private FilterParts(@Nullable PartitionPredicate partitionFilter, 
boolean hasDataFilter) {
+            this.partitionFilter = partitionFilter;
+            this.hasDataFilter = hasDataFilter;
+        }
+
+        private static FilterParts from(@Nullable Predicate filter, 
FileStoreTable table) {
+            if (filter == null) {
+                return new FilterParts(null, false);
+            }
+            Pair<Optional<PartitionPredicate>, List<Predicate>> parts =
+                    splitPartitionPredicatesAndDataPredicates(
+                            filter, table.rowType(), table.partitionKeys());
+            return new FilterParts(parts.getLeft().orElse(null), 
!parts.getRight().isEmpty());
+        }
+
+        private boolean hasDataFilter() {
+            return hasDataFilter;
+        }
+
+        @Nullable
+        private PartitionPredicate mergePartitionFilter(
+                @Nullable PartitionPredicate explicitPartitionFilter) {
+            List<PartitionPredicate> filters = new ArrayList<>(2);
+            if (partitionFilter != null) {
+                filters.add(partitionFilter);
+            }
+            if (explicitPartitionFilter != null) {
+                filters.add(explicitPartitionFilter);
+            }
+            return PartitionPredicate.and(filters);
         }
-        String[] projectionNames = StringUtils.split(projection, ",");
-        return 
rowType.getFieldIndices(Arrays.stream(projectionNames).collect(Collectors.toList()));
     }
 
-    @Override
-    public String identifier() {
-        return IDENTIFIER;
+    private static class ScoredJson {
+
+        private final String json;
+        private final float score;
+
+        private ScoredJson(String json, float score) {
+            this.json = json;
+            this.score = score;
+        }
     }
 
     /** A {@link PositionOutputStream} wrapping a {@link 
ByteArrayOutputStream}. */
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkDataEvolutionVectorRead.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkDataEvolutionVectorRead.java
new file mode 100644
index 0000000000..e1ea1e0d6a
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkDataEvolutionVectorRead.java
@@ -0,0 +1,590 @@
+/*
+ * 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.flink.vectorsearch;
+
+import org.apache.paimon.flink.utils.StreamExecutionEnvironmentUtils;
+import org.apache.paimon.globalindex.GlobalIndexReadThreadPool;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.GlobalIndexResultSerializer;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataEvolutionVectorRead;
+import org.apache.paimon.table.source.IndexVectorSearchSplit;
+import org.apache.paimon.table.source.RawVectorSearchSplit;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.InstantiationUtil;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
+import org.apache.flink.api.common.typeinfo.PrimitiveArrayTypeInfo;
+import org.apache.flink.api.java.tuple.Tuple2;
+import org.apache.flink.api.java.typeutils.TupleTypeInfo;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.util.CloseableIterator;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+
+import static org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Flink-aware {@link DataEvolutionVectorRead}. */
+public class FlinkDataEvolutionVectorRead extends DataEvolutionVectorRead {
+
+    private static final long serialVersionUID = 1L;
+    private static final byte INDEX_RESULT = 0;
+    private static final byte RAW_RESULT = 1;
+    private static final TupleTypeInfo<Tuple2<Byte, byte[]>> RESULT_TYPE_INFO =
+            new TupleTypeInfo<Tuple2<Byte, byte[]>>(
+                    BasicTypeInfo.BYTE_TYPE_INFO,
+                    PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO);
+
+    private final transient StreamExecutionEnvironment env;
+
+    public FlinkDataEvolutionVectorRead(
+            FileStoreTable table,
+            @Nullable PartitionPredicate partitionFilter,
+            @Nullable Predicate filter,
+            int limit,
+            DataField vectorColumn,
+            float[] vector,
+            @Nullable Map<String, String> options,
+            StreamExecutionEnvironment env) {
+        super(table, partitionFilter, filter, limit, vectorColumn, vector, 
options);
+        this.env = checkNotNull(env);
+    }
+
+    @Override
+    public GlobalIndexResult read(VectorScan.Plan plan) {
+        List<IndexVectorSearchSplit> indexSplits = new ArrayList<>();
+        List<RawVectorSearchSplit> rawSplits = new ArrayList<>();
+        splitSearchSplits(plan.splits(), indexSplits, rawSplits);
+        if (indexSplits.isEmpty() && rawSplits.isEmpty()) {
+            return GlobalIndexResult.createEmpty();
+        }
+
+        GlobalIndexer globalIndexer =
+                !indexSplits.isEmpty() && !rawSplits.isEmpty()
+                        ? createGlobalIndexer(indexSplits)
+                        : null;
+        if (!indexSplits.isEmpty() && !rawSplits.isEmpty()) {
+            int parallelism = flinkParallelism();
+            List<Range> rawRowRanges = rawRowRanges(rawSplits);
+            if (indexSplits.size() >= parallelism * 2L
+                    && rawRowCount(rawRowRanges) >= parallelism * 2L) {
+                return readIndexAndRawSplitsInFlink(
+                        indexSplits,
+                        rawSplits,
+                        rawRowRanges,
+                        globalIndexer,
+                        rawPreFilter(rawSplits),
+                        parallelism);
+            }
+        }
+
+        ScoredGlobalIndexResult indexed =
+                indexSplits.isEmpty()
+                        ? ScoredGlobalIndexResult.createEmpty()
+                        : readIndexSplitsInFlink(indexSplits, globalIndexer);
+        ScoredGlobalIndexResult raw =
+                readRawSplitsInFlink(rawSplits, globalIndexer, 
rawPreFilter(rawSplits));
+        return indexed.or(raw).topK(limit);
+    }
+
+    protected ScoredGlobalIndexResult readIndexSplitsInFlink(
+            List<IndexVectorSearchSplit> splits, @Nullable GlobalIndexer 
globalIndexer) {
+        if (splits.isEmpty()) {
+            return ScoredGlobalIndexResult.createEmpty();
+        }
+
+        int parallelism = flinkParallelism();
+        if (splits.size() < parallelism * 2L) {
+            return readIndexed(
+                    splits, globalIndexer == null ? 
createGlobalIndexer(splits) : globalIndexer);
+        }
+
+        List<RoaringNavigableMap64> preFilters = preFilters(splits);
+        String indexType = vectorIndexType(splits);
+        int searchLimit = indexedSearchLimit(indexType);
+        List<List<SerializedSplit>> splitGroups = indexSplitGroups(splits, 
preFilters, parallelism);
+        List<byte[]> remoteResults =
+                executeIndexSearchGroups(splitGroups, searchLimit, 
parallelism);
+        GlobalIndexer rerankGlobalIndexer =
+                globalIndexer == null ? createGlobalIndexer(splits) : 
globalIndexer;
+        return maybeRerankIndexedResult(
+                mergeRemoteResults(remoteResults, searchLimit),
+                indexType,
+                rerankGlobalIndexer,
+                vector);
+    }
+
+    private List<List<SerializedSplit>> indexSplitGroups(
+            List<IndexVectorSearchSplit> splits,
+            List<RoaringNavigableMap64> preFilters,
+            int parallelism) {
+        List<SerializedSplit> serializedSplits = new 
ArrayList<>(splits.size());
+        for (int i = 0; i < splits.size(); i++) {
+            try {
+                IndexVectorSearchSplit split = splits.get(i);
+                RoaringNavigableMap64 preFilter = preFilters.isEmpty() ? null 
: preFilters.get(i);
+                serializedSplits.add(
+                        new SerializedSplit(
+                                InstantiationUtil.serializeObject(split),
+                                preFilter == null
+                                        ? null
+                                        : 
InstantiationUtil.serializeObject(preFilter)));
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to serialize vector-search 
split.", e);
+            }
+        }
+        return splitGroups(serializedSplits, parallelism);
+    }
+
+    protected ScoredGlobalIndexResult readRawSplitsInFlink(
+            List<RawVectorSearchSplit> splits,
+            @Nullable GlobalIndexer globalIndexer,
+            @Nullable RoaringNavigableMap64 preFilter) {
+        List<Range> rawRowRanges = rawRowRanges(splits);
+        if (rawRowRanges.isEmpty()) {
+            return ScoredGlobalIndexResult.createEmpty();
+        }
+
+        int parallelism = flinkParallelism();
+        if (rawRowCount(rawRowRanges) < parallelism * 2L) {
+            return readRawSearch(
+                    rawRowRanges, preFilter, rawSearchIndexer(splits, 
globalIndexer), vector);
+        }
+
+        String metric = rawSearchMetric(rawSearchIndexer(splits, 
globalIndexer));
+        List<List<SerializedSplit>> splitGroups =
+                rawSplitGroups(rawRowRanges, preFilter, parallelism);
+        List<byte[]> remoteResults = executeRawSearchGroups(splitGroups, 
metric, parallelism);
+        return mergeRemoteResults(remoteResults, limit);
+    }
+
+    private List<List<SerializedSplit>> rawSplitGroups(
+            List<Range> rawRowRanges, @Nullable RoaringNavigableMap64 
preFilter, int parallelism) {
+        List<List<Range>> rangeGroups = rangeGroups(rawRowRanges, parallelism);
+        List<SerializedSplit> serializedSplits = new 
ArrayList<>(rangeGroups.size());
+        for (List<Range> rangeGroup : rangeGroups) {
+            try {
+                RoaringNavigableMap64 groupPreFilter = 
groupPreFilter(rangeGroup, preFilter);
+                serializedSplits.add(
+                        new SerializedSplit(
+                                InstantiationUtil.serializeObject(rangeGroup),
+                                groupPreFilter == null
+                                        ? null
+                                        : 
InstantiationUtil.serializeObject(groupPreFilter)));
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to serialize raw vector row 
ranges.", e);
+            }
+        }
+        return splitGroups(serializedSplits, parallelism);
+    }
+
+    @Nullable
+    private RoaringNavigableMap64 groupPreFilter(
+            List<Range> rangeGroup, @Nullable RoaringNavigableMap64 preFilter) 
{
+        if (preFilter == null) {
+            return null;
+        }
+
+        RoaringNavigableMap64 groupRows = new RoaringNavigableMap64();
+        for (Range range : rangeGroup) {
+            groupRows.addRange(range);
+        }
+        groupRows.and(preFilter);
+        if (groupRows.getLongCardinality() == rawRowCount(rangeGroup)) {
+            return null;
+        }
+        groupRows.runOptimize();
+        return groupRows;
+    }
+
+    private ScoredGlobalIndexResult readIndexAndRawSplitsInFlink(
+            List<IndexVectorSearchSplit> indexSplits,
+            List<RawVectorSearchSplit> rawSplits,
+            List<Range> rawRowRanges,
+            GlobalIndexer globalIndexer,
+            @Nullable RoaringNavigableMap64 rawPreFilter,
+            int parallelism) {
+        String indexType = vectorIndexType(indexSplits);
+        int searchLimit = indexedSearchLimit(indexType);
+        List<List<SerializedSplit>> indexGroups =
+                indexSplitGroups(indexSplits, preFilters(indexSplits), 
parallelism);
+
+        String rawMetric = rawSearchMetric(rawSearchIndexer(rawSplits, 
globalIndexer));
+        List<List<SerializedSplit>> rawGroups =
+                rawSplitGroups(rawRowRanges, rawPreFilter, parallelism);
+
+        List<Tuple2<Byte, byte[]>> results =
+                executeIndexAndRawSearchGroups(
+                        indexGroups, searchLimit, rawGroups, rawMetric, 
parallelism);
+        List<byte[]> indexedResults = new ArrayList<>();
+        List<byte[]> rawResults = new ArrayList<>();
+        for (Tuple2<Byte, byte[]> result : results) {
+            byte resultType = checkNotNull(result.f0, "Missing vector-search 
result type.");
+            byte[] resultBytes = checkNotNull(result.f1, "Missing 
vector-search result payload.");
+            if (resultType == INDEX_RESULT) {
+                indexedResults.add(resultBytes);
+            } else if (resultType == RAW_RESULT) {
+                rawResults.add(resultBytes);
+            } else {
+                throw new IllegalArgumentException(
+                        "Unknown vector-search result type: " + resultType);
+            }
+        }
+
+        ScoredGlobalIndexResult indexed =
+                maybeRerankIndexedResult(
+                        mergeRemoteResults(indexedResults, searchLimit),
+                        indexType,
+                        globalIndexer,
+                        vector);
+        ScoredGlobalIndexResult raw = mergeRemoteResults(rawResults, limit);
+        return indexed.or(raw).topK(limit);
+    }
+
+    protected int flinkParallelism() {
+        int maxParallelism =
+                Math.max(1, 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+        int envParallelism = checkNotNull(env).getParallelism();
+        return envParallelism > 0
+                ? Math.max(1, Math.min(envParallelism, maxParallelism))
+                : maxParallelism;
+    }
+
+    protected List<byte[]> executeIndexSearchGroups(
+            List<List<SerializedSplit>> groups, int searchLimit, int 
parallelism) {
+        String operatorName = "Vector Index Search";
+        return collectResults(
+                indexSearchStream(groups, searchLimit, parallelism, 
operatorName), operatorName);
+    }
+
+    protected List<byte[]> executeRawSearchGroups(
+            List<List<SerializedSplit>> groups, String metric, int 
parallelism) {
+        String operatorName = "Raw Vector Search";
+        return collectResults(
+                rawSearchStream(groups, metric, parallelism, operatorName), 
operatorName);
+    }
+
+    protected List<Tuple2<Byte, byte[]>> executeIndexAndRawSearchGroups(
+            List<List<SerializedSplit>> indexGroups,
+            int searchLimit,
+            List<List<SerializedSplit>> rawGroups,
+            String rawMetric,
+            int parallelism) {
+        DataStream<Tuple2<Byte, byte[]>> indexResults =
+                addResultType(
+                        indexSearchStream(
+                                indexGroups, searchLimit, parallelism, "Vector 
Index Search"),
+                        INDEX_RESULT,
+                        "Add Vector Index Result Type");
+        DataStream<Tuple2<Byte, byte[]>> rawResults =
+                addResultType(
+                        rawSearchStream(rawGroups, rawMetric, parallelism, 
"Raw Vector Search"),
+                        RAW_RESULT,
+                        "Add Raw Result Type");
+        return collectResults(indexResults.union(rawResults), "Vector Index 
and Raw Search");
+    }
+
+    private DataStream<byte[]> indexSearchStream(
+            List<List<SerializedSplit>> groups,
+            int searchLimit,
+            int parallelism,
+            String operatorName) {
+        DataStream<byte[]> results =
+                taskSource(groups, operatorName)
+                        .map(
+                                (MapFunction<byte[], byte[]>)
+                                        bytes ->
+                                                executeIndexSearchGroup(
+                                                        
deserializeSplitGroup(bytes),
+                                                        searchLimit,
+                                                        parallelism))
+                        
.returns(PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                        .name(operatorName)
+                        .setParallelism(Math.min(parallelism, groups.size()));
+        return results;
+    }
+
+    private DataStream<byte[]> rawSearchStream(
+            List<List<SerializedSplit>> groups,
+            String metric,
+            int parallelism,
+            String operatorName) {
+        return taskSource(groups, operatorName)
+                .map(
+                        (MapFunction<byte[], byte[]>)
+                                bytes ->
+                                        
executeRawSearchGroup(deserializeSplitGroup(bytes), metric))
+                .returns(PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                .name(operatorName)
+                .setParallelism(Math.min(parallelism, groups.size()));
+    }
+
+    static DataStream<Tuple2<Byte, byte[]>> addResultType(
+            DataStream<byte[]> results, byte resultType, String operatorName) {
+        return results.map(
+                        (MapFunction<byte[], Tuple2<Byte, byte[]>>)
+                                bytes -> Tuple2.of(resultType, bytes))
+                .returns(RESULT_TYPE_INFO)
+                .name(operatorName)
+                .setParallelism(results.getParallelism());
+    }
+
+    private DataStream<byte[]> taskSource(List<List<SerializedSplit>> groups, 
String operatorName) {
+        List<byte[]> serializedGroups = new ArrayList<>(groups.size());
+        try {
+            for (List<SerializedSplit> group : groups) {
+                serializedGroups.add(InstantiationUtil.serializeObject(group));
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to serialize vector-search task 
groups.", e);
+        }
+
+        return StreamExecutionEnvironmentUtils.fromData(
+                        checkNotNull(env),
+                        serializedGroups,
+                        PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                .name(operatorName + " Source")
+                .setParallelism(1)
+                .rebalance();
+    }
+
+    private byte[] executeIndexSearchGroup(
+            List<SerializedSplit> group, int searchLimit, int parallelism) {
+        List<IndexVectorSearchSplit> splits = new ArrayList<>(group.size());
+        for (SerializedSplit serializedSplit : group) {
+            splits.add(deserializeSplit(serializedSplit.split));
+        }
+
+        GlobalIndexer taskGlobalIndexer = createGlobalIndexer(splits);
+        IndexPathFactory indexPathFactory = 
table.store().pathFactory().globalIndexFileFactory();
+        ExecutorService executor =
+                
GlobalIndexReadThreadPool.getExecutorService(Math.min(parallelism, 
group.size()));
+
+        List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> futures =
+                new ArrayList<>(group.size());
+        for (int i = 0; i < group.size(); i++) {
+            SerializedSplit serializedSplit = group.get(i);
+            IndexVectorSearchSplit split = splits.get(i);
+            futures.add(
+                    eval(
+                            taskGlobalIndexer,
+                            indexPathFactory,
+                            split.rowRangeStart(),
+                            split.rowRangeEnd(),
+                            split.vectorIndexFiles(),
+                            vector,
+                            searchLimit,
+                            deserializePreFilter(serializedSplit.preFilter),
+                            executor));
+        }
+        CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).join();
+
+        ScoredGlobalIndexResult result = ScoredGlobalIndexResult.createEmpty();
+        for (CompletableFuture<Optional<ScoredGlobalIndexResult>> future : 
futures) {
+            Optional<ScoredGlobalIndexResult> next = future.join();
+            if (next.isPresent()) {
+                result = result.or(next.get());
+            }
+        }
+        return serializeResult(result.topK(searchLimit));
+    }
+
+    protected byte[] executeRawSearchGroup(List<SerializedSplit> group, String 
metric) {
+        ScoredGlobalIndexResult result = ScoredGlobalIndexResult.createEmpty();
+        for (SerializedSplit serializedSplit : group) {
+            ScoredGlobalIndexResult splitResult =
+                    readRawSearch(
+                            deserializeRanges(serializedSplit.split),
+                            deserializePreFilter(serializedSplit.preFilter),
+                            metric,
+                            vector);
+            result = result.or(splitResult);
+        }
+        return serializeResult(result.topK(limit));
+    }
+
+    private List<SerializedSplit> deserializeSplitGroup(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize vector-search 
task group.", e);
+        }
+    }
+
+    private <T> List<T> collectResults(DataStream<T> results, String 
operatorName) {
+        List<T> output = new ArrayList<>();
+        try (CloseableIterator<T> iterator =
+                results.executeAndCollect(flinkJobName(operatorName))) {
+            iterator.forEachRemaining(output::add);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to execute " + operatorName + 
".", e);
+        }
+        return output;
+    }
+
+    protected String flinkJobName(String operatorName) {
+        return "Vector Search - " + operatorName + " : " + table.fullName();
+    }
+
+    private byte[] serializeResult(ScoredGlobalIndexResult result) {
+        if (result.results().isEmpty()) {
+            return new byte[0];
+        }
+        try {
+            return new GlobalIndexResultSerializer().serialize(result);
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to serialize scored 
vector-search result.", e);
+        }
+    }
+
+    private IndexVectorSearchSplit deserializeSplit(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize vector-search 
split.", e);
+        }
+    }
+
+    private List<Range> deserializeRanges(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize raw vector row 
ranges.", e);
+        }
+    }
+
+    @Nullable
+    private RoaringNavigableMap64 deserializePreFilter(@Nullable byte[] bytes) 
{
+        if (bytes == null) {
+            return null;
+        }
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize vector 
pre-filter.", e);
+        }
+    }
+
+    private List<List<SerializedSplit>> splitGroups(
+            List<SerializedSplit> serializedSplits, int parallelism) {
+        List<List<SerializedSplit>> groups = new ArrayList<>(parallelism);
+        int groupSize = (serializedSplits.size() + parallelism - 1) / 
parallelism;
+        for (int start = 0; start < serializedSplits.size(); start += 
groupSize) {
+            groups.add(
+                    new ArrayList<>(
+                            serializedSplits.subList(
+                                    start, Math.min(start + groupSize, 
serializedSplits.size()))));
+        }
+        return groups;
+    }
+
+    private ScoredGlobalIndexResult mergeRemoteResults(List<byte[]> 
remoteResults, int topK) {
+        ScoredGlobalIndexResult result = ScoredGlobalIndexResult.createEmpty();
+        GlobalIndexResultSerializer serializer = new 
GlobalIndexResultSerializer();
+        for (byte[] bytes : remoteResults) {
+            if (bytes != null && bytes.length > 0) {
+                try {
+                    result = result.or(serializer.deserialize(bytes));
+                } catch (IOException e) {
+                    throw new RuntimeException(
+                            "Failed to deserialize scored vector-search 
result.", e);
+                }
+            }
+        }
+        return result.topK(topK);
+    }
+
+    private List<List<Range>> rangeGroups(List<Range> ranges, int parallelism) 
{
+        long rowCount = rawRowCount(ranges);
+        int groupCount = (int) Math.min(parallelism, rowCount);
+        long targetRowsPerGroup = (rowCount - 1) / groupCount + 1;
+
+        List<List<Range>> groups = new ArrayList<>(groupCount);
+        List<Range> currentGroup = new ArrayList<>();
+        long currentRows = 0;
+        for (Range range : ranges) {
+            long from = range.from;
+            while (from <= range.to) {
+                if (currentRows == targetRowsPerGroup) {
+                    groups.add(currentGroup);
+                    currentGroup = new ArrayList<>();
+                    currentRows = 0;
+                }
+                long remainingGroupRows = targetRowsPerGroup - currentRows;
+                long to = Math.min(range.to, from + remainingGroupRows - 1);
+                Range next = new Range(from, to);
+                currentGroup.add(next);
+                currentRows += next.count();
+                from = to + 1;
+            }
+        }
+        if (!currentGroup.isEmpty()) {
+            groups.add(currentGroup);
+        }
+        return groups;
+    }
+
+    private long rawRowCount(List<Range> ranges) {
+        long rowCount = 0;
+        for (Range range : ranges) {
+            long count = range.count();
+            if (Long.MAX_VALUE - rowCount < count) {
+                return Long.MAX_VALUE;
+            }
+            rowCount += count;
+        }
+        return rowCount;
+    }
+
+    static class SerializedSplit implements java.io.Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private final byte[] split;
+        @Nullable private final byte[] preFilter;
+
+        private SerializedSplit(@Nullable byte[] split, @Nullable byte[] 
preFilter) {
+            this.split = split;
+            this.preFilter = preFilter;
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkPrimaryKeyVectorRead.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkPrimaryKeyVectorRead.java
new file mode 100644
index 0000000000..613c59465b
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkPrimaryKeyVectorRead.java
@@ -0,0 +1,193 @@
+/*
+ * 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.flink.vectorsearch;
+
+import org.apache.paimon.flink.utils.StreamExecutionEnvironmentUtils;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.BucketVectorSearchSplit;
+import org.apache.paimon.table.source.PrimaryKeyVectorRead;
+import org.apache.paimon.table.source.PrimaryKeyVectorScan;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.InstantiationUtil;
+
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.typeinfo.PrimitiveArrayTypeInfo;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.util.CloseableIterator;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Flink-aware {@link PrimaryKeyVectorRead}. */
+public class FlinkPrimaryKeyVectorRead extends PrimaryKeyVectorRead {
+
+    private static final long serialVersionUID = 1L;
+
+    private final transient StreamExecutionEnvironment env;
+
+    public FlinkPrimaryKeyVectorRead(
+            FileStoreTable table,
+            DataField vectorField,
+            float[] query,
+            int limit,
+            Map<String, String> searchOptions,
+            @Nullable Predicate filter,
+            StreamExecutionEnvironment env) {
+        super(table, vectorField, query, limit, searchOptions, filter);
+        this.env = checkNotNull(env);
+    }
+
+    @Override
+    public GlobalIndexResult read(VectorScan.Plan plan) {
+        PrimaryKeyVectorScan.Plan primaryKeyPlan = primaryKeyPlan(plan);
+        List<BucketVectorSearchSplit> splits = bucketSplits(primaryKeyPlan);
+        int parallelism = flinkParallelism();
+        if (splits.size() < parallelism * 2L) {
+            return super.read(plan);
+        }
+
+        List<byte[]> serializedSplits = new ArrayList<>(splits.size());
+        for (BucketVectorSearchSplit split : splits) {
+            try {
+                serializedSplits.add(InstantiationUtil.serializeObject(split));
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to serialize primary-key 
vector split.", e);
+            }
+        }
+
+        List<List<byte[]>> groups = splitGroups(serializedSplits, parallelism);
+        List<byte[]> groupResults = executeBucketSearchGroups(groups, 
parallelism);
+        List<SearchResult> searchResults = new 
ArrayList<>(groupResults.size());
+        for (byte[] groupResult : groupResults) {
+            searchResults.add(deserializeSearchResult(groupResult));
+        }
+        return createResult(primaryKeyPlan, mergeSearchResults(searchResults));
+    }
+
+    protected int flinkParallelism() {
+        int maxParallelism =
+                Math.max(1, 
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+        int envParallelism = checkNotNull(env).getParallelism();
+        return envParallelism > 0
+                ? Math.max(1, Math.min(envParallelism, maxParallelism))
+                : maxParallelism;
+    }
+
+    protected List<byte[]> executeBucketSearchGroups(List<List<byte[]>> 
groups, int parallelism) {
+        String operatorName = "Primary-Key Vector Search";
+        List<byte[]> serializedGroups = new ArrayList<>(groups.size());
+        try {
+            for (List<byte[]> group : groups) {
+                serializedGroups.add(InstantiationUtil.serializeObject(group));
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to serialize primary-key vector 
task groups.", e);
+        }
+
+        DataStream<byte[]> results =
+                StreamExecutionEnvironmentUtils.fromData(
+                                checkNotNull(env),
+                                serializedGroups,
+                                
PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                        .name(operatorName + " Source")
+                        .setParallelism(1)
+                        .rebalance()
+                        .map(
+                                (MapFunction<byte[], byte[]>)
+                                        bytes -> 
executeBucketSearchGroup(deserializeGroup(bytes)))
+                        
.returns(PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                        .name(operatorName)
+                        .setParallelism(Math.min(parallelism, groups.size()));
+
+        List<byte[]> output = new ArrayList<>(groups.size());
+        try (CloseableIterator<byte[]> iterator =
+                results.executeAndCollect(flinkJobName(operatorName))) {
+            iterator.forEachRemaining(output::add);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to execute " + operatorName + 
".", e);
+        }
+        return output;
+    }
+
+    protected String flinkJobName(String operatorName) {
+        return "Vector Search - " + operatorName + " : " + table.fullName();
+    }
+
+    protected byte[] executeBucketSearchGroup(List<byte[]> group) {
+        List<BucketVectorSearchSplit> taskSplits = new 
ArrayList<>(group.size());
+        for (byte[] bytes : group) {
+            taskSplits.add(deserializeSplit(bytes));
+        }
+        try {
+            return 
InstantiationUtil.serializeObject(searchBuckets(taskSplits));
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to serialize primary-key vector 
candidates.", e);
+        }
+    }
+
+    private List<byte[]> deserializeGroup(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize primary-key 
vector task group.", e);
+        }
+    }
+
+    private List<List<byte[]>> splitGroups(List<byte[]> splits, int 
parallelism) {
+        List<List<byte[]>> groups = new ArrayList<>(parallelism);
+        int groupSize = (splits.size() + parallelism - 1) / parallelism;
+        for (int start = 0; start < splits.size(); start += groupSize) {
+            groups.add(
+                    new ArrayList<>(
+                            splits.subList(start, Math.min(start + groupSize, 
splits.size()))));
+        }
+        return groups;
+    }
+
+    private BucketVectorSearchSplit deserializeSplit(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Failed to deserialize primary-key 
vector split.", e);
+        }
+    }
+
+    private SearchResult deserializeSearchResult(byte[] bytes) {
+        try {
+            return InstantiationUtil.deserializeObject(
+                    bytes, Thread.currentThread().getContextClassLoader());
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException(
+                    "Failed to deserialize primary-key vector search result.", 
e);
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkVectorSearchBuilderImpl.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkVectorSearchBuilderImpl.java
new file mode 100644
index 0000000000..cad0f86835
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/vectorsearch/FlinkVectorSearchBuilderImpl.java
@@ -0,0 +1,51 @@
+/*
+ * 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.flink.vectorsearch;
+
+import org.apache.paimon.table.InnerTable;
+import org.apache.paimon.table.source.VectorRead;
+import org.apache.paimon.table.source.VectorSearchBuilderImpl;
+
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Flink-aware vector-search builder which creates distributed Flink readers. 
*/
+public class FlinkVectorSearchBuilderImpl extends VectorSearchBuilderImpl {
+
+    private static final long serialVersionUID = 1L;
+
+    private final transient StreamExecutionEnvironment env;
+
+    public FlinkVectorSearchBuilderImpl(InnerTable table, 
StreamExecutionEnvironment env) {
+        super(table);
+        this.env = checkNotNull(env);
+    }
+
+    @Override
+    public VectorRead newVectorRead() {
+        checkNotNull(vector, "vector must be set via withVector()");
+        if (isPrimaryKeyVectorSearch()) {
+            return new FlinkPrimaryKeyVectorRead(
+                    table, vectorColumn, vector, limit, options, filter, env);
+        }
+        return new FlinkDataEvolutionVectorRead(
+                table, partitionFilter, filter, limit, vectorColumn, vector, 
options, env);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
index 74ec37d14b..9fb817ba10 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
@@ -19,10 +19,12 @@
 package org.apache.paimon.flink.procedure;
 
 import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.GenericArray;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.flink.CatalogITCaseBase;
 import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.GlobalIndexMultiColumnWriter;
 import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
 import org.apache.paimon.globalindex.ResultEntry;
 import org.apache.paimon.globalindex.testvector.TestVectorGlobalIndexer;
@@ -31,6 +33,8 @@ import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataIncrement;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.PostponeUtils;
 import org.apache.paimon.table.sink.BatchTableCommit;
@@ -38,6 +42,9 @@ import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.IndexVectorSearchSplit;
+import org.apache.paimon.table.source.RawVectorSearchSplit;
+import org.apache.paimon.table.source.VectorScan;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.Range;
 
@@ -45,10 +52,14 @@ import org.apache.flink.table.api.config.TableConfigOptions;
 import org.apache.flink.types.Row;
 import org.junit.jupiter.api.Test;
 
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** IT cases for {@link VectorSearchProcedure}. */
 public class VectorSearchProcedureITCase extends CatalogITCaseBase {
@@ -73,6 +84,155 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
                 .containsExactlyInAnyOrder("{\"id\":\"2\"}", "{\"id\":\"3\"}");
     }
 
+    @Test
+    public void testPrimaryKeyVectorSearchWithSnapshot() throws Exception {
+        createPrimaryKeyVectorTable("PK_SNAPSHOT_T");
+        sql(
+                "INSERT INTO PK_SNAPSHOT_T VALUES "
+                        + "(1, ARRAY[CAST(1.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+        long snapshotId = 
paimonTable("PK_SNAPSHOT_T").latestSnapshot().get().id();
+        sql(
+                "INSERT INTO PK_SNAPSHOT_T VALUES "
+                        + "(2, ARRAY[CAST(0.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+
+        assertThat(search("PK_SNAPSHOT_T", "0.0,0.0", 2, "id", 
"scan.snapshot-id=" + snapshotId))
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"1\"}");
+    }
+
+    @Test
+    public void testEmptyTableVectorSearch() {
+        createVectorTable("EMPTY_T");
+        createPrimaryKeyVectorTable("EMPTY_PK_T");
+
+        assertThat(search("EMPTY_T", "0.0,0.0", 1, "id", null)).isEmpty();
+        assertThat(searchPrimaryKeyVectorTable("EMPTY_PK_T", 1, 
"id")).isEmpty();
+    }
+
+    @Test
+    public void testVectorSearchKeepsResolvedSchema() throws Exception {
+        createVectorTable("SCHEMA_EVOLUTION_T", "'global-index.search-mode' = 
'full'");
+        FileStoreTable table = paimonTable("SCHEMA_EVOLUTION_T");
+        float[][] vectors = {{0.0f, 0.0f}, {1.0f, 0.0f}};
+        writeVectors(table, vectors);
+        buildAndCommitVectorIndex(table, vectors);
+
+        sql("ALTER TABLE SCHEMA_EVOLUTION_T ADD payload STRING FIRST");
+        FileStoreTable evolvedTable = paimonTable("SCHEMA_EVOLUTION_T");
+        assertThat(evolvedTable.schema().id())
+                .isNotEqualTo(evolvedTable.latestSnapshot().get().schemaId());
+
+        List<String> defaultProjection =
+                sql(
+                                "CALL sys.vector_search("
+                                        + "`table` => 
'default.SCHEMA_EVOLUTION_T', "
+                                        + "vector_column => 'vec', "
+                                        + "query_vector => '0.0,0.0', "
+                                        + "top_k => 1)")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+        assertThat(defaultProjection).hasSize(1);
+        assertThat(defaultProjection.get(0))
+                .contains("\"payload\":null", "\"id\":\"0\"", "\"vec\"");
+
+        assertThat(search("SCHEMA_EVOLUTION_T", "0.0,0.0", 1, "payload,id", 
null))
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"payload\":null,\"id\":\"0\"}");
+
+        List<String> filtered =
+                sql(
+                                "CALL sys.vector_search("
+                                        + "`table` => 
'default.SCHEMA_EVOLUTION_T', "
+                                        + "vector_column => 'vec', "
+                                        + "query_vector => '0.0,0.0', "
+                                        + "top_k => 1, "
+                                        + "projection => 'payload,id', "
+                                        + "`where` => 'payload IS NULL AND id 
= 1')")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+        
assertThat(filtered).containsExactly("{\"payload\":null,\"id\":\"1\"}");
+    }
+
+    @Test
+    public void testDistributedPrimaryKeyVectorSearch() {
+        createPrimaryKeyVectorTable(
+                "DISTRIBUTED_PK_T",
+                4,
+                "'vector-search.distribute.enabled' = 'true', "
+                        + "'global-index.thread-num' = '2'");
+        String values =
+                IntStream.range(0, 32)
+                        .mapToObj(
+                                i ->
+                                        String.format(
+                                                "(%d, ARRAY[CAST(%d.0 AS 
FLOAT), CAST(0.0 AS FLOAT)])",
+                                                i, i))
+                        .collect(Collectors.joining(","));
+        sql("INSERT INTO DISTRIBUTED_PK_T VALUES " + values);
+        assertThat(sql("SELECT DISTINCT bucket FROM 
`DISTRIBUTED_PK_T$files`")).hasSize(4);
+
+        List<String> rows =
+                searchPrimaryKeyVectorTable("DISTRIBUTED_PK_T", 3, 
"id,__paimon_search_score")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+
+        assertThat(rows).hasSize(3);
+        assertThat(rows.get(0)).contains("\"id\":\"0\"");
+        assertThat(rows.get(1)).contains("\"id\":\"1\"");
+        assertThat(rows.get(2)).contains("\"id\":\"2\"");
+        assertThat(rows).allMatch(row -> 
row.contains("\"__paimon_search_score\""));
+
+        List<String> scoreOnlyRows =
+                searchPrimaryKeyVectorTable("DISTRIBUTED_PK_T", 2, 
"__paimon_search_score").stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+        assertThat(scoreOnlyRows).hasSize(2);
+        assertThat(scoreOnlyRows)
+                .allMatch(
+                        row ->
+                                row.startsWith("{\"__paimon_search_score\":")
+                                        && row.endsWith("}")
+                                        && !row.contains("\"id\"")
+                                        && !row.contains("\"vec\""));
+    }
+
+    @Test
+    public void testDistributedPrimaryKeyVectorSearchWithResidualFilter() {
+        createPrimaryKeyVectorTableWithPayload(
+                "DISTRIBUTED_FILTERED_PK_T",
+                4,
+                "'vector-search.distribute.enabled' = 'true', "
+                        + "'global-index.thread-num' = '2'");
+        String values =
+                IntStream.rangeClosed(1, 16)
+                        .mapToObj(
+                                i ->
+                                        String.format(
+                                                "(%d, '%s', ARRAY[CAST(%d.0 AS 
FLOAT), CAST(0.0 AS FLOAT)])",
+                                                i, i == 1 ? "drop" : "keep", 
i))
+                        .collect(Collectors.joining(","));
+        sql("INSERT INTO DISTRIBUTED_FILTERED_PK_T VALUES " + values);
+        assertThat(sql("SELECT DISTINCT bucket FROM 
`DISTRIBUTED_FILTERED_PK_T$files`")).hasSize(4);
+
+        List<String> rows =
+                sql(
+                                "CALL sys.vector_search("
+                                        + "`table` => 
'default.DISTRIBUTED_FILTERED_PK_T', "
+                                        + "vector_column => 'vec', "
+                                        + "query_vector => '0.0,0.0', "
+                                        + "top_k => 2, "
+                                        + "projection => 'id', "
+                                        + "`where` => 'payload = ''keep''')")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+
+        assertThat(rows).containsExactly("{\"id\":\"2\"}", "{\"id\":\"3\"}");
+    }
+
     @Test
     public void testPostponeBucketBuildsVectorIndexDuringCompact() throws 
Exception {
         createPostponePrimaryKeyVectorTable("POSTPONE_PK_T");
@@ -303,6 +463,336 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
         assertThat(result.size()).isLessThanOrEqualTo(2);
     }
 
+    @Test
+    public void testDistributedVectorSearchWithFilterAndScore() throws 
Exception {
+        createVectorTable(
+                "DISTRIBUTED_T",
+                "'vector-search.distribute.enabled' = 'true', "
+                        + "'global-index.thread-num' = '2', "
+                        + "'global-index.search-mode' = 'full'");
+        FileStoreTable table = paimonTable("DISTRIBUTED_T");
+        float[][] vectors = {
+            {0.0f, 0.0f},
+            {1.0f, 0.0f},
+            {2.0f, 0.0f},
+            {3.0f, 0.0f},
+            {4.0f, 0.0f},
+            {5.0f, 0.0f}
+        };
+        writeVectors(table, vectors);
+
+        List<String> rows =
+                sql(
+                                "CALL sys.vector_search("
+                                        + "`table` => 'default.DISTRIBUTED_T', 
"
+                                        + "vector_column => 'vec', "
+                                        + "query_vector => '0.0,0.0', "
+                                        + "top_k => 2, "
+                                        + "projection => 
'vec,__paimon_search_score', "
+                                        + "`where` => 'id >= 3')")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+
+        assertThat(rows).hasSize(2);
+        assertThat(rows.get(0)).contains("3.0");
+        assertThat(rows.get(1)).contains("4.0");
+        assertThat(rows)
+                .allMatch(
+                        row ->
+                                row.contains("\"vec\"")
+                                        && 
row.contains("\"__paimon_search_score\"")
+                                        && !row.contains("\"id\""));
+
+        List<String> scoreOnlyRows =
+                search("DISTRIBUTED_T", "0.0,0.0", 2, "__paimon_search_score", 
null).stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+        assertThat(scoreOnlyRows).hasSize(2);
+        assertThat(scoreOnlyRows)
+                .allMatch(
+                        row ->
+                                row.startsWith("{\"__paimon_search_score\":")
+                                        && row.endsWith("}")
+                                        && !row.contains("\"id\"")
+                                        && !row.contains("\"vec\""));
+    }
+
+    @Test
+    public void testDistributedMultiFieldVectorIndex() throws Exception {
+        createVectorTable(
+                "DISTRIBUTED_MULTI_FIELD_T",
+                "'vector-search.distribute.enabled' = 'true', "
+                        + "'global-index.thread-num' = '2', "
+                        + "'global-index.search-mode' = 'full'");
+        FileStoreTable table = paimonTable("DISTRIBUTED_MULTI_FIELD_T");
+        float[][] vectors = {
+            {0.0f, 0.0f},
+            {1.0f, 0.0f},
+            {2.0f, 0.0f},
+            {3.0f, 0.0f},
+            {4.0f, 0.0f},
+            {5.0f, 0.0f},
+            {6.0f, 0.0f},
+            {7.0f, 0.0f}
+        };
+        writeVectors(table, vectors);
+        for (int start = 0; start < vectors.length; start += 2) {
+            buildAndCommitMultiFieldVectorIndex(
+                    table,
+                    new float[][] {vectors[start], vectors[start + 1]},
+                    new Range(start, start + 1));
+        }
+
+        VectorScan.Plan plan =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(2)
+                        .withVectorColumn(VECTOR_FIELD)
+                        .newVectorScan()
+                        .scan();
+        List<IndexVectorSearchSplit> indexSplits =
+                plan.splits().stream()
+                        .filter(IndexVectorSearchSplit.class::isInstance)
+                        .map(IndexVectorSearchSplit.class::cast)
+                        .collect(Collectors.toList());
+        assertThat(indexSplits).hasSize(4);
+        assertThat(indexSplits).allMatch(split -> 
split.vectorIndexFiles().size() == 2);
+
+        List<String> rows =
+                sql(
+                                "CALL sys.vector_search("
+                                        + "`table` => 
'default.DISTRIBUTED_MULTI_FIELD_T', "
+                                        + "vector_column => 'vec', "
+                                        + "query_vector => '0.0,0.0', "
+                                        + "top_k => 2, "
+                                        + "projection => 'id', "
+                                        + "`where` => 'id >= 5')")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+
+        assertThat(rows).containsExactly("{\"id\":\"5\"}", "{\"id\":\"6\"}");
+    }
+
+    @Test
+    public void testDistributedMixedIndexedAndRawVectorSearch() throws 
Exception {
+        createVectorTable(
+                "DISTRIBUTED_MIXED_T",
+                "'vector-search.distribute.enabled' = 'true', "
+                        + "'global-index.thread-num' = '2', "
+                        + "'global-index.search-mode' = 'full'");
+        FileStoreTable table = paimonTable("DISTRIBUTED_MIXED_T");
+        float[][] vectors = {
+            {20.0f, 0.0f},
+            {21.0f, 0.0f},
+            {22.0f, 0.0f},
+            {23.0f, 0.0f},
+            {24.0f, 0.0f},
+            {25.0f, 0.0f},
+            {26.0f, 0.0f},
+            {27.0f, 0.0f},
+            {0.0f, 0.0f},
+            {1.0f, 0.0f},
+            {2.0f, 0.0f},
+            {3.0f, 0.0f}
+        };
+        writeVectors(table, vectors);
+        for (int start = 0; start < 8; start += 2) {
+            buildAndCommitVectorIndex(
+                    table,
+                    new float[][] {vectors[start], vectors[start + 1]},
+                    new Range(start, start + 1));
+        }
+
+        VectorScan.Plan plan =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(3)
+                        .withVectorColumn(VECTOR_FIELD)
+                        .newVectorScan()
+                        .scan();
+        
assertThat(plan.splits().stream().filter(IndexVectorSearchSplit.class::isInstance))
+                .hasSize(4);
+        
assertThat(plan.splits().stream().filter(RawVectorSearchSplit.class::isInstance))
+                .hasSize(1);
+
+        List<String> rows =
+                search("DISTRIBUTED_MIXED_T", "0.0,0.0", 3, "id", 
null).stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+
+        assertThat(rows).containsExactly("{\"id\":\"8\"}", "{\"id\":\"9\"}", 
"{\"id\":\"10\"}");
+    }
+
+    @Test
+    public void testDistributedMixedIndexedAndRawVectorSearchWithFilter() 
throws Exception {
+        createVectorTable(
+                "DISTRIBUTED_FILTERED_MIXED_T",
+                "'vector-search.distribute.enabled' = 'true', "
+                        + "'global-index.thread-num' = '2', "
+                        + "'global-index.search-mode' = 'full'");
+        FileStoreTable table = paimonTable("DISTRIBUTED_FILTERED_MIXED_T");
+        float[][] vectors = {
+            {0.0f, 0.0f},
+            {0.1f, 0.0f},
+            {0.2f, 0.0f},
+            {0.3f, 0.0f},
+            {0.4f, 0.0f},
+            {1.0f, 0.0f},
+            {4.0f, 0.0f},
+            {5.0f, 0.0f},
+            {0.5f, 0.0f},
+            {2.0f, 0.0f},
+            {0.05f, 0.0f},
+            {0.15f, 0.0f}
+        };
+        writeVectors(table, vectors);
+        for (int start = 0; start < 8; start += 2) {
+            buildAndCommitMultiFieldVectorIndex(
+                    table,
+                    new float[][] {vectors[start], vectors[start + 1]},
+                    new Range(start, start + 1));
+        }
+
+        PredicateBuilder predicateBuilder = new 
PredicateBuilder(table.rowType());
+        Predicate filter =
+                PredicateBuilder.and(
+                        predicateBuilder.greaterOrEqual(0, 5), 
predicateBuilder.lessThan(0, 10));
+        VectorScan.Plan plan =
+                table.newVectorSearchBuilder()
+                        .withVector(new float[] {0.0f, 0.0f})
+                        .withLimit(3)
+                        .withVectorColumn(VECTOR_FIELD)
+                        .withFilter(filter)
+                        .newVectorScan()
+                        .scan();
+        List<IndexVectorSearchSplit> indexSplits =
+                plan.splits().stream()
+                        .filter(IndexVectorSearchSplit.class::isInstance)
+                        .map(IndexVectorSearchSplit.class::cast)
+                        .collect(Collectors.toList());
+        assertThat(indexSplits).hasSize(4);
+        assertThat(indexSplits)
+                .allSatisfy(
+                        split -> {
+                            assertThat(split.vectorIndexFiles()).hasSize(2);
+                            assertThat(split.scalarIndexFiles())
+                                    
.containsExactlyElementsOf(split.vectorIndexFiles());
+                        });
+        List<RawVectorSearchSplit> rawSplits =
+                plan.splits().stream()
+                        .filter(RawVectorSearchSplit.class::isInstance)
+                        .map(RawVectorSearchSplit.class::cast)
+                        .collect(Collectors.toList());
+        assertThat(rawSplits).hasSize(1);
+        assertThat(rawSplits.get(0).rowRanges()).containsExactly(new Range(8, 
11));
+        assertThat(rawSplits.get(0).scalarIndexFiles()).isEmpty();
+
+        List<String> rows =
+                sql(
+                                "CALL sys.vector_search("
+                                        + "`table` => 
'default.DISTRIBUTED_FILTERED_MIXED_T', "
+                                        + "vector_column => 'vec', "
+                                        + "query_vector => '0.0,0.0', "
+                                        + "top_k => 3, "
+                                        + "projection => 'id', "
+                                        + "`where` => 'id >= 5 AND id < 10')")
+                        .stream()
+                        .map(row -> row.getField(0).toString())
+                        .collect(Collectors.toList());
+
+        assertThat(rows).containsExactly("{\"id\":\"8\"}", "{\"id\":\"5\"}", 
"{\"id\":\"9\"}");
+    }
+
+    @Test
+    public void testVectorSearchWithPartitions() throws Exception {
+        createPartitionedVectorTable("PARTITIONED_T");
+        FileStoreTable table = paimonTable("PARTITIONED_T");
+        writePartitionedVectors(table);
+
+        List<Row> result = searchWithFilters("PARTITIONED_T", 1, null, "pt=b");
+
+        assertThat(result)
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"2\"}");
+
+        assertThat(searchWithFilters("PARTITIONED_T", 1, "pt = 'a'", 
"pt=b")).isEmpty();
+        assertThat(searchWithFilters("PARTITIONED_T", 1, "pt = 'b'", "pt=b"))
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"2\"}");
+    }
+
+    @Test
+    public void testVectorSearchRejectsInvalidPartitions() {
+        createPartitionedVectorTable("INVALID_PARTITIONS_T");
+
+        assertThat(searchWithFilters("INVALID_PARTITIONS_T", 1, null, "   
")).isEmpty();
+        assertThat(searchWithFilters("INVALID_PARTITIONS_T", 1, null, 
"pt=")).isEmpty();
+        String separator = ";";
+        for (String partitions :
+                Arrays.asList(separator + "pt=a", "pt=a" + separator + 
separator + "pt=b")) {
+            assertThatThrownBy(() -> searchWithFilters("INVALID_PARTITIONS_T", 
1, null, partitions))
+                    .hasStackTraceContaining("must exactly match partition 
keys");
+        }
+        for (String partitions :
+                Arrays.asList(separator, separator + separator, "pt=a" + 
separator)) {
+            assertThat(searchWithFilters("INVALID_PARTITIONS_T", 1, null, 
partitions)).isEmpty();
+        }
+        assertThat(searchWithFilters("INVALID_PARTITIONS_T", 1, null, 
"pt=a,pt=b")).isEmpty();
+    }
+
+    @Test
+    public void testFirstRowPrimaryKeyVectorSearchWhereBoundary() {
+        createFirstRowPartitionedPrimaryKeyVectorTable("FIRST_ROW_PK_T");
+        sql(
+                "INSERT INTO FIRST_ROW_PK_T VALUES "
+                        + "(1, 'a', ARRAY[CAST(1.0 AS FLOAT), CAST(0.0 AS 
FLOAT)]), "
+                        + "(2, 'b', ARRAY[CAST(2.0 AS FLOAT), CAST(0.0 AS 
FLOAT)]), "
+                        + "(3, 'b', ARRAY[CAST(3.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+
+        assertThat(searchWithFilters("FIRST_ROW_PK_T", 1, "pt = 'b'", null))
+                .extracting(row -> row.getField(0).toString())
+                .containsExactly("{\"id\":\"2\"}");
+        assertThatThrownBy(() -> searchWithFilters("FIRST_ROW_PK_T", 1, "id >= 
2", null))
+                .hasStackTraceContaining(
+                        "Primary-key vector where filter on non-partition 
columns requires")
+                .hasStackTraceContaining("deletion-vectors.enabled = true");
+    }
+
+    @Test
+    public void testVectorSearchValidation() {
+        createVectorTable("VALIDATION_T");
+        createVectorTable("QUERY_AUTH_T", "'query-auth.enabled' = 'true'");
+        createVectorTable(
+                "DISTRIBUTED_QUERY_AUTH_T",
+                "'query-auth.enabled' = 'true', " + 
"'vector-search.distribute.enabled' = 'true'");
+
+        assertThatThrownBy(() -> search("VALIDATION_T", "0.0,0.0", 0, "id", 
null))
+                .hasStackTraceContaining("top_k must be positive");
+        assertThatThrownBy(() -> search("VALIDATION_T", "NaN,0.0", 1, "id", 
null))
+                .hasStackTraceContaining("query_vector values must be finite");
+        assertThatThrownBy(() -> search("VALIDATION_T", "0.0,0.0", 1, 
"missing", null))
+                .hasStackTraceContaining("Unknown projection column");
+        assertThatThrownBy(() -> search("VALIDATION_T", "0.0,0.0", 1, "id,id", 
null))
+                .hasStackTraceContaining("Duplicate projection column");
+        assertThatThrownBy(
+                        () ->
+                                search(
+                                        "VALIDATION_T",
+                                        "0.0,0.0",
+                                        1,
+                                        "id",
+                                        "query-auth.enabled=false"))
+                .hasStackTraceContaining("Option 'query-auth.enabled' is not 
allowed");
+        assertThatThrownBy(() -> search("QUERY_AUTH_T", "0.0,0.0", 1, "id", 
null))
+                .hasStackTraceContaining(
+                        "Vector search does not support tables with query auth 
enabled");
+        assertThatThrownBy(() -> search("DISTRIBUTED_QUERY_AUTH_T", "0.0,0.0", 
1, "id", null))
+                .hasStackTraceContaining(
+                        "Vector search does not support tables with query auth 
enabled");
+    }
+
     private void createVectorTable(String tableName) {
         createVectorTable(tableName, "");
     }
@@ -325,13 +815,18 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
     }
 
     private void createPrimaryKeyVectorTable(String tableName) {
+        createPrimaryKeyVectorTable(tableName, 2, "");
+    }
+
+    private void createPrimaryKeyVectorTable(String tableName, int bucket, 
String extraOptions) {
+        String formattedExtraOptions = extraOptions.isEmpty() ? "" : ", " + 
extraOptions;
         sql(
                 "CREATE TABLE %s ("
                         + "id INT, "
                         + "vec ARRAY<FLOAT>, "
                         + "PRIMARY KEY (id) NOT ENFORCED"
                         + ") WITH ("
-                        + "'bucket' = '2', "
+                        + "'bucket' = '%d', "
                         + "'file.format' = 'json', "
                         + "'file.compression' = 'none', "
                         + "'deletion-vectors.enabled' = 'true', "
@@ -342,10 +837,87 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
                         + "'fields.vec.pk-vector.distance.metric' = 'l2', "
                         + "'test.vector.dimension' = '%d', "
                         + "'test.vector.metric' = 'l2'"
+                        + "%s"
+                        + ")",
+                tableName,
+                bucket,
+                DIMENSION,
+                TestVectorGlobalIndexerFactory.IDENTIFIER,
+                DIMENSION,
+                formattedExtraOptions);
+    }
+
+    private void createPrimaryKeyVectorTableWithPayload(
+            String tableName, int bucket, String extraOptions) {
+        String formattedExtraOptions = extraOptions.isEmpty() ? "" : ", " + 
extraOptions;
+        sql(
+                "CREATE TABLE %s ("
+                        + "id INT, "
+                        + "payload STRING, "
+                        + "vec ARRAY<FLOAT>, "
+                        + "PRIMARY KEY (id) NOT ENFORCED"
+                        + ") WITH ("
+                        + "'bucket' = '%d', "
+                        + "'file.format' = 'json', "
+                        + "'file.compression' = 'none', "
+                        + "'deletion-vectors.enabled' = 'true', "
+                        + "'vector-field' = 'vec', "
+                        + "'field.vec.vector-dim' = '%d', "
+                        + "'pk-vector.index.columns' = 'vec', "
+                        + "'fields.vec.pk-vector.index.type' = '%s', "
+                        + "'fields.vec.pk-vector.distance.metric' = 'l2', "
+                        + "'test.vector.dimension' = '%d', "
+                        + "'test.vector.metric' = 'l2'"
+                        + "%s"
+                        + ")",
+                tableName,
+                bucket,
+                DIMENSION,
+                TestVectorGlobalIndexerFactory.IDENTIFIER,
+                DIMENSION,
+                formattedExtraOptions);
+    }
+
+    private void createFirstRowPartitionedPrimaryKeyVectorTable(String 
tableName) {
+        sql(
+                "CREATE TABLE %s ("
+                        + "id INT, "
+                        + "pt STRING, "
+                        + "vec ARRAY<FLOAT>, "
+                        + "PRIMARY KEY (id, pt) NOT ENFORCED"
+                        + ") PARTITIONED BY (pt) WITH ("
+                        + "'bucket' = '2', "
+                        + "'merge-engine' = 'first-row', "
+                        + "'file.format' = 'json', "
+                        + "'file.compression' = 'none', "
+                        + "'vector-field' = 'vec', "
+                        + "'field.vec.vector-dim' = '%d', "
+                        + "'pk-vector.index.columns' = 'vec', "
+                        + "'fields.vec.pk-vector.index.type' = '%s', "
+                        + "'fields.vec.pk-vector.distance.metric' = 'l2', "
+                        + "'test.vector.dimension' = '%d', "
+                        + "'test.vector.metric' = 'l2'"
                         + ")",
                 tableName, DIMENSION, 
TestVectorGlobalIndexerFactory.IDENTIFIER, DIMENSION);
     }
 
+    private void createPartitionedVectorTable(String tableName) {
+        sql(
+                "CREATE TABLE %s ("
+                        + "id INT, "
+                        + "pt STRING, "
+                        + "vec ARRAY<FLOAT>"
+                        + ") PARTITIONED BY (pt) WITH ("
+                        + "'bucket' = '-1', "
+                        + "'row-tracking.enabled' = 'true', "
+                        + "'data-evolution.enabled' = 'true', "
+                        + "'global-index.search-mode' = 'full', "
+                        + "'test.vector.dimension' = '%d', "
+                        + "'test.vector.metric' = 'l2'"
+                        + ")",
+                tableName, DIMENSION);
+    }
+
     private void createPostponePrimaryKeyVectorTable(String tableName) {
         sql(
                 "CREATE TABLE %s ("
@@ -382,6 +954,35 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
                 tableName, topK, projection);
     }
 
+    private List<Row> search(
+            String tableName, String queryVector, int topK, String projection, 
String options) {
+        String optionsArgument = options == null ? "" : ", options => '" + 
options + "'";
+        return sql(
+                "CALL sys.vector_search("
+                        + "`table` => 'default.%s', "
+                        + "vector_column => 'vec', "
+                        + "query_vector => '%s', "
+                        + "top_k => %d, "
+                        + "projection => '%s'%s)",
+                tableName, queryVector, topK, projection, optionsArgument);
+    }
+
+    private List<Row> searchWithFilters(
+            String tableName, int topK, String where, String partitions) {
+        String whereArgument =
+                where == null ? "" : ", `where` => '" + where.replace("'", 
"''") + "'";
+        String partitionsArgument =
+                partitions == null ? "" : ", partitions => '" + 
partitions.replace("'", "''") + "'";
+        return sql(
+                "CALL sys.vector_search("
+                        + "`table` => 'default.%s', "
+                        + "vector_column => 'vec', "
+                        + "query_vector => '0.0,0.0', "
+                        + "top_k => %d, "
+                        + "projection => 'id'%s%s)",
+                tableName, topK, whereArgument, partitionsArgument);
+    }
+
     private void createPartialUpdatePrimaryKeyVectorTable(String tableName) {
         sql(
                 "CREATE TABLE %s ("
@@ -418,8 +1019,36 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
         }
     }
 
+    private void writePartitionedVectors(FileStoreTable table) throws 
Exception {
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            write.write(
+                    GenericRow.of(
+                            1,
+                            BinaryString.fromString("a"),
+                            new GenericArray(new float[] {0.0f, 0.0f})));
+            write.write(
+                    GenericRow.of(
+                            2,
+                            BinaryString.fromString("b"),
+                            new GenericArray(new float[] {1.0f, 0.0f})));
+            write.write(
+                    GenericRow.of(
+                            3,
+                            BinaryString.fromString("b"),
+                            new GenericArray(new float[] {2.0f, 0.0f})));
+            commit.commit(write.prepareCommit());
+        }
+    }
+
     private void buildAndCommitVectorIndex(FileStoreTable table, float[][] 
vectors)
             throws Exception {
+        buildAndCommitVectorIndex(table, vectors, new Range(0, vectors.length 
- 1));
+    }
+
+    private void buildAndCommitVectorIndex(FileStoreTable table, float[][] 
vectors, Range rowRange)
+            throws Exception {
         Options options = table.coreOptions().toConfiguration();
         DataField vectorField = table.rowType().getField(VECTOR_FIELD);
 
@@ -435,7 +1064,6 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
         }
         List<ResultEntry> entries = writer.finish();
 
-        Range rowRange = new Range(0, vectors.length - 1);
         List<IndexFileMeta> indexFiles =
                 GlobalIndexBuilderUtils.toIndexFileMetas(
                         table.fileIO(),
@@ -458,4 +1086,46 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
             commit.commit(Collections.singletonList(message));
         }
     }
+
+    private void buildAndCommitMultiFieldVectorIndex(
+            FileStoreTable table, float[][] vectors, Range rowRange) throws 
Exception {
+        Options options = table.coreOptions().toConfiguration();
+        DataField vectorField = table.rowType().getField(VECTOR_FIELD);
+        DataField idField = table.rowType().getField("id");
+
+        GlobalIndexMultiColumnWriter writer =
+                (GlobalIndexMultiColumnWriter)
+                        GlobalIndexBuilderUtils.createIndexWriter(
+                                table,
+                                TestVectorGlobalIndexerFactory.IDENTIFIER,
+                                vectorField,
+                                Collections.singletonList(idField),
+                                options);
+        for (int i = 0; i < vectors.length; i++) {
+            writer.write(i, GenericRow.of(new GenericArray(vectors[i]), (int) 
(rowRange.from + i)));
+        }
+        List<ResultEntry> entries = writer.finish();
+
+        List<IndexFileMeta> indexFiles =
+                GlobalIndexBuilderUtils.toIndexFileMetas(
+                        table.fileIO(),
+                        table.store().pathFactory().globalIndexFileFactory(),
+                        table.coreOptions(),
+                        rowRange,
+                        Arrays.asList(vectorField, idField),
+                        TestVectorGlobalIndexerFactory.IDENTIFIER,
+                        entries);
+
+        DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
+        CommitMessage message =
+                new CommitMessageImpl(
+                        BinaryRow.EMPTY_ROW,
+                        0,
+                        null,
+                        dataIncrement,
+                        CompactIncrement.emptyIncrement());
+        try (BatchTableCommit commit = 
table.newBatchWriteBuilder().newCommit()) {
+            commit.commit(Collections.singletonList(message));
+        }
+    }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/vectorsearch/FlinkDataEvolutionVectorReadTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/vectorsearch/FlinkDataEvolutionVectorReadTest.java
new file mode 100644
index 0000000000..9bd45a7776
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/vectorsearch/FlinkDataEvolutionVectorReadTest.java
@@ -0,0 +1,555 @@
+/*
+ * 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.flink.vectorsearch;
+
+import org.apache.paimon.flink.utils.StreamExecutionEnvironmentUtils;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.GlobalIndexResultSerializer;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
+import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.IndexVectorSearchSplit;
+import org.apache.paimon.table.source.RawVectorSearchSplit;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.table.source.VectorSearchSplit;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.typeinfo.PrimitiveArrayTypeInfo;
+import org.apache.flink.api.java.tuple.Tuple2;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link FlinkDataEvolutionVectorRead}. */
+public class FlinkDataEvolutionVectorReadTest {
+
+    @Test
+    public void testExecutesSerializedRawSearchTasks() {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setParallelism(2);
+        ExecutingFlinkVectorRead read = new ExecutingFlinkVectorRead(env);
+
+        ScoredGlobalIndexResult result =
+                
read.readRawSplitsInFlink(Collections.singletonList(rawSplit(0, 63)), null, 
null);
+        ScoredGlobalIndexResult secondResult =
+                read.readRawSplitsInFlink(
+                        Collections.singletonList(rawSplit(100, 103)), null, 
null);
+
+        assertThat(result.results().getLongCardinality()).isEqualTo(64);
+        assertThat(secondResult.results().getLongCardinality()).isEqualTo(4);
+    }
+
+    @Test
+    public void testRawSearchUsesFlinkPath() {
+        TestingFlinkVectorRead read = new TestingFlinkVectorRead();
+        RawVectorSearchSplit rawSplit =
+                new RawVectorSearchSplit(
+                        Collections.singletonList(new Range(42, 42)),
+                        Collections.emptyList(),
+                        null);
+        VectorScan.Plan plan = () -> Collections.singletonList(rawSplit);
+
+        GlobalIndexResult result = read.read(plan);
+
+        assertThat(read.rawFlinkPathUsed).isTrue();
+        assertThat(result.results().contains(42L)).isTrue();
+    }
+
+    @Test
+    public void testRawSearchSplitsRangesAcrossFlinkTasks() {
+        RecordingFlinkVectorRead read = new RecordingFlinkVectorRead();
+        RawVectorSearchSplit rawSplit =
+                new RawVectorSearchSplit(
+                        Collections.singletonList(new Range(0, 63)), 
Collections.emptyList(), null);
+
+        ScoredGlobalIndexResult result =
+                read.readRawSplitsInFlink(Collections.singletonList(rawSplit), 
null, null);
+
+        assertThat(read.rawSearchRanges).containsExactly(new Range(0, 31), new 
Range(32, 63));
+        assertThat(read.flinkParallelism).isEqualTo(2);
+        assertThat(result.results().getLongCardinality()).isEqualTo(64);
+    }
+
+    @Test
+    public void testRawSearchScopesPreFilterToTaskRanges() {
+        PreFilterRecordingFlinkVectorRead read = new 
PreFilterRecordingFlinkVectorRead();
+        RoaringNavigableMap64 preFilter = new RoaringNavigableMap64();
+        preFilter.addRange(new Range(0, 29));
+        preFilter.add(35L);
+        preFilter.add(39L);
+
+        read.readRawSplitsInFlink(Collections.singletonList(rawSplit(0, 89)), 
null, preFilter);
+
+        assertThat(read.rawSearchRanges)
+                .containsExactly(new Range(0, 29), new Range(30, 59), new 
Range(60, 89));
+        assertThat(read.rawSearchPreFilters)
+                .containsExactly(null, Arrays.asList(35L, 39L), 
Collections.emptyList());
+    }
+
+    @Test
+    public void testDistributedIndexRefinesAfterGlobalMerge() {
+        DistributedRefineFlinkVectorRead read = new 
DistributedRefineFlinkVectorRead();
+
+        ScoredGlobalIndexResult result =
+                read.readIndexSplitsInFlink(indexSplits("test-vector-ann", 4), 
new L2Indexer());
+
+        assertThat(read.flinkParallelism).isEqualTo(2);
+        assertThat(read.rawSearchCandidateRows).containsExactly(0L, 2L);
+        assertThat(result.results().getLongCardinality()).isEqualTo(1);
+        assertThat(result.results().contains(0L)).isTrue();
+    }
+
+    @Test
+    public void testMixedSearchUsesCombinedFlinkJob() {
+        MixedFlinkVectorRead read = new MixedFlinkVectorRead();
+        List<VectorSearchSplit> splits = new 
ArrayList<>(indexSplits("test-vector-ann", 4));
+        splits.add(rawSplit(42, 105));
+
+        GlobalIndexResult result = read.read(() -> splits);
+
+        assertThat(read.combinedExecution).isTrue();
+        assertThat(result.results().contains(0L)).isTrue();
+        assertThat(result.results().contains(42L)).isTrue();
+    }
+
+    @Test
+    public void testMixedResultTypeKeepsSearchParallelism() {
+        StreamExecutionEnvironment env = environment();
+        env.setParallelism(8);
+        DataStream<byte[]> searchResults =
+                StreamExecutionEnvironmentUtils.fromData(
+                                env,
+                                Collections.singletonList(new byte[] {1}),
+                                
PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                        .map((MapFunction<byte[], byte[]>) bytes -> bytes)
+                        
.returns(PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO)
+                        .setParallelism(2);
+
+        DataStream<Tuple2<Byte, byte[]>> typedResults =
+                FlinkDataEvolutionVectorRead.addResultType(
+                        searchResults, (byte) 0, "Add Vector Search Result 
Type");
+
+        assertThat(typedResults.getParallelism()).isEqualTo(2);
+    }
+
+    @Test
+    public void testFlinkJobNameContainsFullTableName() {
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.fullName()).thenReturn("default.T");
+        FlinkDataEvolutionVectorRead read =
+                new FlinkDataEvolutionVectorRead(
+                        table,
+                        null,
+                        null,
+                        1,
+                        new DataField(0, "vec", new 
ArrayType(DataTypes.FLOAT())),
+                        new float[] {1.0f},
+                        null,
+                        environment());
+
+        assertThat(read.flinkJobName("Vector Index Search"))
+                .isEqualTo("Vector Search - Vector Index Search : default.T");
+    }
+
+    private static StreamExecutionEnvironment environment() {
+        return StreamExecutionEnvironment.getExecutionEnvironment();
+    }
+
+    private static RawVectorSearchSplit rawSplit(long from, long to) {
+        return new RawVectorSearchSplit(
+                Collections.singletonList(new Range(from, to)), 
Collections.emptyList(), null);
+    }
+
+    private static List<IndexVectorSearchSplit> indexSplits(String indexType, 
int count) {
+        List<IndexVectorSearchSplit> splits = new ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            GlobalIndexMeta globalIndexMeta = new GlobalIndexMeta(i, i, 0, 
null, new byte[0]);
+            IndexFileMeta indexFile =
+                    new IndexFileMeta(indexType, "index-" + i, 1L, 1L, 
globalIndexMeta, null);
+            splits.add(
+                    new IndexVectorSearchSplit(
+                            i, i, Collections.singletonList(indexFile), 
Collections.emptyList()));
+        }
+        return splits;
+    }
+
+    private static ScoredGlobalIndexResult scoredResult(long rowId, float 
score) {
+        RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+        rows.add(rowId);
+        return ScoredGlobalIndexResult.create(rows, candidate -> score);
+    }
+
+    private static class TestingFlinkVectorRead extends 
FlinkDataEvolutionVectorRead {
+
+        private boolean rawFlinkPathUsed;
+
+        private TestingFlinkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    10,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {1.0f},
+                    null,
+                    environment());
+        }
+
+        @Override
+        protected GlobalIndexResult readSplits(List<? extends 
VectorSearchSplit> splits) {
+            throw new AssertionError("Raw search should not fall back to local 
vector read.");
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readIndexSplitsInFlink(
+                List<IndexVectorSearchSplit> splits, @Nullable GlobalIndexer 
globalIndexer) {
+            throw new AssertionError("Index search is not part of this test.");
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawSplitsInFlink(
+                List<RawVectorSearchSplit> splits,
+                @Nullable GlobalIndexer globalIndexer,
+                @Nullable RoaringNavigableMap64 preFilter) {
+            rawFlinkPathUsed = true;
+            assertThat(splits).hasSize(1);
+            assertThat(globalIndexer).isNull();
+            assertThat(preFilter).isNull();
+
+            RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+            rows.add(42L);
+            return ScoredGlobalIndexResult.create(rows, rowId -> 1.0f);
+        }
+    }
+
+    private static class DistributedRefineFlinkVectorRead extends 
FlinkDataEvolutionVectorRead {
+
+        private int flinkParallelism;
+        private List<Long> rawSearchCandidateRows = Collections.emptyList();
+
+        private DistributedRefineFlinkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    1,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {0.0f},
+                    Collections.singletonMap("refine_factor", "2"),
+                    environment());
+        }
+
+        @Override
+        protected int flinkParallelism() {
+            return 2;
+        }
+
+        @Override
+        protected List<byte[]> executeIndexSearchGroups(
+                List<List<SerializedSplit>> groups, int searchLimit, int 
parallelism) {
+            flinkParallelism = parallelism;
+            assertThat(groups).hasSize(2);
+            assertThat(searchLimit).isEqualTo(2);
+            try {
+                GlobalIndexResultSerializer serializer = new 
GlobalIndexResultSerializer();
+                return Arrays.asList(
+                        serializer.serialize(scoredResult(2L, 100.0f)),
+                        serializer.serialize(scoredResult(0L, 1.0f)));
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawRefineSearch(
+                RoaringNavigableMap64 candidates,
+                @Nullable GlobalIndexer globalIndexer,
+                float[] queryVector) {
+            assertThat(globalIndexer).isInstanceOf(VectorGlobalIndexer.class);
+            assertThat(((VectorGlobalIndexer) 
globalIndexer).metric()).isEqualTo("l2");
+            assertThat(queryVector).containsExactly(0.0f);
+            rawSearchCandidateRows = new ArrayList<>();
+            for (long rowId : candidates) {
+                rawSearchCandidateRows.add(rowId);
+            }
+
+            RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+            for (long rowId : candidates) {
+                rows.add(rowId);
+            }
+            return ScoredGlobalIndexResult.create(
+                            rows, rowId -> rowId == 0L ? 1.0f : 1.0f / (1.0f + 
rowId * rowId))
+                    .topK(1);
+        }
+    }
+
+    private static class MixedFlinkVectorRead extends 
FlinkDataEvolutionVectorRead {
+
+        private boolean combinedExecution;
+
+        private MixedFlinkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    2,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {0.0f},
+                    Collections.singletonMap("test.vector.metric", "l2"),
+                    environment());
+        }
+
+        @Override
+        protected GlobalIndexer 
createGlobalIndexer(List<IndexVectorSearchSplit> splits) {
+            return new L2Indexer();
+        }
+
+        @Override
+        protected int flinkParallelism() {
+            return 2;
+        }
+
+        @Override
+        protected List<Tuple2<Byte, byte[]>> executeIndexAndRawSearchGroups(
+                List<List<SerializedSplit>> indexGroups,
+                int searchLimit,
+                List<List<SerializedSplit>> rawGroups,
+                String rawMetric,
+                int parallelism) {
+            combinedExecution = true;
+            assertThat(indexGroups).hasSize(2);
+            assertThat(rawGroups).hasSize(2);
+            assertThat(searchLimit).isEqualTo(2);
+            assertThat(rawMetric).isEqualTo("l2");
+            assertThat(parallelism).isEqualTo(2);
+            try {
+                GlobalIndexResultSerializer serializer = new 
GlobalIndexResultSerializer();
+                return Arrays.asList(
+                        Tuple2.of((byte) 0, 
serializer.serialize(scoredResult(0L, 1.0f))),
+                        Tuple2.of((byte) 1, 
serializer.serialize(scoredResult(42L, 0.9f))));
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        @Override
+        protected List<byte[]> executeIndexSearchGroups(
+                List<List<SerializedSplit>> groups, int searchLimit, int 
parallelism) {
+            throw new AssertionError("Mixed search must not submit an 
indexed-only Flink job.");
+        }
+
+        @Override
+        protected List<byte[]> executeRawSearchGroups(
+                List<List<SerializedSplit>> groups, String metric, int 
parallelism) {
+            throw new AssertionError("Mixed search must not submit a raw-only 
Flink job.");
+        }
+    }
+
+    private static class RecordingFlinkVectorRead extends 
FlinkDataEvolutionVectorRead {
+
+        private final AtomicInteger nextTask = new AtomicInteger();
+        private final List<Range> rawSearchRanges = 
Collections.synchronizedList(new ArrayList<>());
+        private int flinkParallelism;
+
+        private RecordingFlinkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    100,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {1.0f},
+                    Collections.singletonMap("test.vector.metric", "l2"),
+                    environment());
+        }
+
+        @Override
+        protected int flinkParallelism() {
+            return 2;
+        }
+
+        @Override
+        protected List<byte[]> executeRawSearchGroups(
+                List<List<SerializedSplit>> groups, String metric, int 
parallelism) {
+            flinkParallelism = parallelism;
+            return groups.stream()
+                    .map(group -> executeRawSearchGroup(group, metric))
+                    .collect(Collectors.toList());
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawSearch(
+                List<Range> rawRowRanges,
+                @Nullable RoaringNavigableMap64 preFilter,
+                String metric,
+                float[] queryVector) {
+            assertThat(preFilter).isNull();
+            assertThat(metric).isEqualTo("l2");
+            assertThat(queryVector).containsExactly(1.0f);
+            rawSearchRanges.addAll(rawRowRanges);
+
+            RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+            int scoreBase = nextTask.getAndIncrement();
+            for (Range range : rawRowRanges) {
+                rows.addRange(range);
+            }
+            return ScoredGlobalIndexResult.create(rows, rowId -> scoreBase + 
(float) rowId);
+        }
+    }
+
+    private static class PreFilterRecordingFlinkVectorRead extends 
FlinkDataEvolutionVectorRead {
+
+        private final List<Range> rawSearchRanges = new ArrayList<>();
+        private final List<List<Long>> rawSearchPreFilters = new ArrayList<>();
+
+        private PreFilterRecordingFlinkVectorRead() {
+            super(
+                    null,
+                    null,
+                    null,
+                    100,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {1.0f},
+                    Collections.singletonMap("test.vector.metric", "l2"),
+                    environment());
+        }
+
+        @Override
+        protected int flinkParallelism() {
+            return 3;
+        }
+
+        @Override
+        protected List<byte[]> executeRawSearchGroups(
+                List<List<SerializedSplit>> groups, String metric, int 
parallelism) {
+            return groups.stream()
+                    .map(group -> executeRawSearchGroup(group, metric))
+                    .collect(Collectors.toList());
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawSearch(
+                List<Range> rawRowRanges,
+                @Nullable RoaringNavigableMap64 preFilter,
+                String metric,
+                float[] queryVector) {
+            assertThat(rawRowRanges).hasSize(1);
+            assertThat(metric).isEqualTo("l2");
+            assertThat(queryVector).containsExactly(1.0f);
+            rawSearchRanges.add(rawRowRanges.get(0));
+            if (preFilter == null) {
+                rawSearchPreFilters.add(null);
+            } else {
+                List<Long> rowIds = new ArrayList<>();
+                for (long rowId : preFilter) {
+                    rowIds.add(rowId);
+                }
+                rawSearchPreFilters.add(rowIds);
+            }
+            return ScoredGlobalIndexResult.createEmpty();
+        }
+    }
+
+    private static class ExecutingFlinkVectorRead extends 
FlinkDataEvolutionVectorRead {
+
+        private ExecutingFlinkVectorRead(StreamExecutionEnvironment env) {
+            super(
+                    null,
+                    null,
+                    null,
+                    100,
+                    new DataField(0, "vec", new ArrayType(DataTypes.FLOAT())),
+                    new float[] {1.0f},
+                    Collections.singletonMap("test.vector.metric", "l2"),
+                    env);
+        }
+
+        @Override
+        protected int flinkParallelism() {
+            return 2;
+        }
+
+        @Override
+        protected String flinkJobName(String operatorName) {
+            return "Vector Search Test - " + operatorName;
+        }
+
+        @Override
+        protected ScoredGlobalIndexResult readRawSearch(
+                List<Range> rawRowRanges,
+                @Nullable RoaringNavigableMap64 preFilter,
+                String metric,
+                float[] queryVector) {
+            RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+            for (Range range : rawRowRanges) {
+                rows.addRange(range);
+            }
+            return ScoredGlobalIndexResult.create(rows, rowId -> (float) 
rowId);
+        }
+    }
+
+    private static class L2Indexer implements VectorGlobalIndexer {
+
+        @Override
+        public GlobalIndexWriter createWriter(GlobalIndexFileWriter 
fileWriter) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public GlobalIndexReader createReader(
+                GlobalIndexFileReader fileReader,
+                List<GlobalIndexIOMeta> files,
+                ExecutorService executor) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String metric() {
+            return "l2";
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/vectorsearch/FlinkPrimaryKeyVectorReadTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/vectorsearch/FlinkPrimaryKeyVectorReadTest.java
new file mode 100644
index 0000000000..e09dfdaec3
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/vectorsearch/FlinkPrimaryKeyVectorReadTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.flink.vectorsearch;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.BucketVectorSearchSplit;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.PrimaryKeyVectorRead;
+import org.apache.paimon.table.source.PrimaryKeyVectorScan;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.FloatType;
+import org.apache.paimon.types.VectorType;
+
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link FlinkPrimaryKeyVectorRead}. */
+public class FlinkPrimaryKeyVectorReadTest {
+
+    @Test
+    public void testDistributesSerializedBucketGroups() {
+        List<BucketVectorSearchSplit> splits = new ArrayList<>();
+        for (int bucket = 0; bucket < 4; bucket++) {
+            DataSplit dataSplit =
+                    DataSplit.builder()
+                            .withSnapshot(1L)
+                            .withPartition(BinaryRow.EMPTY_ROW)
+                            .withBucket(bucket)
+                            .withBucketPath("bucket-" + bucket)
+                            .withDataFiles(Collections.emptyList())
+                            .isStreaming(false)
+                            .rawConvertible(false)
+                            .build();
+            splits.add(new BucketVectorSearchSplit(dataSplit, 
Collections.emptyList()));
+        }
+
+        TestingFlinkPrimaryKeyVectorRead read = new 
TestingFlinkPrimaryKeyVectorRead(splits);
+        GlobalIndexResult result = read.read(() -> Collections.emptyList());
+
+        assertThat(result.results()).isEmpty();
+        assertThat(read.taskGroupSizes).containsExactly(2, 2);
+        assertThat(read.dispatchedBuckets).containsExactlyInAnyOrder(0, 1, 2, 
3);
+        assertThat(read.operatorParallelism).isEqualTo(2);
+        assertThat(read.createResultCalled).isTrue();
+    }
+
+    @Test
+    public void testFlinkJobNameContainsFullTableName() {
+        TestingFlinkPrimaryKeyVectorRead read =
+                new TestingFlinkPrimaryKeyVectorRead(Collections.emptyList());
+
+        assertThat(read.flinkJobName("Primary-Key Vector Search"))
+                .isEqualTo("Vector Search - Primary-Key Vector Search : 
default.PK_T");
+    }
+
+    private static FileStoreTable table() {
+        Map<String, String> options = new HashMap<>();
+        options.put("fields.vector.pk-vector.index.type", "test-vector-ann");
+        options.put("fields.vector.pk-vector.distance.metric", "l2");
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.coreOptions()).thenReturn(new CoreOptions(options));
+        when(table.options()).thenReturn(options);
+        when(table.fullName()).thenReturn("default.PK_T");
+        return table;
+    }
+
+    private static class TestingFlinkPrimaryKeyVectorRead extends 
FlinkPrimaryKeyVectorRead {
+
+        private final PrimaryKeyVectorScan.Plan primaryKeyPlan;
+        private final List<BucketVectorSearchSplit> splits;
+        private final List<Integer> taskGroupSizes = new ArrayList<>();
+        private final List<Integer> dispatchedBuckets = new ArrayList<>();
+        private int operatorParallelism;
+        private boolean createResultCalled;
+
+        private TestingFlinkPrimaryKeyVectorRead(List<BucketVectorSearchSplit> 
splits) {
+            super(
+                    table(),
+                    new DataField(1, "vector", new VectorType(2, new 
FloatType())),
+                    new float[] {0.0f, 0.0f},
+                    1,
+                    Collections.emptyMap(),
+                    null,
+                    mock(StreamExecutionEnvironment.class));
+            this.primaryKeyPlan = mock(PrimaryKeyVectorScan.Plan.class);
+            this.splits = splits;
+        }
+
+        @Override
+        protected PrimaryKeyVectorScan.Plan primaryKeyPlan(VectorScan.Plan 
plan) {
+            return primaryKeyPlan;
+        }
+
+        @Override
+        protected List<BucketVectorSearchSplit> 
bucketSplits(PrimaryKeyVectorScan.Plan plan) {
+            assertThat(plan).isSameAs(primaryKeyPlan);
+            return splits;
+        }
+
+        @Override
+        protected int flinkParallelism() {
+            return 2;
+        }
+
+        @Override
+        protected List<byte[]> executeBucketSearchGroups(
+                List<List<byte[]>> groups, int parallelism) {
+            operatorParallelism = parallelism;
+            
taskGroupSizes.addAll(groups.stream().map(List::size).collect(Collectors.toList()));
+            return 
groups.stream().map(this::executeBucketSearchGroup).collect(Collectors.toList());
+        }
+
+        @Override
+        protected SearchResult searchBuckets(List<BucketVectorSearchSplit> 
taskSplits) {
+            dispatchedBuckets.addAll(
+                    taskSplits.stream()
+                            .map(split -> split.dataSplit().bucket())
+                            .collect(Collectors.toList()));
+            return new SearchResult(Collections.emptyList(), 
Collections.emptyList());
+        }
+
+        @Override
+        protected GlobalIndexResult createResult(
+                PrimaryKeyVectorScan.Plan plan, 
PrimaryKeyVectorRead.SearchResult searchResult) {
+            assertThat(plan).isSameAs(primaryKeyPlan);
+            assertThat(searchResult.indexedCandidates()).isEmpty();
+            assertThat(searchResult.exactCandidates()).isEmpty();
+            createResultCalled = true;
+            return GlobalIndexResult.createEmpty();
+        }
+    }
+}

Reply via email to