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 110985f3bf [core][docs] Parallelize primary-key vector search (#8668)
110985f3bf is described below
commit 110985f3bf6aac2d80f537f28789e4f150477cf4
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 15 23:42:27 2026 +0800
[core][docs] Parallelize primary-key vector search (#8668)
Primary-key vector reads currently wait for each bucket and ANN segment
before starting the next one, so local search latency grows serially
with the number of buckets and segments.
---
...026-07-15-primary-key-vector-parallel-search.md | 75 +++++++++++
docs/docs/primary-key-table/global-index.mdx | 24 ++--
.../index/pkvector/PkVectorAnnSegmentSearcher.java | 127 ++++++++++++-------
.../pkvector/PrimaryKeyVectorBucketSearch.java | 118 ++++++++++++-----
.../paimon/table/source/PrimaryKeyVectorRead.java | 49 ++++---
.../pkvector/PrimaryKeyVectorBucketSearchTest.java | 141 ++++++++++++++++++++-
.../table/source/PrimaryKeyVectorReadTest.java | 105 +++++++++++++++
7 files changed, 533 insertions(+), 106 deletions(-)
diff --git a/docs/designs/2026-07-15-primary-key-vector-parallel-search.md
b/docs/designs/2026-07-15-primary-key-vector-parallel-search.md
new file mode 100644
index 0000000000..8135f63310
--- /dev/null
+++ b/docs/designs/2026-07-15-primary-key-vector-parallel-search.md
@@ -0,0 +1,75 @@
+<!--
+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.
+-->
+
+# Primary-Key Vector Parallel Search Design
+
+## Problem Statement
+
+`PrimaryKeyVectorRead` searches bucket splits serially, and
+`PrimaryKeyVectorBucketSearch` waits for each ANN segment before starting the
next one. A query
+over multiple buckets or segments therefore accumulates their local-search
latency.
+
+## Chosen Approach
+
+Use the existing global-index read executor and `global-index.thread-num` to
compose bucket,
+segment, and uncovered-file searches with `CompletableFuture`. Submit only
leaf searches to the
+executor and wait once after all searches have been started. This avoids
blocking an executor task
+while it waits for more work submitted to the same executor.
+
+## Design Details
+
+### Asynchronous Search
+
+- Add asynchronous ANN segment search while retaining synchronous wrappers for
compatibility.
+- Compose all segment futures within a bucket instead of joining each segment
immediately.
+- Compose all bucket futures in `PrimaryKeyVectorRead` and preserve the
existing deterministic
+ global Top-K merge.
+- Submit FULL and DETAIL exact searches for uncovered files as independent
leaf tasks.
+
+### Resource and Error Handling
+
+- Attach index-reader closure directly to the future returned by the index
reader.
+- Close exact-search readers within their leaf tasks.
+- Do not submit additional work from completion callbacks.
+- Preserve the existing top-level `Failed to search primary-key vector index.`
error contract.
+
+### Compatibility
+
+- Keep JDK 8 syntax and existing synchronous search entry points.
+- Do not add a new option or executor.
+- Keep bucket preparation, deletion-vector loading, residual-filter
evaluation, reranking, and
+ result ordering unchanged.
+
+### Verification
+
+- Prove multiple ANN segment searches can be in flight together.
+- Prove multiple bucket searches are composed concurrently and merge
deterministically.
+- Prove FULL and DETAIL exact fallback searches uncovered files concurrently.
+- Verify exceptional completion closes readers and is propagated.
+- Verify `global-index.thread-num=1` completes without deadlock.
+
+## Open Questions
+
+None.
+
+## Out of Scope
+
+- Fair scheduling across buckets when a semaphore-limited executor blocks
submissions.
+- Parallel bucket metadata, deletion-vector, residual-filter, or rerank reads.
+- Changes to `GlobalIndexReadThreadPool` or its executor wrappers.
diff --git a/docs/docs/primary-key-table/global-index.mdx
b/docs/docs/primary-key-table/global-index.mdx
index a8f3dea2f0..ba59a1e972 100644
--- a/docs/docs/primary-key-table/global-index.mdx
+++ b/docs/docs/primary-key-table/global-index.mdx
@@ -236,6 +236,7 @@ schema validation.
| `fields.<column>.pk-bitmap.index.options` | Not set | JSON object containing
Bitmap build options. Unqualified keys are scoped to `bitmap-index`. |
| `fields.<column>.pk-index.compaction.level-fanout` | `5` | Number of
similarly sized index groups which triggers a rebuild and maximum row-count
ratio within one size tier. Shared by all four families. Must be greater than
`1`. |
| `fields.<column>.pk-index.compaction.stale-ratio-threshold` | `0.2` | Ratio
of rows from inactive source files which triggers a rebuild. Shared by all four
families. Must be in `(0, 1]`. |
+| `global-index.search-mode` | `fast` | Search mode for primary-key Vector and
Full Text queries. `fast` searches indexed data only, so uncovered files are
omitted. For Vector, `full` and `detail` search uncovered files exactly.
Primary-key Full Text supports only `fast`. |
For algorithm-specific options, see the corresponding
[BTree](../multimodal-table/global-index/btree),
@@ -274,11 +275,16 @@ Index construction can execute asynchronously inside the
writer. A writer which
compaction also waits for active index maintenance; a non-blocking writer can
complete maintenance
in a later commit. Coverage can therefore be temporarily partial.
-For scalar and vector indexes, partial coverage affects acceleration, not
correctness:
+Coverage behavior depends on the index family and search mode.
`global-index.search-mode` defaults
+to `fast`. For primary-key Vector and Full Text queries, `fast` searches only
data covered by an
+active index group. Uncovered files are not searched, so partial coverage can
make results
+incomplete.
-- BTree and Bitmap scans read uncovered files through the ordinary data path.
-- Vector search evaluates files without an active ANN group exactly.
-- The original scalar predicate and deletion vectors are applied after index
pruning.
+- BTree and Bitmap scans always read uncovered files through the ordinary data
path. Partial
+ coverage affects their acceleration, not result completeness. The original
scalar predicate and
+ deletion vectors are applied after index pruning.
+- Vector search in `full` or `detail` mode evaluates files without an active
ANN group exactly and
+ merges those results with ANN candidates. In the default `fast` mode, those
files are omitted.
Primary-key Full Text currently supports only `global-index.search-mode =
fast`. It searches
persistent archives and ignores uncovered files; `full` and `detail` are
rejected because a
@@ -388,8 +394,9 @@ factor must be a positive integer. A factor of `1` performs
exact reranking with
additional candidates.
Only ANN candidates can win the rerank, so a larger factor can improve recall
but does not
-guarantee the exact global Top-K. It also increases ANN work and data-file
I/O. Uncovered files are
-searched exactly and merged separately with the ANN candidates.
+guarantee the exact global Top-K. It also increases ANN work and data-file
I/O. In `full` or
+`detail` mode, uncovered files are searched exactly and merged separately with
the ANN candidates;
+the default `fast` mode does not search them.
## Full-Text Search
@@ -514,8 +521,9 @@ create a table with the desired definition and migrate the
data.
- Index acceleration and vector search are snapshot-scoped batch operations;
continuous streaming
and lateral Vector or Full Text search are not supported.
- Flink vector search returns rows but does not expose the ANN score as a
separate column.
-- Primary-key Full Text supports only FAST search mode and excludes uncovered
files until
- compaction creates persistent archives.
+- FAST is the default search mode and excludes uncovered files from
primary-key Vector and Full
+ Text results. Vector supports exact fallback for uncovered files in FULL and
DETAIL modes;
+ primary-key Full Text supports only FAST until compaction creates persistent
archives.
- Full Text routes support partition pruning but not arbitrary row predicates
before Top-K.
- Hybrid search cannot mix source-backed physical routes with global row-ID
routes.
- Online replacement between two definitions on the same column is not
supported.
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 797afd104c..58696e5a0b 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
@@ -48,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import static org.apache.paimon.utils.Preconditions.checkArgument;
@@ -148,6 +149,27 @@ public class PkVectorAnnSegmentSearcher {
Set<String> activeSourceFiles,
Map<String, List<Range>> rowRangesByFile,
Map<String, String> searchOptions) {
+ return searchAsync(
+ segment,
+ sourceMeta,
+ query,
+ limit,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ searchOptions)
+ .join();
+ }
+
+ CompletableFuture<List<PkVectorSearchResult>> searchAsync(
+ IndexFileMeta segment,
+ PrimaryKeyIndexSourceMeta sourceMeta,
+ float[] query,
+ int limit,
+ Map<String, DeletionVector> deletionVectors,
+ Set<String> activeSourceFiles,
+ Map<String, List<Range>> rowRangesByFile,
+ Map<String, String> searchOptions) {
checkArgument(limit > 0, "Vector search limit must be positive: %s.",
limit);
GlobalIndexMeta globalIndexMeta = segment.globalIndexMeta();
checkArgument(
@@ -189,51 +211,70 @@ public class PkVectorAnnSegmentSearcher {
if (liveRows != null) {
search.withIncludeRowIds(liveRows);
}
- Optional<ScoredGlobalIndexResult> result =
reader.visitVectorSearch(search).join();
- if (!result.isPresent()) {
- return Collections.emptyList();
- }
-
- long sourceRowCount = totalRowCount(sourceMeta.sourceFiles());
- List<PkVectorSearchResult> candidates = new ArrayList<>();
- ScoredGlobalIndexResult scored = result.get();
- for (long ordinal : scored.results()) {
- checkArgument(
- ordinal >= 0 && ordinal < sourceRowCount,
- "ANN segment %s returned ordinal %s outside [0, %s).",
- segment.fileName(),
- ordinal,
- sourceRowCount);
- FilePosition filePosition =
filePosition(sourceMeta.sourceFiles(), ordinal);
- checkArgument(
- activeSourceFiles.contains(filePosition.dataFileName),
- "ANN segment %s returned inactive source %s.",
- segment.fileName(),
- filePosition.dataFileName);
- DeletionVector deletionVector =
deletionVectors.get(filePosition.dataFileName);
- checkArgument(
- deletionVector == null
- ||
!deletionVector.isDeleted(filePosition.rowPosition),
- "ANN segment %s returned snapshot-deleted row position
%s.",
- segment.fileName(),
- filePosition.rowPosition);
- List<Range> rowRanges =
rowRangesByFile.get(filePosition.dataFileName);
- checkArgument(
- rowRanges == null || contains(rowRanges,
filePosition.rowPosition),
- "ANN segment %s returned a row outside the
pre-filter.",
- segment.fileName());
- candidates.add(
- new PkVectorSearchResult(
- filePosition.dataFileName,
- filePosition.rowPosition,
- VectorSearchMetric.scoreToDistance(
- scored.scoreGetter().score(ordinal),
metric)));
- }
- Collections.sort(candidates, BEST_FIRST);
- return Collections.unmodifiableList(candidates);
- } finally {
+ return reader.visitVectorSearch(search)
+ .whenComplete((ignored, error) ->
IOUtils.closeQuietly(reader))
+ .thenApply(
+ result ->
+ mapResults(
+ segment,
+ sourceMeta,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ result));
+ } catch (RuntimeException | Error t) {
IOUtils.closeQuietly(reader);
+ throw t;
+ }
+ }
+
+ private List<PkVectorSearchResult> mapResults(
+ IndexFileMeta segment,
+ PrimaryKeyIndexSourceMeta sourceMeta,
+ Map<String, DeletionVector> deletionVectors,
+ Set<String> activeSourceFiles,
+ Map<String, List<Range>> rowRangesByFile,
+ Optional<ScoredGlobalIndexResult> result) {
+ if (!result.isPresent()) {
+ return Collections.emptyList();
+ }
+
+ long sourceRowCount = totalRowCount(sourceMeta.sourceFiles());
+ List<PkVectorSearchResult> candidates = new ArrayList<>();
+ ScoredGlobalIndexResult scored = result.get();
+ for (long ordinal : scored.results()) {
+ checkArgument(
+ ordinal >= 0 && ordinal < sourceRowCount,
+ "ANN segment %s returned ordinal %s outside [0, %s).",
+ segment.fileName(),
+ ordinal,
+ sourceRowCount);
+ FilePosition filePosition = filePosition(sourceMeta.sourceFiles(),
ordinal);
+ checkArgument(
+ activeSourceFiles.contains(filePosition.dataFileName),
+ "ANN segment %s returned inactive source %s.",
+ segment.fileName(),
+ filePosition.dataFileName);
+ DeletionVector deletionVector =
deletionVectors.get(filePosition.dataFileName);
+ checkArgument(
+ deletionVector == null ||
!deletionVector.isDeleted(filePosition.rowPosition),
+ "ANN segment %s returned snapshot-deleted row position
%s.",
+ segment.fileName(),
+ filePosition.rowPosition);
+ List<Range> rowRanges =
rowRangesByFile.get(filePosition.dataFileName);
+ checkArgument(
+ rowRanges == null || contains(rowRanges,
filePosition.rowPosition),
+ "ANN segment %s returned a row outside the pre-filter.",
+ segment.fileName());
+ candidates.add(
+ new PkVectorSearchResult(
+ filePosition.dataFileName,
+ filePosition.rowPosition,
+ VectorSearchMetric.scoreToDistance(
+ scored.scoreGetter().score(ordinal),
metric)));
}
+ Collections.sort(candidates, BEST_FIRST);
+ return Collections.unmodifiableList(candidates);
}
@Nullable
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 d864a3a3fb..ed2e2c4114 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
@@ -38,6 +38,9 @@ import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Executor;
import java.util.function.LongPredicate;
import static org.apache.paimon.utils.Preconditions.checkArgument;
@@ -115,18 +118,36 @@ public class PrimaryKeyVectorBucketSearch {
int indexedLimit,
int exactLimit)
throws IOException {
+ return join(
+ searchAsync(
+ state,
+ activeFiles,
+ deletionVectors,
+ rowRangesByFile,
+ query,
+ indexedLimit,
+ exactLimit,
+ Runnable::run));
+ }
+
+ public CompletableFuture<Result> searchAsync(
+ PkVectorBucketIndexState state,
+ List<DataFileMeta> activeFiles,
+ Map<String, DeletionVector> deletionVectors,
+ Map<String, List<Range>> rowRangesByFile,
+ float[] query,
+ int indexedLimit,
+ int exactLimit,
+ Executor executor) {
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.");
}
- PriorityQueue<PkVectorSearchResult> indexedNearest =
- new PriorityQueue<>(indexedLimit, BEST_FIRST.reversed());
- PriorityQueue<PkVectorSearchResult> exactNearest =
- new PriorityQueue<>(exactLimit, BEST_FIRST.reversed());
Set<String> activeSourceFiles = new HashSet<>(filesByName.keySet());
Set<String> covered = new HashSet<>();
+ List<CompletableFuture<List<PkVectorSearchResult>>> indexedFutures =
new ArrayList<>();
for (IndexFileMeta ann : state.annSegments()) {
PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(ann);
for (PrimaryKeyIndexSourceFile source : sourceMeta.sourceFiles()) {
@@ -141,30 +162,19 @@ public class PrimaryKeyVectorBucketSearch {
covered.add(source.fileName());
}
checkArgument(annSearcher != null, "ANN search is not
configured.");
- List<PkVectorSearchResult> annResults =
- rowRangesByFile.isEmpty()
- ? annSearcher.search(
- ann,
- sourceMeta,
- query,
- indexedLimit,
- deletionVectors,
- activeSourceFiles,
- searchOptions)
- : annSearcher.search(
- ann,
- sourceMeta,
- query,
- indexedLimit,
- deletionVectors,
- activeSourceFiles,
- rowRangesByFile,
- searchOptions);
- for (PkVectorSearchResult result : annResults) {
- add(indexedNearest, result, indexedLimit);
- }
+ indexedFutures.add(
+ annSearcher.searchAsync(
+ ann,
+ sourceMeta,
+ query,
+ indexedLimit,
+ deletionVectors,
+ activeSourceFiles,
+ rowRangesByFile,
+ searchOptions));
}
+ List<CompletableFuture<List<PkVectorSearchResult>>> exactFutures = new
ArrayList<>();
if (searchMode != GlobalIndexSearchMode.FAST) {
for (DataFileMeta file : activeFiles) {
if (covered.contains(file.fileName())) {
@@ -179,16 +189,56 @@ public class PrimaryKeyVectorBucketSearch {
position ->
(dv != null && dv.isDeleted(position))
|| (rowRanges != null &&
!contains(rowRanges, position));
- try (PkVectorReader reader = vectorReaderFactory.create(file))
{
- for (PkVectorSearchResult result :
- PkVectorExactSearcher.search(
- file.fileName(), reader, query, metric,
exactLimit, excluded)) {
- add(exactNearest, result, exactLimit);
- }
- }
+ exactFutures.add(
+ CompletableFuture.supplyAsync(
+ () -> exactSearch(file, query, exactLimit,
excluded), executor));
+ }
+ }
+ List<CompletableFuture<?>> futures = new ArrayList<>();
+ futures.addAll(indexedFutures);
+ futures.addAll(exactFutures);
+ return CompletableFuture.allOf(futures.toArray(new
CompletableFuture[0]))
+ .thenApply(
+ ignored -> {
+ PriorityQueue<PkVectorSearchResult> indexedNearest
=
+ new PriorityQueue<>(indexedLimit,
BEST_FIRST.reversed());
+ for (CompletableFuture<List<PkVectorSearchResult>>
future :
+ indexedFutures) {
+ for (PkVectorSearchResult result :
future.join()) {
+ add(indexedNearest, result, indexedLimit);
+ }
+ }
+ PriorityQueue<PkVectorSearchResult> exactNearest =
+ new PriorityQueue<>(exactLimit,
BEST_FIRST.reversed());
+ for (CompletableFuture<List<PkVectorSearchResult>>
future :
+ exactFutures) {
+ for (PkVectorSearchResult result :
future.join()) {
+ add(exactNearest, result, exactLimit);
+ }
+ }
+ return new Result(sorted(indexedNearest),
sorted(exactNearest));
+ });
+ }
+
+ private List<PkVectorSearchResult> exactSearch(
+ DataFileMeta file, float[] query, int limit, LongPredicate
excluded) {
+ try (PkVectorReader reader = vectorReaderFactory.create(file)) {
+ return PkVectorExactSearcher.search(
+ file.fileName(), reader, query, metric, limit, excluded);
+ } catch (IOException e) {
+ throw new CompletionException(e);
+ }
+ }
+
+ private static <T> T join(CompletableFuture<T> future) throws IOException {
+ try {
+ return future.join();
+ } catch (CompletionException e) {
+ if (e.getCause() instanceof IOException) {
+ throw (IOException) e.getCause();
}
+ throw e;
}
- return new Result(sorted(indexedNearest), sorted(exactNearest));
}
private static boolean contains(List<Range> ranges, long position) {
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 315050bb59..ad1a4a2dca 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
@@ -65,6 +65,8 @@ import java.util.Objects;
import java.util.Optional;
import java.util.PriorityQueue;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;
@@ -188,21 +190,31 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
protected SearchResult searchBuckets(List<BucketVectorSearchSplit> splits)
{
try {
- SearchContext context = new SearchContext(table);
- List<Candidate> indexedCandidates = new ArrayList<>();
- List<Candidate> exactCandidates = new ArrayList<>();
+ SearchContext context = createSearchContext();
+ List<CompletableFuture<SearchResult>> futures = new
ArrayList<>(splits.size());
+ // Start from the caller because each bucket submits leaf searches
to the same executor.
+ // Wrapping the whole bucket in that executor and waiting inside
it can starve leaf
+ // work.
for (BucketVectorSearchSplit split : splits) {
- SearchResult result = search(split, context);
- indexedCandidates.addAll(result.indexedCandidates());
- exactCandidates.addAll(result.exactCandidates());
+ futures.add(searchAsync(split, context));
}
- return new SearchResult(
- topK(indexedCandidates, indexedLimit),
topK(exactCandidates, limit));
+ CompletableFuture.allOf(futures.toArray(new
CompletableFuture[0])).join();
+ List<SearchResult> results = new ArrayList<>(futures.size());
+ for (CompletableFuture<SearchResult> future : futures) {
+ results.add(future.join());
+ }
+ return mergeSearchResults(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);
+ }
+
protected GlobalIndexResult createResult(
PrimaryKeyVectorScan.Plan plan, SearchResult searchResult) {
List<Candidate> indexedCandidates =
topK(searchResult.indexedCandidates(), indexedLimit);
@@ -227,8 +239,8 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
topK(indexedCandidates, indexedLimit), topK(exactCandidates,
limit));
}
- private SearchResult search(BucketVectorSearchSplit split, SearchContext
context)
- throws IOException {
+ CompletableFuture<SearchResult> searchAsync(
+ BucketVectorSearchSplit split, SearchContext context) throws
IOException {
DataSplit dataSplit = split.dataSplit();
List<DataFileMeta> activeFiles =
dataSplit.dataFiles().stream()
@@ -261,18 +273,21 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
searchOptions,
metric,
table.coreOptions().globalIndexSearchMode());
- PrimaryKeyVectorBucketSearch.Result result =
- bucketSearch.search(
+ return bucketSearch
+ .searchAsync(
state,
activeFiles,
deletionVectors,
rowRangesByFile(split),
query,
indexedLimit,
- limit);
- return new SearchResult(
- candidates(dataSplit, result.indexedCandidates()),
- candidates(dataSplit, result.exactCandidates()));
+ limit,
+ context.executor)
+ .thenApply(
+ result ->
+ new SearchResult(
+ candidates(dataSplit,
result.indexedCandidates()),
+ candidates(dataSplit,
result.exactCandidates())));
}
private Map<String, List<Range>> rowRangesByFile(BucketVectorSearchSplit
split)
@@ -621,7 +636,7 @@ public class PrimaryKeyVectorRead implements VectorRead,
Serializable {
}
}
- private static class SearchContext {
+ static class SearchContext {
private final FileIO fileIO;
private final IndexFileHandler indexFileHandler;
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 c4c53d6fe4..a80cb69e0f 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
@@ -38,6 +38,11 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -49,6 +54,108 @@ import static org.mockito.Mockito.when;
/** Tests for {@link PrimaryKeyVectorBucketSearch}. */
class PrimaryKeyVectorBucketSearchTest {
+ @Test
+ void testSearchesUncoveredFilesConcurrently() throws Exception {
+ DataFileMeta data1 = dataFile("data-1");
+ DataFileMeta data2 = dataFile("data-2");
+ PkVectorDataFileReader.Factory readerFactory =
mock(PkVectorDataFileReader.Factory.class);
+ CountDownLatch started = new CountDownLatch(2);
+ CountDownLatch release = new CountDownLatch(1);
+ PkVectorDataFileReader reader1 = blockingReader(started, release);
+ PkVectorDataFileReader reader2 = blockingReader(started, release);
+ when(readerFactory.create(data1)).thenReturn(reader1);
+ when(readerFactory.create(data2)).thenReturn(reader2);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ try {
+ CompletableFuture<PrimaryKeyVectorBucketSearch.Result> future =
+ new PrimaryKeyVectorBucketSearch(
+ readerFactory,
+ null,
+ Collections.emptyMap(),
+ "l2",
+ GlobalIndexSearchMode.FULL)
+ .searchAsync(
+ new PkVectorBucketIndexState(
+ 7, "test-vector-ann",
Collections.emptyList()),
+ Arrays.asList(data1, data2),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ new float[] {0, 0},
+ 1,
+ 1,
+ executor);
+
+ try {
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ release.countDown();
+ }
+ assertThat(future.get(5,
TimeUnit.SECONDS).exactCandidates()).isEmpty();
+ } finally {
+ release.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void testStartsAnnSegmentsBeforeWaitingForResults() throws Exception {
+ DataFileMeta data1 = dataFile("data-1");
+ DataFileMeta data2 = dataFile("data-2");
+ IndexFileMeta ann1 = segment("ann-1", data1);
+ IndexFileMeta ann2 = segment("ann-2", data2);
+ PkVectorAnnSegmentSearcher annSearcher =
mock(PkVectorAnnSegmentSearcher.class);
+ CompletableFuture<List<PkVectorSearchResult>> future1 = new
CompletableFuture<>();
+ CompletableFuture<List<PkVectorSearchResult>> future2 = new
CompletableFuture<>();
+ CountDownLatch started = new CountDownLatch(2);
+ when(annSearcher.searchAsync(
+ org.mockito.ArgumentMatchers.any(IndexFileMeta.class),
+
org.mockito.ArgumentMatchers.any(PrimaryKeyIndexSourceMeta.class),
+ org.mockito.ArgumentMatchers.any(float[].class),
+ org.mockito.ArgumentMatchers.eq(1),
+
org.mockito.ArgumentMatchers.eq(Collections.emptyMap()),
+ org.mockito.ArgumentMatchers.eq(
+ new
java.util.HashSet<>(Arrays.asList("data-1", "data-2"))),
+
org.mockito.ArgumentMatchers.eq(Collections.emptyMap()),
+
org.mockito.ArgumentMatchers.eq(Collections.emptyMap())))
+ .thenAnswer(
+ invocation -> {
+ started.countDown();
+ return invocation
+ .<IndexFileMeta>getArgument(0)
+ .fileName()
+ .equals("ann-1")
+ ? future1
+ : future2;
+ });
+
+ CompletableFuture<PrimaryKeyVectorBucketSearch.Result> resultFuture =
+ new PrimaryKeyVectorBucketSearch(
+ mock(PkVectorDataFileReader.Factory.class),
+ annSearcher,
+ Collections.emptyMap(),
+ "l2",
+ GlobalIndexSearchMode.FAST)
+ .searchAsync(
+ new PkVectorBucketIndexState(
+ 7, "test-vector-ann",
Arrays.asList(ann1, ann2)),
+ Arrays.asList(data1, data2),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ new float[] {0, 0},
+ 1,
+ 1,
+ Runnable::run);
+
+ assertThat(started.getCount()).isZero();
+ future1.complete(Collections.singletonList(new
PkVectorSearchResult("data-1", 0, 2F)));
+ future2.complete(Collections.singletonList(new
PkVectorSearchResult("data-2", 0, 1F)));
+
+ assertThat(resultFuture.get(5, TimeUnit.SECONDS).indexedCandidates())
+ .extracting(PkVectorSearchResult::dataFileName)
+ .containsExactly("data-2");
+ }
+
@Test
void testFastModeSkipsExactFallback() throws Exception {
DataFileMeta data = dataFile("data");
@@ -112,7 +219,7 @@ class PrimaryKeyVectorBucketSearchTest {
PkVectorAnnSegmentSearcher annSearcher =
mock(PkVectorAnnSegmentSearcher.class);
Map<String, DeletionVector> deletionVectors = Collections.emptyMap();
Map<String, String> searchOptions =
Collections.singletonMap("nprobes", "8");
- when(annSearcher.search(
+ when(annSearcher.searchAsync(
org.mockito.ArgumentMatchers.eq(ann),
org.mockito.ArgumentMatchers.any(PrimaryKeyIndexSourceMeta.class),
org.mockito.ArgumentMatchers.any(float[].class),
@@ -120,8 +227,12 @@ class PrimaryKeyVectorBucketSearchTest {
org.mockito.ArgumentMatchers.eq(deletionVectors),
org.mockito.ArgumentMatchers.eq(
new
java.util.HashSet<>(Arrays.asList("data-1", "data-2"))),
+
org.mockito.ArgumentMatchers.eq(Collections.emptyMap()),
org.mockito.ArgumentMatchers.eq(searchOptions)))
- .thenReturn(Collections.singletonList(new
PkVectorSearchResult("data-1", 1, 0.5F)));
+ .thenReturn(
+ CompletableFuture.completedFuture(
+ Collections.singletonList(
+ new PkVectorSearchResult("data-1", 1,
0.5F))));
PrimaryKeyVectorBucketSearch.Result results =
new PrimaryKeyVectorBucketSearch(
@@ -162,15 +273,19 @@ class PrimaryKeyVectorBucketSearchTest {
IndexFileMeta ann = segment("ann", Arrays.asList(retired, active));
PkVectorAnnSegmentSearcher annSearcher =
mock(PkVectorAnnSegmentSearcher.class);
Map<String, DeletionVector> deletionVectors = Collections.emptyMap();
- when(annSearcher.search(
+ when(annSearcher.searchAsync(
org.mockito.ArgumentMatchers.eq(ann),
org.mockito.ArgumentMatchers.any(PrimaryKeyIndexSourceMeta.class),
org.mockito.ArgumentMatchers.any(float[].class),
org.mockito.ArgumentMatchers.eq(1),
org.mockito.ArgumentMatchers.eq(deletionVectors),
org.mockito.ArgumentMatchers.eq(Collections.singleton("active")),
+
org.mockito.ArgumentMatchers.eq(Collections.emptyMap()),
org.mockito.ArgumentMatchers.eq(Collections.emptyMap())))
- .thenReturn(Collections.singletonList(new
PkVectorSearchResult("active", 0, 1F)));
+ .thenReturn(
+ CompletableFuture.completedFuture(
+ Collections.singletonList(
+ new PkVectorSearchResult("active", 0,
1F))));
PkVectorDataFileReader.Factory readerFactory =
mock(PkVectorDataFileReader.Factory.class);
List<PkVectorSearchResult> results =
@@ -255,6 +370,24 @@ class PrimaryKeyVectorBucketSearchTest {
return reader;
}
+ private static PkVectorDataFileReader blockingReader(
+ CountDownLatch started, CountDownLatch release) throws Exception {
+ PkVectorDataFileReader reader = mock(PkVectorDataFileReader.class);
+ when(reader.dimension()).thenReturn(2);
+ when(reader.rowCount()).thenReturn(2L);
+
when(reader.readNextVector(org.mockito.ArgumentMatchers.any(float[].class)))
+ .thenAnswer(
+ invocation -> {
+ started.countDown();
+ if (!release.await(5, TimeUnit.SECONDS)) {
+ throw new AssertionError(
+ "Timed out waiting for concurrent
search.");
+ }
+ return false;
+ });
+ return reader;
+ }
+
private static DataFileMeta dataFile(String fileName) {
return DataFileMeta.forAppend(
fileName,
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorReadTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorReadTest.java
index cf7b447732..0164c38cbd 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorReadTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorReadTest.java
@@ -18,18 +18,69 @@
package org.apache.paimon.table.source;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.FloatType;
+import org.apache.paimon.types.VectorType;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
/** Tests global candidate merging for primary-key vector search. */
class PrimaryKeyVectorReadTest {
+ @Test
+ void testStartsBucketsBeforeWaitingForResults() throws Exception {
+ BucketVectorSearchSplit split1 = mock(BucketVectorSearchSplit.class);
+ BucketVectorSearchSplit split2 = mock(BucketVectorSearchSplit.class);
+ CompletableFuture<PrimaryKeyVectorRead.SearchResult> future1 = new
CompletableFuture<>();
+ CompletableFuture<PrimaryKeyVectorRead.SearchResult> future2 = new
CompletableFuture<>();
+ CountDownLatch started = new CountDownLatch(2);
+ TestingPrimaryKeyVectorRead read =
+ new TestingPrimaryKeyVectorRead(split1, future1, split2,
future2, started);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ try {
+ Future<PrimaryKeyVectorRead.SearchResult> resultFuture =
+ executor.submit(() ->
read.searchBuckets(Arrays.asList(split1, split2)));
+ assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+ future1.complete(
+ new PrimaryKeyVectorRead.SearchResult(
+ Collections.singletonList(candidate(0, "data-1",
0, 2F)),
+ Collections.emptyList()));
+ future2.complete(
+ new PrimaryKeyVectorRead.SearchResult(
+ Collections.singletonList(candidate(1, "data-2",
0, 1F)),
+ Collections.emptyList()));
+
+ assertThat(resultFuture.get(5,
TimeUnit.SECONDS).indexedCandidates())
+ .extracting(PrimaryKeyVectorRead.Candidate::dataFileName)
+ .containsExactly("data-2");
+ } finally {
+ future1.completeExceptionally(new RuntimeException("Test
cleanup."));
+ future2.completeExceptionally(new RuntimeException("Test
cleanup."));
+ executor.shutdownNow();
+ }
+ }
+
@Test
void testMergesGlobalTopKWithDeterministicTies() {
List<PrimaryKeyVectorRead.Candidate> candidates =
@@ -55,4 +106,58 @@ class PrimaryKeyVectorReadTest {
return new PrimaryKeyVectorRead.Candidate(
BinaryRow.EMPTY_ROW, bucket, fileName, position, distance);
}
+
+ 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);
+ return table;
+ }
+
+ private static class TestingPrimaryKeyVectorRead extends
PrimaryKeyVectorRead {
+
+ private final BucketVectorSearchSplit split1;
+ private final CompletableFuture<SearchResult> future1;
+ private final BucketVectorSearchSplit split2;
+ private final CompletableFuture<SearchResult> future2;
+ private final CountDownLatch started;
+
+ private TestingPrimaryKeyVectorRead(
+ BucketVectorSearchSplit split1,
+ CompletableFuture<SearchResult> future1,
+ BucketVectorSearchSplit split2,
+ CompletableFuture<SearchResult> future2,
+ CountDownLatch started) {
+ super(
+ table(),
+ new DataField(1, "vector", new VectorType(2, new
FloatType())),
+ new float[] {0, 0},
+ 1,
+ Collections.emptyMap());
+ this.split1 = split1;
+ this.future1 = future1;
+ this.split2 = split2;
+ this.future2 = future2;
+ this.started = started;
+ }
+
+ @Override
+ protected SearchContext createSearchContext() {
+ return null;
+ }
+
+ @Override
+ protected CompletableFuture<SearchResult> searchAsync(
+ BucketVectorSearchSplit split, SearchContext context) {
+ started.countDown();
+ if (split == split1) {
+ return future1;
+ }
+ assertThat(split).isSameAs(split2);
+ return future2;
+ }
+ }
}