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 b7df2b487e [core] Support batch primary-key vector search (#8666)
b7df2b487e is described below
commit b7df2b487edccb6f683165890e580c08332fe5b3
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 16 00:29:25 2026 +0800
[core] Support batch primary-key vector search (#8666)
---
.../index/pkvector/PkVectorAnnSegmentSearcher.java | 114 ++++++++++++-
.../index/pkvector/PkVectorExactSearcher.java | 79 ++++++---
.../pkvector/PrimaryKeyVectorBucketSearch.java | 161 ++++++++++++++----
.../table/source/BatchVectorSearchBuilderImpl.java | 17 ++
.../table/source/PrimaryKeyBatchVectorRead.java | 80 +++++++++
.../paimon/table/source/PrimaryKeyVectorRead.java | 59 ++++++-
.../index/pkvector/PkVectorAnnSegmentFileTest.java | 55 +++++++
.../index/pkvector/PkVectorExactSearcherTest.java | 26 +++
.../pkvector/PrimaryKeyVectorBucketSearchTest.java | 33 ++++
.../table/source/PrimaryKeyVectorSearchTest.java | 39 +++++
.../paimon/spark/execution/PaimonStrategy.scala | 179 ++++++++++++++++++++-
.../spark/sql/PrimaryKeyVectorSearchTest.scala | 59 +++++++
12 files changed, 837 insertions(+), 64 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
index 788ff56121..75274336a9 100644
---
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java
@@ -31,6 +31,7 @@ import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.BatchVectorSearch;
import org.apache.paimon.predicate.VectorSearch;
import org.apache.paimon.types.DataField;
import org.apache.paimon.utils.IOUtils;
@@ -177,7 +178,7 @@ public class PkVectorAnnSegmentSearcher {
"Vector segment %s has no source metadata.",
segment.fileName());
if (segment.rowCount() == 0) {
- return Collections.emptyList();
+ return CompletableFuture.completedFuture(Collections.emptyList());
}
GlobalIndexer indexer =
GlobalIndexer.create(segment.indexType(), vectorField,
indexOptions);
@@ -231,6 +232,117 @@ public class PkVectorAnnSegmentSearcher {
}
}
+ public List<List<PkVectorSearchResult>> searchBatch(
+ IndexFileMeta segment,
+ PrimaryKeyIndexSourceMeta sourceMeta,
+ float[][] queries,
+ int limit,
+ Map<String, DeletionVector> deletionVectors,
+ Set<String> activeSourceFiles,
+ Map<String, List<Range>> rowRangesByFile,
+ Map<String, String> searchOptions) {
+ return searchBatchAsync(
+ segment,
+ sourceMeta,
+ queries,
+ limit,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ searchOptions)
+ .join();
+ }
+
+ CompletableFuture<List<List<PkVectorSearchResult>>> searchBatchAsync(
+ IndexFileMeta segment,
+ PrimaryKeyIndexSourceMeta sourceMeta,
+ float[][] queries,
+ int limit,
+ Map<String, DeletionVector> deletionVectors,
+ Set<String> activeSourceFiles,
+ Map<String, List<Range>> rowRangesByFile,
+ Map<String, String> searchOptions) {
+ checkArgument(queries != null && queries.length > 0, "Query vectors
cannot be empty.");
+ checkArgument(limit > 0, "Vector search limit must be positive: %s.",
limit);
+ GlobalIndexMeta globalIndexMeta = segment.globalIndexMeta();
+ checkArgument(
+ globalIndexMeta != null && globalIndexMeta.sourceMeta() !=
null,
+ "Vector segment %s has no source metadata.",
+ segment.fileName());
+ if (segment.rowCount() == 0) {
+ List<List<PkVectorSearchResult>> results = new
ArrayList<>(queries.length);
+ for (int i = 0; i < queries.length; i++) {
+ results.add(Collections.emptyList());
+ }
+ return
CompletableFuture.completedFuture(Collections.unmodifiableList(results));
+ }
+ GlobalIndexer indexer =
+ GlobalIndexer.create(segment.indexType(), vectorField,
indexOptions);
+ checkArgument(
+ indexer instanceof VectorGlobalIndexer,
+ "Index algorithm %s does not implement VectorGlobalIndexer.",
+ segment.indexType());
+ String readerMetric =
+ VectorSearchMetric.normalize(((VectorGlobalIndexer)
indexer).metric());
+ checkArgument(
+ metric.equals(readerMetric),
+ "ANN segment metric %s does not match index reader metric %s.",
+ metric,
+ readerMetric);
+
+ GlobalIndexIOMeta ioMeta =
+ new GlobalIndexIOMeta(
+ annSegmentFile.path(segment),
+ segment.fileSize(),
+ globalIndexMeta.indexMeta());
+ GlobalIndexReader reader =
+ indexer.createReader(
+ meta -> fileIO.newInputStream(meta.filePath()),
+ Collections.singletonList(ioMeta),
+ executor);
+ try {
+ BatchVectorSearch search =
+ new BatchVectorSearch(queries, limit, vectorField.name(),
searchOptions);
+ RoaringNavigableMap64 liveRows =
+ liveRowPositions(
+ sourceMeta.sourceFiles(),
+ activeSourceFiles,
+ deletionVectors,
+ rowRangesByFile);
+ if (liveRows != null) {
+ search.withIncludeRowIds(liveRows);
+ }
+ return reader.visitBatchVectorSearch(search)
+ .whenComplete((ignored, error) ->
IOUtils.closeQuietly(reader))
+ .thenApply(
+ scoredResults -> {
+ checkArgument(
+ scoredResults.size() == queries.length,
+ "ANN segment %s returned %s batch
results for %s queries.",
+ segment.fileName(),
+ scoredResults.size(),
+ queries.length);
+ List<List<PkVectorSearchResult>> results =
+ new ArrayList<>(queries.length);
+ for (Optional<ScoredGlobalIndexResult>
scoredResult :
+ scoredResults) {
+ results.add(
+ mapResults(
+ segment,
+ sourceMeta,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ scoredResult));
+ }
+ return Collections.unmodifiableList(results);
+ });
+ } catch (RuntimeException | Error t) {
+ IOUtils.closeQuietly(reader);
+ throw t;
+ }
+ }
+
private List<PkVectorSearchResult> mapResults(
IndexFileMeta segment,
PrimaryKeyIndexSourceMeta sourceMeta,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorExactSearcher.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorExactSearcher.java
index e63fa700b8..781cde4068 100644
---
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorExactSearcher.java
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorExactSearcher.java
@@ -43,44 +43,79 @@ public final class PkVectorExactSearcher {
int limit,
LongPredicate excludedPosition)
throws IOException {
- checkArgument(query.length == reader.dimension(), "Query vector
dimension does not match.");
+ return searchBatch(
+ dataFileName,
+ reader,
+ new float[][] {query},
+ metric,
+ limit,
+ excludedPosition)
+ .get(0);
+ }
+
+ public static List<List<PkVectorSearchResult>> searchBatch(
+ String dataFileName,
+ PkVectorReader reader,
+ float[][] queries,
+ String metric,
+ int limit,
+ LongPredicate excludedPosition)
+ throws IOException {
+ checkArgument(queries != null && queries.length > 0, "Query vectors
cannot be empty.");
checkArgument(limit > 0, "Vector search limit must be positive.");
checkArgument(
VectorSearchMetric.isSupported(metric),
"Unsupported vector distance metric: %s.",
metric);
metric = VectorSearchMetric.normalize(metric);
- for (int i = 0; i < query.length; i++) {
- checkArgument(
- Float.isFinite(query[i]),
- "Query vector element at position %s must be finite.",
- i);
- }
Comparator<PkVectorSearchResult> bestFirst =
Comparator.comparingDouble(PkVectorSearchResult::distance)
.thenComparingLong(PkVectorSearchResult::rowPosition);
- PriorityQueue<PkVectorSearchResult> nearest =
- new PriorityQueue<>(limit, bestFirst.reversed());
+ List<PriorityQueue<PkVectorSearchResult>> nearest = new
ArrayList<>(queries.length);
+ for (float[] query : queries) {
+ validateQuery(query, reader.dimension());
+ nearest.add(new PriorityQueue<>(limit, bestFirst.reversed()));
+ }
+
float[] vector = new float[reader.dimension()];
for (long position = 0; position < reader.rowCount(); position++) {
if (!reader.readNextVector(vector) ||
excludedPosition.test(position)) {
continue;
}
- PkVectorSearchResult candidate =
- new PkVectorSearchResult(
- dataFileName,
- position,
- VectorSearchMetric.computeDistance(query, vector,
metric));
- if (nearest.size() < limit) {
- nearest.add(candidate);
- } else if (bestFirst.compare(candidate, nearest.peek()) < 0) {
- nearest.poll();
- nearest.add(candidate);
+ for (int i = 0; i < queries.length; i++) {
+ PkVectorSearchResult candidate =
+ new PkVectorSearchResult(
+ dataFileName,
+ position,
+ VectorSearchMetric.computeDistance(queries[i],
vector, metric));
+ PriorityQueue<PkVectorSearchResult> queryNearest =
nearest.get(i);
+ if (queryNearest.size() < limit) {
+ queryNearest.add(candidate);
+ } else if (bestFirst.compare(candidate, queryNearest.peek()) <
0) {
+ queryNearest.poll();
+ queryNearest.add(candidate);
+ }
}
}
- List<PkVectorSearchResult> result = new ArrayList<>(nearest);
- Collections.sort(result, bestFirst);
- return Collections.unmodifiableList(result);
+
+ List<List<PkVectorSearchResult>> results = new
ArrayList<>(queries.length);
+ for (PriorityQueue<PkVectorSearchResult> queryNearest : nearest) {
+ List<PkVectorSearchResult> result = new ArrayList<>(queryNearest);
+ Collections.sort(result, bestFirst);
+ results.add(Collections.unmodifiableList(result));
+ }
+ return Collections.unmodifiableList(results);
+ }
+
+ private static void validateQuery(float[] query, int dimension) {
+ checkArgument(query != null, "Query vector cannot be null.");
+ checkArgument(query.length == dimension, "Query vector dimension does
not match.");
+ for (int i = 0; i < query.length; i++) {
+ checkArgument(
+ Float.isFinite(query[i]),
+ "Query vector element at position %s must be finite.",
+ i);
+ }
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearch.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearch.java
index ed2e2c4114..65dd95362b 100644
---
a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearch.java
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearch.java
@@ -139,15 +139,78 @@ public class PrimaryKeyVectorBucketSearch {
int indexedLimit,
int exactLimit,
Executor executor) {
+ return searchBatchAsync(
+ state,
+ activeFiles,
+ deletionVectors,
+ rowRangesByFile,
+ new float[][] {query},
+ indexedLimit,
+ exactLimit,
+ executor)
+ .thenApply(results -> results.get(0));
+ }
+
+ public List<Result> searchBatch(
+ PkVectorBucketIndexState state,
+ List<DataFileMeta> activeFiles,
+ Map<String, DeletionVector> deletionVectors,
+ float[][] queries,
+ int indexedLimit,
+ int exactLimit)
+ throws IOException {
+ return searchBatch(
+ state,
+ activeFiles,
+ deletionVectors,
+ Collections.emptyMap(),
+ queries,
+ indexedLimit,
+ exactLimit);
+ }
+
+ public List<Result> searchBatch(
+ PkVectorBucketIndexState state,
+ List<DataFileMeta> activeFiles,
+ Map<String, DeletionVector> deletionVectors,
+ Map<String, List<Range>> rowRangesByFile,
+ float[][] queries,
+ int indexedLimit,
+ int exactLimit)
+ throws IOException {
+ return join(
+ searchBatchAsync(
+ state,
+ activeFiles,
+ deletionVectors,
+ rowRangesByFile,
+ queries,
+ indexedLimit,
+ exactLimit,
+ Runnable::run));
+ }
+
+ public CompletableFuture<List<Result>> searchBatchAsync(
+ PkVectorBucketIndexState state,
+ List<DataFileMeta> activeFiles,
+ Map<String, DeletionVector> deletionVectors,
+ Map<String, List<Range>> rowRangesByFile,
+ float[][] queries,
+ int indexedLimit,
+ int exactLimit,
+ Executor executor) {
+ checkArgument(queries != null && queries.length > 0, "Query vectors
cannot be empty.");
checkArgument(indexedLimit > 0, "Vector indexed search limit must be
positive.");
checkArgument(exactLimit > 0, "Vector exact search limit must be
positive.");
+
Map<String, DataFileMeta> filesByName = new HashMap<>();
for (DataFileMeta file : activeFiles) {
checkArgument(filesByName.put(file.fileName(), file) == null,
"Duplicate data file.");
}
Set<String> activeSourceFiles = new HashSet<>(filesByName.keySet());
Set<String> covered = new HashSet<>();
- List<CompletableFuture<List<PkVectorSearchResult>>> indexedFutures =
new ArrayList<>();
+ List<CompletableFuture<List<List<PkVectorSearchResult>>>>
indexedFutures =
+ new ArrayList<>();
for (IndexFileMeta ann : state.annSegments()) {
PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(ann);
for (PrimaryKeyIndexSourceFile source : sourceMeta.sourceFiles()) {
@@ -162,19 +225,34 @@ public class PrimaryKeyVectorBucketSearch {
covered.add(source.fileName());
}
checkArgument(annSearcher != null, "ANN search is not
configured.");
- indexedFutures.add(
- annSearcher.searchAsync(
- ann,
- sourceMeta,
- query,
- indexedLimit,
- deletionVectors,
- activeSourceFiles,
- rowRangesByFile,
- searchOptions));
+ if (queries.length == 1) {
+ indexedFutures.add(
+ annSearcher
+ .searchAsync(
+ ann,
+ sourceMeta,
+ queries[0],
+ indexedLimit,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ searchOptions)
+ .thenApply(Collections::singletonList));
+ } else {
+ indexedFutures.add(
+ annSearcher.searchBatchAsync(
+ ann,
+ sourceMeta,
+ queries,
+ indexedLimit,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ searchOptions));
+ }
}
- List<CompletableFuture<List<PkVectorSearchResult>>> exactFutures = new
ArrayList<>();
+ List<CompletableFuture<List<List<PkVectorSearchResult>>>> exactFutures
= new ArrayList<>();
if (searchMode != GlobalIndexSearchMode.FAST) {
for (DataFileMeta file : activeFiles) {
if (covered.contains(file.fileName())) {
@@ -191,7 +269,7 @@ public class PrimaryKeyVectorBucketSearch {
|| (rowRanges != null &&
!contains(rowRanges, position));
exactFutures.add(
CompletableFuture.supplyAsync(
- () -> exactSearch(file, query, exactLimit,
excluded), executor));
+ () -> exactSearch(file, queries, exactLimit,
excluded), executor));
}
}
List<CompletableFuture<?>> futures = new ArrayList<>();
@@ -200,36 +278,63 @@ public class PrimaryKeyVectorBucketSearch {
return CompletableFuture.allOf(futures.toArray(new
CompletableFuture[0]))
.thenApply(
ignored -> {
- PriorityQueue<PkVectorSearchResult> indexedNearest
=
- new PriorityQueue<>(indexedLimit,
BEST_FIRST.reversed());
- for (CompletableFuture<List<PkVectorSearchResult>>
future :
+ List<PriorityQueue<PkVectorSearchResult>>
indexedNearest =
+ queues(queries.length, indexedLimit);
+ for
(CompletableFuture<List<List<PkVectorSearchResult>>> future :
indexedFutures) {
- for (PkVectorSearchResult result :
future.join()) {
- add(indexedNearest, result, indexedLimit);
+ List<List<PkVectorSearchResult>> batchResults
= future.join();
+ checkArgument(
+ batchResults.size() == queries.length,
+ "ANN batch result count does not match
query count.");
+ for (int i = 0; i < queries.length; i++) {
+ for (PkVectorSearchResult result :
batchResults.get(i)) {
+ add(indexedNearest.get(i), result,
indexedLimit);
+ }
}
}
- PriorityQueue<PkVectorSearchResult> exactNearest =
- new PriorityQueue<>(exactLimit,
BEST_FIRST.reversed());
- for (CompletableFuture<List<PkVectorSearchResult>>
future :
+ List<PriorityQueue<PkVectorSearchResult>>
exactNearest =
+ queues(queries.length, exactLimit);
+ for
(CompletableFuture<List<List<PkVectorSearchResult>>> future :
exactFutures) {
- for (PkVectorSearchResult result :
future.join()) {
- add(exactNearest, result, exactLimit);
+ List<List<PkVectorSearchResult>> batchResults
= future.join();
+ checkArgument(
+ batchResults.size() == queries.length,
+ "Exact batch result count does not
match query count.");
+ for (int i = 0; i < queries.length; i++) {
+ for (PkVectorSearchResult result :
batchResults.get(i)) {
+ add(exactNearest.get(i), result,
exactLimit);
+ }
}
}
- return new Result(sorted(indexedNearest),
sorted(exactNearest));
+ List<Result> results = new
ArrayList<>(queries.length);
+ for (int i = 0; i < queries.length; i++) {
+ results.add(
+ new Result(
+ sorted(indexedNearest.get(i)),
+ sorted(exactNearest.get(i))));
+ }
+ return Collections.unmodifiableList(results);
});
}
- private List<PkVectorSearchResult> exactSearch(
- DataFileMeta file, float[] query, int limit, LongPredicate
excluded) {
+ private List<List<PkVectorSearchResult>> exactSearch(
+ DataFileMeta file, float[][] queries, int limit, LongPredicate
excluded) {
try (PkVectorReader reader = vectorReaderFactory.create(file)) {
- return PkVectorExactSearcher.search(
- file.fileName(), reader, query, metric, limit, excluded);
+ return PkVectorExactSearcher.searchBatch(
+ file.fileName(), reader, queries, metric, limit, excluded);
} catch (IOException e) {
throw new CompletionException(e);
}
}
+ private static List<PriorityQueue<PkVectorSearchResult>> queues(int count,
int limit) {
+ List<PriorityQueue<PkVectorSearchResult>> queues = new
ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ queues.add(new PriorityQueue<>(limit, BEST_FIRST.reversed()));
+ }
+ return queues;
+ }
+
private static <T> T join(CompletableFuture<T> future) throws IOException {
try {
return future.join();
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
index ab92a1e668..8209f93c4c 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/BatchVectorSearchBuilderImpl.java
@@ -125,6 +125,14 @@ public class BatchVectorSearchBuilderImpl implements
BatchVectorSearchBuilder {
@Override
public VectorScan newVectorScan() {
+ if (isPrimaryKeyVectorSearch()) {
+ return new PrimaryKeyVectorScan(
+ table,
+ vectorColumn.id(),
+
table.coreOptions().primaryKeyVectorIndexType(vectorColumn.name()),
+ partitionFilter,
+ filter);
+ }
return new DataEvolutionVectorScan(table, partitionFilter, filter,
vectorColumn, options);
}
@@ -137,7 +145,16 @@ public class BatchVectorSearchBuilderImpl implements
BatchVectorSearchBuilder {
for (float[] vector : vectors) {
checkNotNull(vector, "Search vector element cannot be null");
}
+ if (isPrimaryKeyVectorSearch()) {
+ return new PrimaryKeyBatchVectorRead(
+ table, vectorColumn, vectors, limit, options, filter);
+ }
return new BatchVectorReadImpl(
table, partitionFilter, filter, limit, vectorColumn, vectors,
options);
}
+
+ protected boolean isPrimaryKeyVectorSearch() {
+ return vectorColumn != null
+ &&
table.coreOptions().primaryKeyVectorIndexColumns().contains(vectorColumn.name());
+ }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyBatchVectorRead.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyBatchVectorRead.java
new file mode 100644
index 0000000000..bc63614126
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyBatchVectorRead.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.types.DataField;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Executes snapshot-consistent primary-key vector search for multiple query
vectors. */
+public class PrimaryKeyBatchVectorRead implements BatchVectorRead,
Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final float[][] queries;
+ private final List<PrimaryKeyVectorRead> queryReads;
+
+ public PrimaryKeyBatchVectorRead(
+ FileStoreTable table,
+ DataField vectorField,
+ float[][] queries,
+ int limit,
+ Map<String, String> searchOptions,
+ @Nullable Predicate filter) {
+ checkArgument(queries != null && queries.length > 0, "Query vectors
cannot be empty.");
+ this.queries = new float[queries.length][];
+ this.queryReads = new ArrayList<>(queries.length);
+ for (int i = 0; i < queries.length; i++) {
+ float[] query = checkNotNull(queries[i], "Query vector cannot be
null.").clone();
+ this.queries[i] = query;
+ this.queryReads.add(
+ new PrimaryKeyVectorRead(
+ table, vectorField, query, limit, searchOptions,
filter));
+ }
+ }
+
+ @Override
+ public List<GlobalIndexResult> readBatch(VectorScan.Plan plan) {
+ PrimaryKeyVectorRead firstRead = queryReads.get(0);
+ PrimaryKeyVectorScan.Plan primaryKeyPlan =
firstRead.primaryKeyPlan(plan);
+ List<PrimaryKeyVectorRead.SearchResult> searchResults =
+
firstRead.searchBuckets(firstRead.bucketSplits(primaryKeyPlan), queries);
+ checkArgument(
+ searchResults.size() == queryReads.size(),
+ "Primary-key vector batch result count does not match query
count.");
+
+ List<GlobalIndexResult> results = new ArrayList<>(queryReads.size());
+ for (int i = 0; i < queryReads.size(); i++) {
+ results.add(queryReads.get(i).createResult(primaryKeyPlan,
searchResults.get(i)));
+ }
+ return Collections.unmodifiableList(results);
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
index ad1a4a2dca..4a33127a29 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorRead.java
@@ -211,6 +211,40 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
}
}
+ protected List<SearchResult> searchBuckets(
+ List<BucketVectorSearchSplit> splits, float[][] queries) {
+ try {
+ SearchContext context = createSearchContext();
+ List<CompletableFuture<List<SearchResult>>> futures = new
ArrayList<>(splits.size());
+ for (BucketVectorSearchSplit split : splits) {
+ futures.add(searchBatchAsync(split, context, queries));
+ }
+ CompletableFuture.allOf(futures.toArray(new
CompletableFuture[0])).join();
+ List<List<SearchResult>> resultsByQuery = new
ArrayList<>(queries.length);
+ for (int i = 0; i < queries.length; i++) {
+ resultsByQuery.add(new ArrayList<>());
+ }
+ for (CompletableFuture<List<SearchResult>> future : futures) {
+ List<SearchResult> splitResults = future.join();
+ checkArgument(
+ splitResults.size() == queries.length,
+ "Primary-key vector batch result count does not match
query count.");
+ for (int i = 0; i < queries.length; i++) {
+ resultsByQuery.get(i).add(splitResults.get(i));
+ }
+ }
+ List<SearchResult> results = new ArrayList<>(queries.length);
+ for (List<SearchResult> queryResults : resultsByQuery) {
+ results.add(mergeSearchResults(queryResults));
+ }
+ return Collections.unmodifiableList(results);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to search primary-key vector
index.", e);
+ } catch (CompletionException e) {
+ throw new RuntimeException("Failed to search primary-key vector
index.", e.getCause());
+ }
+ }
+
SearchContext createSearchContext() {
return new SearchContext(table);
}
@@ -241,6 +275,13 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
CompletableFuture<SearchResult> searchAsync(
BucketVectorSearchSplit split, SearchContext context) throws
IOException {
+ return searchBatchAsync(split, context, new float[][] {query})
+ .thenApply(results -> results.get(0));
+ }
+
+ CompletableFuture<List<SearchResult>> searchBatchAsync(
+ BucketVectorSearchSplit split, SearchContext context, float[][]
queries)
+ throws IOException {
DataSplit dataSplit = split.dataSplit();
List<DataFileMeta> activeFiles =
dataSplit.dataFiles().stream()
@@ -274,20 +315,26 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
metric,
table.coreOptions().globalIndexSearchMode());
return bucketSearch
- .searchAsync(
+ .searchBatchAsync(
state,
activeFiles,
deletionVectors,
rowRangesByFile(split),
- query,
+ queries,
indexedLimit,
limit,
context.executor)
.thenApply(
- result ->
- new SearchResult(
- candidates(dataSplit,
result.indexedCandidates()),
- candidates(dataSplit,
result.exactCandidates())));
+ bucketResults -> {
+ List<SearchResult> results = new
ArrayList<>(bucketResults.size());
+ for (PrimaryKeyVectorBucketSearch.Result result :
bucketResults) {
+ results.add(
+ new SearchResult(
+ candidates(dataSplit,
result.indexedCandidates()),
+ candidates(dataSplit,
result.exactCandidates())));
+ }
+ return Collections.unmodifiableList(results);
+ });
}
private Map<String, List<Range>> rowRangesByFile(BucketVectorSearchSplit
split)
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
index 8e89ac6fc8..097ff80195 100644
---
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java
@@ -175,6 +175,61 @@ class PkVectorAnnSegmentFileTest {
org.assertj.core.groups.Tuple.tuple("data-1", 1L));
}
+ @Test
+ void testBatchSearchPreservesQueryOrderAndMapsPhysicalPositions() throws
Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ PkVectorAnnSegmentFile annFile = annFile(fileIO);
+ IndexFileMeta segment =
+ annFile.build(
+ Arrays.asList(
+ new PkVectorAnnSegmentFile.Source(
+ dataFile("data-1", 2),
+ new ArrayReader(new float[][] {{5, 0},
{10, 0}})),
+ new PkVectorAnnSegmentFile.Source(
+ dataFile("data-2", 2),
+ new ArrayReader(new float[][] {{0, 0},
{2, 0}}))),
+ vectorField(),
+ indexOptions(),
+ "l2",
+ "test-vector-ann");
+ PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(segment);
+ BitmapDeletionVector data2Deletes = new BitmapDeletionVector();
+ data2Deletes.delete(0);
+ Map<String, org.apache.paimon.deletionvectors.DeletionVector>
deletionVectors =
+ new HashMap<>();
+ deletionVectors.put("data-2", data2Deletes);
+ Map<String, List<Range>> rowRangesByFile = new HashMap<>();
+ rowRangesByFile.put("data-1", Collections.singletonList(new Range(1,
1)));
+ rowRangesByFile.put("data-2", Collections.singletonList(new Range(1,
1)));
+
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ List<List<PkVectorSearchResult>> results;
+ try {
+ results =
+ new PkVectorAnnSegmentSearcher(
+ fileIO, annFile, vectorField(),
indexOptions(), "l2", executor)
+ .searchBatch(
+ segment,
+ sourceMeta,
+ new float[][] {{0, 0}, {10, 0}},
+ 1,
+ deletionVectors,
+ new HashSet<>(Arrays.asList("data-1",
"data-2")),
+ rowRangesByFile,
+ Collections.emptyMap());
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertThat(results).hasSize(2);
+ assertThat(results.get(0))
+ .extracting(PkVectorSearchResult::dataFileName,
PkVectorSearchResult::rowPosition)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("data-2",
1L));
+ assertThat(results.get(1))
+ .extracting(PkVectorSearchResult::dataFileName,
PkVectorSearchResult::rowPosition)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("data-1",
1L));
+ }
+
@Test
void testSearchFiltersInactiveSources() throws Exception {
LocalFileIO fileIO = LocalFileIO.create();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorExactSearcherTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorExactSearcherTest.java
index 8f7e14fe0d..866ca7ceab 100644
---
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorExactSearcherTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorExactSearcherTest.java
@@ -77,6 +77,32 @@ class PkVectorExactSearcherTest {
org.assertj.core.groups.Tuple.tuple("data-file", 0L,
9F));
}
+ @Test
+ void testBatchSearchPreservesQueryOrderAndScansSourceOnce() throws
Exception {
+ ArrayReader reader = new ArrayReader(new float[][] {{0, 0}, {2, 0},
{5, 0}});
+
+ List<List<PkVectorSearchResult>> results;
+ try (PkVectorReader ignored = reader) {
+ results =
+ PkVectorExactSearcher.searchBatch(
+ "data-file",
+ reader,
+ new float[][] {{0, 0}, {5, 0}},
+ "l2",
+ 1,
+ position -> false);
+ }
+
+ assertThat(results).hasSize(2);
+ assertThat(results.get(0))
+ .extracting(PkVectorSearchResult::rowPosition)
+ .containsExactly(0L);
+ assertThat(results.get(1))
+ .extracting(PkVectorSearchResult::rowPosition)
+ .containsExactly(2L);
+ assertThat(reader.position).isEqualTo(3);
+ }
+
private static float distance(String metric) throws IOException {
try (PkVectorReader reader = new ArrayReader(new float[][] {{1, 0}})) {
return PkVectorExactSearcher.search(
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearchTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearchTest.java
index a80cb69e0f..70c9f83953 100644
---
a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearchTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorBucketSearchTest.java
@@ -351,6 +351,39 @@ class PrimaryKeyVectorBucketSearchTest {
.containsExactly(org.assertj.core.groups.Tuple.tuple("data-1",
1L, 4F));
}
+ @Test
+ void testBatchExactFallbackPreservesQueryOrderAndOpensFileOnce() throws
Exception {
+ DataFileMeta data = dataFile("data");
+ PkVectorDataFileReader.Factory readerFactory =
mock(PkVectorDataFileReader.Factory.class);
+ PkVectorDataFileReader dataReader = reader(new float[][] {{0, 0}, {5,
0}});
+ when(readerFactory.create(data)).thenReturn(dataReader);
+
+ List<PrimaryKeyVectorBucketSearch.Result> results =
+ new PrimaryKeyVectorBucketSearch(
+ readerFactory,
+ null,
+ Collections.emptyMap(),
+ "l2",
+ GlobalIndexSearchMode.FULL)
+ .searchBatch(
+ new PkVectorBucketIndexState(
+ 7, "test-vector-ann",
Collections.emptyList()),
+ Collections.singletonList(data),
+ Collections.emptyMap(),
+ new float[][] {{0, 0}, {5, 0}},
+ 1,
+ 1);
+
+ assertThat(results).hasSize(2);
+ assertThat(results.get(0).exactCandidates())
+ .extracting(PkVectorSearchResult::rowPosition)
+ .containsExactly(0L);
+ assertThat(results.get(1).exactCandidates())
+ .extracting(PkVectorSearchResult::rowPosition)
+ .containsExactly(1L);
+ verify(readerFactory).create(data);
+ }
+
private static PkVectorDataFileReader reader(float[][] vectors) throws
IOException {
PkVectorDataFileReader reader = mock(PkVectorDataFileReader.class);
when(reader.dimension()).thenReturn(2);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
index d1fb7c2556..06d376be26 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorSearchTest.java
@@ -153,6 +153,30 @@ class PrimaryKeyVectorSearchTest extends TableTestBase {
assertThat(ids).containsExactly(2, 3);
}
+ @Test
+ void testBatchVectorSearchPreservesQueryOrder() throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+ write(
+ table,
+ ioManager,
+ GenericRow.of(1, BinaryVector.fromPrimitiveArray(new float[]
{0, 0})),
+ GenericRow.of(2, BinaryVector.fromPrimitiveArray(new float[]
{5, 0})),
+ GenericRow.of(3, BinaryVector.fromPrimitiveArray(new float[]
{10, 0})));
+
+ List<GlobalIndexResult> results =
+ table.newBatchVectorSearchBuilder()
+ .withVectorColumn("embedding")
+ .withVectors(new float[][] {{0, 0}, {10, 0}})
+ .withLimit(1)
+ .withOption("refine_factor", "2")
+ .executeBatchLocal();
+
+ assertThat(results).hasSize(2);
+ assertThat(readIds(table, results.get(0))).containsExactly(1);
+ assertThat(readIds(table, results.get(1))).containsExactly(3);
+ }
+
@Test
void testEmptyVectorSearch() throws Exception {
createTableDefault();
@@ -168,6 +192,21 @@ class PrimaryKeyVectorSearchTest extends TableTestBase {
assertThat(((GlobalIndexSplitResult) result).splits()).isEmpty();
}
+ @Test
+ void testBatchBuilderSelectsPrimaryKeyVectorScan() throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ VectorScan scan =
+ table.newBatchVectorSearchBuilder()
+ .withVectorColumn("embedding")
+ .withVectors(new float[][] {{0, 0}})
+ .withLimit(1)
+ .newVectorScan();
+
+ assertThat(scan).isInstanceOf(PrimaryKeyVectorScan.class);
+ }
+
@Test
void testVectorSearchUsesSortedIndexPreFilter() throws Exception {
Schema schema =
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
index b3c23e03d6..74c0e6c47d 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
@@ -19,7 +19,8 @@
package org.apache.paimon.spark.execution
import org.apache.paimon.CoreOptions
-import org.apache.paimon.globalindex.{GlobalIndexResult,
ScoredGlobalIndexResult}
+import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.globalindex.{GlobalIndexResult, IndexedSplit,
ScoredGlobalIndexResult}
import org.apache.paimon.partition.PartitionPredicate
import
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates
import org.apache.paimon.predicate.{Predicate, PredicateBuilder}
@@ -31,7 +32,7 @@ import org.apache.paimon.spark.data.SparkInternalRow
import org.apache.paimon.spark.read.VectorSearchResultUtils
import org.apache.paimon.spark.schema.PaimonMetadataColumn
import org.apache.paimon.table.{InnerTable, SpecialFields, Table}
-import org.apache.paimon.table.source.{BatchVectorSearchBuilder,
InnerTableScan, ReadBuilder, VectorScan}
+import org.apache.paimon.table.source.{BatchVectorSearchBuilder, DataSplit,
InnerTableScan, PrimaryKeyScoredResult, PrimaryKeySearchPosition,
PrimaryKeyVectorResult, ReadBuilder, VectorScan}
import org.apache.paimon.types.RowType
import org.apache.paimon.utils.RoaringNavigableMap64
@@ -328,6 +329,9 @@ case class LateralVectorSearchExec(
val readBuilder = innerTable
.newReadBuilder()
.withReadType(rowTypeWithRowId.project(readFieldNamesWithRowId.asJava))
+ val physicalReadBuilder = innerTable
+ .newReadBuilder()
+ .withReadType(readRowType)
val scoreMetadataColumns =
if (vectorSearchOutput.exists(_.name ==
PaimonMetadataColumn.SEARCH_SCORE_COLUMN)) {
Seq(PaimonMetadataColumn.SEARCH_SCORE)
@@ -348,7 +352,7 @@ case class LateralVectorSearchExec(
.withVectorColumn(columnName)
.withLimit(limit)
.withOptions(options.asJava)
- pushSearchFilters(readBuilder, vectorSearchBuilder)
+ pushSearchFilters(Seq(readBuilder, physicalReadBuilder),
vectorSearchBuilder)
val vectorPlan = vectorSearchBuilder.newVectorScan().scan()
val batchSize =
@@ -356,6 +360,7 @@ case class LateralVectorSearchExec(
LateralVectorSearchContext(
readBuilder,
+ physicalReadBuilder,
vectorSearchBuilder,
vectorPlan,
scoreMetadataColumns,
@@ -378,7 +383,7 @@ case class LateralVectorSearchExec(
}
private def pushSearchFilters(
- readBuilder: ReadBuilder,
+ readBuilders: Seq[ReadBuilder],
vectorSearchBuilder: BatchVectorSearchBuilder): Unit = {
val predicates = convertSearchFilters()
if (predicates.nonEmpty) {
@@ -388,12 +393,12 @@ case class LateralVectorSearchExec(
innerTable.partitionKeys())
if (split.getLeft.isPresent) {
val partitionFilter = split.getLeft.get()
- readBuilder.withPartitionFilter(partitionFilter)
+ readBuilders.foreach(_.withPartitionFilter(partitionFilter))
vectorSearchBuilder.withPartitionFilter(partitionFilter)
}
if (!split.getRight.isEmpty) {
val dataFilter = PredicateBuilder.and(split.getRight)
- readBuilder.withFilter(dataFilter)
+ readBuilders.foreach(_.withFilter(dataFilter))
vectorSearchBuilder.withFilter(dataFilter)
}
}
@@ -434,9 +439,14 @@ case class LateralVectorSearchExec(
s"Batch vector search returned ${globalIndexResults.size} results for
${queries.size} " +
"query vectors. The result count must match the query count."
)
+ val primaryKeyResults = primaryKeyVectorResults(globalIndexResults)
if (context.metaColumnsOnly) {
- return searchMetaColumns(queries, globalIndexResults, context)
+ return primaryKeyResults match {
+ case Some(results) => searchPrimaryKeyMetaColumns(queries, results,
context)
+ case None => searchMetaColumns(queries, globalIndexResults, context)
+ }
}
+ primaryKeyResults.foreach(results => return searchPrimaryKeyRows(queries,
results, context))
val rowIdToMatches = createRowIdToMatches(queries, globalIndexResults)
val batchGlobalIndexResult =
createBatchGlobalIndexResult(globalIndexResults)
val scan = context.readBuilder
@@ -472,6 +482,126 @@ case class LateralVectorSearchExec(
}
}
+ private def searchPrimaryKeyMetaColumns(
+ queries: Seq[LateralVectorSearchQuery],
+ results: Seq[PrimaryKeyVectorResult],
+ context: LateralVectorSearchContext): Iterator[(InternalRow,
InternalRow)] = {
+ queries.zip(results).iterator.flatMap {
+ case (query, result) =>
+ result.positions().iterator().asScala.map {
+ position =>
+ val values = vectorSearchOutput.map {
+ attr =>
+ attr.name match {
+ case PaimonMetadataColumn.ROW_ID_COLUMN =>
position.rowPosition()
+ case PaimonMetadataColumn.SEARCH_SCORE_COLUMN =>
position.score()
+ case name =>
+ throw new IllegalArgumentException(
+ s"Unsupported primary-key vector search metadata column:
$name")
+ }
+ }.toArray
+ val projectedRow = context.rightProjection(
+ new JoinedRow(
+ query.outerRow,
+ new GenericInternalRow(values.asInstanceOf[Array[Any]])))
+ (query.outerRow, projectedRow)
+ }
+ }
+ }
+
+ private def primaryKeyVectorResults(
+ globalIndexResults: Seq[GlobalIndexResult]):
Option[Seq[PrimaryKeyVectorResult]] = {
+ val results = globalIndexResults.collect { case result:
PrimaryKeyVectorResult => result }
+ require(
+ results.isEmpty || results.size == globalIndexResults.size,
+ "Batch vector search cannot mix primary-key physical results with global
row-ID results."
+ )
+ if (results.isEmpty) None else Some(results)
+ }
+
+ private def searchPrimaryKeyRows(
+ queries: Seq[LateralVectorSearchQuery],
+ results: Seq[PrimaryKeyVectorResult],
+ context: LateralVectorSearchContext): Iterator[(InternalRow,
InternalRow)] = {
+ val snapshotId = results.head.snapshotId()
+ require(
+ results.forall(_.snapshotId() == snapshotId),
+ "Primary-key batch vector results must belong to the same snapshot."
+ )
+
+ val positionToMatches = scala.collection.mutable
+ .LinkedHashMap[LateralVectorSearchPhysicalPosition,
ArrayBuffer[LateralVectorSearchMatch]]()
+ val uniquePositions = scala.collection.mutable
+ .LinkedHashMap[LateralVectorSearchPhysicalPosition,
PrimaryKeySearchPosition]()
+ queries.zip(results).foreach {
+ case (query, result) =>
+ result.positions().asScala.foreach {
+ position =>
+ val key = LateralVectorSearchPhysicalPosition.from(position)
+ positionToMatches.getOrElseUpdate(key, ArrayBuffer()) +=
+ LateralVectorSearchMatch(query.outerRow, position.score())
+ uniquePositions.getOrElseUpdate(key, position)
+ }
+ }
+
+ val sourceSplits =
+ scala.collection.mutable.LinkedHashMap[LateralVectorSearchPhysicalFile,
DataSplit]()
+ results.foreach {
+ result =>
+ result.splits().asScala.foreach {
+ split =>
+ val dataSplit = split.dataSplit()
+ val key = LateralVectorSearchPhysicalFile.from(dataSplit)
+ sourceSplits.getOrElseUpdate(key, dataSplit)
+ }
+ }
+ val batchResult = new PrimaryKeyScoredResult(
+ snapshotId,
+ sourceSplits.values.toList.asJava,
+ uniquePositions.values.toList.asJava)
+ val scan = context.physicalReadBuilder
+ .newScan()
+ .withGlobalIndexResult(batchResult)
+ .asInstanceOf[InnerTableScan]
+ val read = context.physicalReadBuilder.newRead()
+
+ scan.plan().splits().asScala.iterator.flatMap {
+ split =>
+ val indexedSplit = split.asInstanceOf[IndexedSplit]
+ val dataSplit = indexedSplit.dataSplit()
+ val file = LateralVectorSearchPhysicalFile.from(dataSplit)
+ val reader =
+ PaimonRecordReaderIterator(
+ read.createReader(split),
+ Seq(PaimonMetadataColumn.ROW_ID) ++ context.scoreMetadataColumns,
+ split)
+ val readerState = context.readerTracker.track(reader)
+ new Iterator[Iterator[(InternalRow, InternalRow)]] {
+ override def hasNext: Boolean = {
+ val hasNext = reader.hasNext
+ if (!hasNext) {
+ readerState.closeOnce()
+ }
+ hasNext
+ }
+
+ override def next(): Iterator[(InternalRow, InternalRow)] = {
+ val rightRow = context.sparkRow.replace(reader.next())
+ val position = LateralVectorSearchPhysicalPosition(
+ file.partition,
+ file.bucket,
+ file.dataFileName,
+ rightRow.getLong(context.rowIdOrdinal))
+ positionToMatches.getOrElse(position, Seq.empty).iterator.map {
+ searchMatch =>
+ val projectedRow = projectRightRow(rightRow, searchMatch,
context)
+ (searchMatch.outerRow, projectedRow)
+ }
+ }
+ }.flatMap(identity)
+ }
+ }
+
private def searchMetaColumns(
queries: Seq[LateralVectorSearchQuery],
globalIndexResults: Seq[GlobalIndexResult],
@@ -599,6 +729,7 @@ case class LateralVectorSearchExec(
private case class LateralVectorSearchContext(
readBuilder: ReadBuilder,
+ physicalReadBuilder: ReadBuilder,
vectorSearchBuilder: BatchVectorSearchBuilder,
vectorPlan: VectorScan.Plan,
scoreMetadataColumns: Seq[PaimonMetadataColumn],
@@ -613,4 +744,38 @@ case class LateralVectorSearchExec(
private case class LateralVectorSearchQuery(outerRow: InternalRow,
queryVector: Array[Float])
private case class LateralVectorSearchMatch(outerRow: InternalRow, score:
Float)
+
+ private case class LateralVectorSearchPhysicalFile(
+ partition: BinaryRow,
+ bucket: Int,
+ dataFileName: String)
+
+ private object LateralVectorSearchPhysicalFile {
+ def from(split: DataSplit): LateralVectorSearchPhysicalFile = {
+ require(
+ split.dataFiles().size() == 1,
+ "Primary-key indexed split must contain exactly one data file."
+ )
+ LateralVectorSearchPhysicalFile(
+ split.partition().copy(),
+ split.bucket(),
+ split.dataFiles().get(0).fileName())
+ }
+ }
+
+ private case class LateralVectorSearchPhysicalPosition(
+ partition: BinaryRow,
+ bucket: Int,
+ dataFileName: String,
+ rowPosition: Long)
+
+ private object LateralVectorSearchPhysicalPosition {
+ def from(position: PrimaryKeySearchPosition):
LateralVectorSearchPhysicalPosition = {
+ LateralVectorSearchPhysicalPosition(
+ position.partition().copy(),
+ position.bucket(),
+ position.dataFileName(),
+ position.rowPosition())
+ }
+ }
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
index b9b71c9045..ebafe40933 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
@@ -398,6 +398,65 @@ class PrimaryKeyVectorSearchTest extends
PaimonSparkTestBase {
}
}
+ test("lateral primary-key vector search reads physical positions") {
+ withTable("T") {
+ createVectorTable(columns = "id INT, embedding ARRAY<FLOAT>,
query_embedding ARRAY<FLOAT>")
+ spark.sql("""
+ |INSERT INTO T VALUES
+ | (1, array(1.0f, 0.0f), array(0.0f, 0.0f)),
+ | (2, array(5.0f, 0.0f), array(0.5f, 0.0f))
+ |""".stripMargin)
+
+ val rows = spark
+ .sql("""
+ |SELECT q.id AS query_id, r.id AS result_id, r._row_id AS
row_id,
+ | r.__paimon_search_score AS score
+ |FROM T AS q,
+ |LATERAL (
+ | SELECT id, _row_id, __paimon_search_score
+ | FROM vector_search('T', 'embedding', q.query_embedding, 1)
+ |) AS r
+ |ORDER BY query_id
+ |""".stripMargin)
+ .collect()
+
+ assert(rows.length == 2)
+ assert(rows.map(_.getInt(1)).toSeq == Seq(1, 1))
+ assert(rows.map(_.getLong(2)).distinct.length == 1)
+ assert(Math.abs(rows(0).getFloat(3) - 0.5f) < 1e-6)
+ assert(Math.abs(rows(1).getFloat(3) - 0.8f) < 1e-6)
+ }
+ }
+
+ test("lateral primary-key vector search projects physical metadata") {
+ withTable("T") {
+ createVectorTable(columns = "id INT, embedding ARRAY<FLOAT>,
query_embedding ARRAY<FLOAT>")
+ spark.sql("""
+ |INSERT INTO T VALUES
+ | (1, array(1.0f, 0.0f), array(0.0f, 0.0f)),
+ | (2, array(5.0f, 0.0f), array(0.5f, 0.0f))
+ |""".stripMargin)
+
+ val rows = spark
+ .sql("""
+ |SELECT q.id AS query_id, r._row_id AS row_id,
+ | r.__paimon_search_score AS score
+ |FROM T AS q,
+ |LATERAL (
+ | SELECT _row_id, __paimon_search_score
+ | FROM vector_search('T', 'embedding', q.query_embedding, 1)
+ |) AS r
+ |ORDER BY query_id
+ |""".stripMargin)
+ .collect()
+
+ assert(rows.length == 2)
+ assert(rows.map(_.getLong(1)).distinct.length == 1)
+ assert(Math.abs(rows(0).getFloat(2) - 0.5f) < 1e-6)
+ assert(Math.abs(rows(1).getFloat(2) - 0.8f) < 1e-6)
+ }
+ }
+
private def createVectorTable(
columns: String = "id INT, embedding ARRAY<FLOAT>",
primaryKey: String = "id",