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 16af7fd137 [core] Search primary-key full-text indexes (#8652)
16af7fd137 is described below
commit 16af7fd137767350c03962c7dddf5ff892d48ef9
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 15 18:10:12 2026 +0800
[core] Search primary-key full-text indexes (#8652)
Build on #8649 and #8651 by adding the core read path for primary-key
full-text indexes.
---
.../pkfulltext/PrimaryKeyFullTextBucketSearch.java | 262 +++++++++++++
...eadImpl.java => DataEvolutionFullTextRead.java} | 9 +-
...canImpl.java => DataEvolutionFullTextScan.java} | 12 +-
.../table/source/FullTextSearchBuilderImpl.java | 43 ++-
.../table/source/HybridSearchBuilderImpl.java | 5 +
.../table/source/PrimaryKeyFullTextRead.java | 224 +++++++++++
.../table/source/PrimaryKeyFullTextScan.java | 303 +++++++++++++++
.../source/PrimaryKeyFullTextSearchSplit.java | 167 +++++++++
.../table/source/PrimaryKeySearchRanker.java | 29 ++
.../PrimaryKeyFullTextBucketSearchTest.java | 416 +++++++++++++++++++++
.../table/source/FullTextSearchBuilderTest.java | 83 +++-
.../table/source/PrimaryKeyFullTextReadTest.java | 162 ++++++++
.../table/source/PrimaryKeyFullTextScanTest.java | 307 +++++++++++++++
.../table/source/PrimaryKeyFullTextSearchTest.java | 120 ++++++
.../index/NativePrimaryKeyFullTextIndexTest.java | 267 +++++++++++++
15 files changed, 2393 insertions(+), 16 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PrimaryKeyFullTextBucketSearch.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PrimaryKeyFullTextBucketSearch.java
new file mode 100644
index 0000000000..31f95a8839
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PrimaryKeyFullTextBucketSearch.java
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.index.pkfulltext;
+
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.predicate.FullTextSearch;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.PrimaryKeyFullTextSearchSplit;
+import org.apache.paimon.table.source.PrimaryKeySearchPosition;
+import org.apache.paimon.table.source.PrimaryKeySearchRanker;
+import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Searches source-backed full-text payloads and merges their scored
candidates. */
+public class PrimaryKeyFullTextBucketSearch {
+
+ private final ReaderFactory readerFactory;
+
+ public PrimaryKeyFullTextBucketSearch(ReaderFactory readerFactory) {
+ this.readerFactory = readerFactory;
+ }
+
+ public List<PrimaryKeySearchPosition> search(
+ PrimaryKeyFullTextSearchSplit split,
+ Map<String, DeletionVector> deletionVectors,
+ String column,
+ String query,
+ int limit) {
+ List<List<PrimaryKeySearchPosition>> localRankings =
+ searchRankings(split, deletionVectors, column, query, limit);
+ if (localRankings.isEmpty()) {
+ return Collections.emptyList();
+ }
+ return PrimaryKeySearchRanker.topKByScore(localRankings, limit);
+ }
+
+ public List<List<PrimaryKeySearchPosition>> searchRankings(
+ PrimaryKeyFullTextSearchSplit split,
+ Map<String, DeletionVector> deletionVectors,
+ String column,
+ String query,
+ int limit) {
+ checkArgument(limit > 0, "Full-text search limit must be positive:
%s.", limit);
+ DataSplit dataSplit = split.dataSplit();
+ Map<String, DataFileMeta> files = new HashMap<>();
+ for (DataFileMeta file : dataSplit.dataFiles()) {
+ checkArgument(
+ files.put(file.fileName(), file) == null,
+ "Duplicate full-text source file %s.",
+ file.fileName());
+ }
+
+ List<PayloadRequest> requests = new ArrayList<>();
+ for (IndexFileMeta payload : split.payloadFiles()) {
+ List<SourceRange> sourceRanges = new ArrayList<>();
+ boolean needsInclude = false;
+ long totalRowCount = 0;
+ for (PrimaryKeyIndexSourceFile source :
+
PrimaryKeyIndexSourceMeta.fromIndexFile(payload).sourceFiles()) {
+ DataFileMeta file = files.get(source.fileName());
+ if (file != null) {
+ checkArgument(
+ source.rowCount() == file.rowCount(),
+ "Full-text payload %s source row count does not
match data file %s.",
+ payload.fileName(),
+ source.fileName());
+ }
+ DeletionVector deletionVector =
deletionVectors.get(source.fileName());
+ needsInclude |=
+ file == null || (deletionVector != null &&
!deletionVector.isEmpty());
+ sourceRanges.add(
+ new SourceRange(
+ source.fileName(), totalRowCount,
source.rowCount(), file != null));
+ totalRowCount = Math.addExact(totalRowCount,
source.rowCount());
+ }
+ RoaringNavigableMap64 include =
+ needsInclude ? liveRows(sourceRanges, deletionVectors) :
null;
+ if (include != null && include.isEmpty()) {
+ continue;
+ }
+ GlobalIndexReader reader = readerFactory.create(payload);
+ CompletableFuture<Optional<ScoredGlobalIndexResult>> future;
+ try {
+ FullTextSearch predicate = new FullTextSearch(column, query,
limit);
+ if (include != null) {
+ predicate.withIncludeRowIds(include);
+ }
+ future =
+ reader.visitFullTextSearch(predicate)
+ .whenComplete((ignored, error) ->
IOUtils.closeQuietly(reader));
+ } catch (RuntimeException | Error t) {
+ IOUtils.closeQuietly(reader);
+ throw t;
+ }
+ requests.add(new PayloadRequest(sourceRanges, totalRowCount,
include, future));
+ }
+
+ CompletableFuture.allOf(
+ requests.stream()
+ .map(request -> request.future)
+ .toArray(CompletableFuture[]::new))
+ .join();
+ List<List<PrimaryKeySearchPosition>> localRankings = new
ArrayList<>(requests.size());
+ for (PayloadRequest request : requests) {
+ Optional<ScoredGlobalIndexResult> result = request.future.join();
+ if (!result.isPresent()) {
+ continue;
+ }
+ ScoredGlobalIndexResult scored = result.get();
+ List<PrimaryKeySearchPosition> ranking = new ArrayList<>();
+ for (long rowId : scored.results()) {
+ checkArgument(
+ rowId >= 0 && rowId < request.totalRowCount,
+ "Full-text index returned archive row position %s
outside row count %s.",
+ rowId,
+ request.totalRowCount);
+ if (request.include != null &&
!request.include.contains(rowId)) {
+ continue;
+ }
+ SourceRange source = request.source(rowId);
+ checkArgument(
+ source != null && source.active,
+ "Full-text index returned row position %s from an
inactive source.",
+ rowId);
+ ranking.add(
+ new PrimaryKeySearchPosition(
+ dataSplit.partition(),
+ dataSplit.bucket(),
+ source.fileName,
+ rowId - source.offset,
+ scored.scoreGetter().score(rowId)));
+ }
+ ranking.sort(
+ (left, right) -> {
+ int scoreOrder = Float.compare(right.score(),
left.score());
+ if (scoreOrder != 0) {
+ return scoreOrder;
+ }
+ int fileOrder =
left.dataFileName().compareTo(right.dataFileName());
+ return fileOrder != 0
+ ? fileOrder
+ : Long.compare(left.rowPosition(),
right.rowPosition());
+ });
+ localRankings.add(ranking);
+ }
+ return Collections.unmodifiableList(localRankings);
+ }
+
+ private static RoaringNavigableMap64 liveRows(
+ List<SourceRange> sourceRanges, Map<String, DeletionVector>
deletionVectors) {
+ RoaringNavigableMap64 include = new RoaringNavigableMap64();
+ RoaringNavigableMap64 deleted = new RoaringNavigableMap64();
+ for (SourceRange source : sourceRanges) {
+ if (!source.active) {
+ continue;
+ }
+ if (source.rowCount > 0) {
+ include.addRange(
+ new Range(
+ source.offset, Math.addExact(source.offset,
source.rowCount) - 1));
+ }
+ DeletionVector deletionVector =
deletionVectors.get(source.fileName);
+ if (deletionVector != null && !deletionVector.isEmpty()) {
+ deletionVector.forEachDeletedPosition(
+ position -> {
+ checkArgument(
+ position >= 0 && position <
source.rowCount,
+ "Deletion vector contains invalid row
position %s.",
+ position);
+ deleted.add(Math.addExact(source.offset,
position));
+ });
+ }
+ }
+ include.andNot(deleted);
+ return include;
+ }
+
+ /** Creates one independently closeable reader for an immutable payload
archive. */
+ @FunctionalInterface
+ public interface ReaderFactory {
+ GlobalIndexReader create(IndexFileMeta payload);
+ }
+
+ private static class PayloadRequest {
+
+ private final List<SourceRange> sourceRanges;
+ private final long totalRowCount;
+ @Nullable private final RoaringNavigableMap64 include;
+ private final CompletableFuture<Optional<ScoredGlobalIndexResult>>
future;
+
+ private PayloadRequest(
+ List<SourceRange> sourceRanges,
+ long totalRowCount,
+ @Nullable RoaringNavigableMap64 include,
+ CompletableFuture<Optional<ScoredGlobalIndexResult>> future) {
+ this.sourceRanges = sourceRanges;
+ this.totalRowCount = totalRowCount;
+ this.include = include;
+ this.future = future;
+ }
+
+ @Nullable
+ private SourceRange source(long rowId) {
+ for (SourceRange source : sourceRanges) {
+ if (rowId >= source.offset && rowId < source.offset +
source.rowCount) {
+ return source;
+ }
+ }
+ return null;
+ }
+ }
+
+ private static class SourceRange {
+
+ private final String fileName;
+ private final long offset;
+ private final long rowCount;
+ private final boolean active;
+
+ private SourceRange(String fileName, long offset, long rowCount,
boolean active) {
+ this.fileName = fileName;
+ this.offset = offset;
+ this.rowCount = rowCount;
+ this.active = active;
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionFullTextRead.java
similarity index 97%
rename from
paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
rename to
paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionFullTextRead.java
index 0a2134c7a5..bf74d2e036 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionFullTextRead.java
@@ -53,7 +53,7 @@ import static
org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
import static org.apache.paimon.utils.Preconditions.checkNotNull;
/** Implementation for {@link FullTextRead}. */
-public class FullTextReadImpl implements FullTextRead {
+public class DataEvolutionFullTextRead implements FullTextRead {
private final FileStoreTable table;
@Nullable private final PartitionPredicate partitionFilter;
@@ -61,16 +61,17 @@ public class FullTextReadImpl implements FullTextRead {
private final DataField textColumn;
private final String query;
- public FullTextReadImpl(FileStoreTable table, int limit, DataField
textColumn, String query) {
+ public DataEvolutionFullTextRead(
+ FileStoreTable table, int limit, DataField textColumn, String
query) {
this(table, null, limit, Collections.singletonList(textColumn), query);
}
- public FullTextReadImpl(
+ public DataEvolutionFullTextRead(
FileStoreTable table, int limit, List<DataField> textColumns,
String query) {
this(table, null, limit, textColumns, query);
}
- public FullTextReadImpl(
+ public DataEvolutionFullTextRead(
FileStoreTable table,
@Nullable PartitionPredicate partitionFilter,
int limit,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionFullTextScan.java
similarity index 97%
rename from
paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
rename to
paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionFullTextScan.java
index ecf6719f74..bb250dbe60 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionFullTextScan.java
@@ -50,22 +50,22 @@ import java.util.stream.Collectors;
import static org.apache.paimon.utils.Preconditions.checkNotNull;
/** Implementation for {@link FullTextScan}. */
-public class FullTextScanImpl implements FullTextScan {
+public class DataEvolutionFullTextScan implements FullTextScan {
private final FileStoreTable table;
private final PartitionPredicate partitionFilter;
private final List<DataField> textColumns;
- public FullTextScanImpl(FileStoreTable table, DataField textColumn) {
+ public DataEvolutionFullTextScan(FileStoreTable table, DataField
textColumn) {
this(table, null, textColumn);
}
- public FullTextScanImpl(
+ public DataEvolutionFullTextScan(
FileStoreTable table, PartitionPredicate partitionFilter,
DataField textColumn) {
this(table, partitionFilter, Collections.singletonList(textColumn));
}
- public FullTextScanImpl(
+ public DataEvolutionFullTextScan(
FileStoreTable table, PartitionPredicate partitionFilter,
List<DataField> textColumns) {
this.table = table;
this.partitionFilter = partitionFilter;
@@ -94,7 +94,7 @@ public class FullTextScanImpl implements FullTextScan {
return false;
}
GlobalIndexMeta globalIndex =
entry.indexFile().globalIndexMeta();
- if (globalIndex == null) {
+ if (globalIndex == null || globalIndex.sourceMeta() !=
null) {
return false;
}
return !matchedTextColumnIds(globalIndex,
textColumnIds).isEmpty()
@@ -216,7 +216,7 @@ public class FullTextScanImpl implements FullTextScan {
List<Long> sortedBoundaries = new ArrayList<>(boundaries);
Map<IndexRangeCandidate, List<Range>> assigned = new
LinkedHashMap<>();
TreeSet<IndexRangeCandidate> active =
- new TreeSet<>(FullTextScanImpl::compareCandidates);
+ new
TreeSet<>(DataEvolutionFullTextScan::compareCandidates);
for (int i = 0; i < sortedBoundaries.size(); i++) {
long from = sortedBoundaries.get(i);
List<IndexRangeCandidate> ending = endingAt.get(from);
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
index a46d174f04..bfd2c31199 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java
@@ -18,13 +18,15 @@
package org.apache.paimon.table.source;
+import org.apache.paimon.index.pk.PrimaryKeyIndexDefinition;
+import org.apache.paimon.index.pk.PrimaryKeyIndexDefinitions;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.InnerTable;
import org.apache.paimon.types.DataField;
import java.util.Collections;
-import java.util.List;
+import java.util.Optional;
import static org.apache.paimon.utils.Preconditions.checkArgument;
import static org.apache.paimon.utils.Preconditions.checkNotNull;
@@ -66,20 +68,51 @@ public class FullTextSearchBuilderImpl implements
FullTextSearchBuilder {
@Override
public FullTextScan newFullTextScan() {
- return new FullTextScanImpl(table, partitionFilter, textColumns());
+ DataField textColumn = textColumn();
+ Optional<PrimaryKeyIndexDefinition> definition =
primaryKeyFullTextDefinition(textColumn);
+ return definition.isPresent()
+ ? new PrimaryKeyFullTextScan(table, definition.get(),
partitionFilter)
+ : new DataEvolutionFullTextScan(
+ table, partitionFilter,
Collections.singletonList(textColumn));
}
@Override
public FullTextRead newFullTextRead() {
checkArgument(limit > 0, "Limit must be positive, set via
withLimit()");
- return new FullTextReadImpl(table, partitionFilter, limit,
textColumns(), query);
+ DataField textColumn = textColumn();
+ Optional<PrimaryKeyIndexDefinition> definition =
primaryKeyFullTextDefinition(textColumn);
+ return definition.isPresent()
+ ? new PrimaryKeyFullTextRead(table, definition.get(),
textColumn, query, limit)
+ : new DataEvolutionFullTextRead(
+ table,
+ partitionFilter,
+ limit,
+ Collections.singletonList(textColumn),
+ query);
}
- private List<DataField> textColumns() {
+ private DataField textColumn() {
checkNotNull(query, "Query must be set via withQuery()");
checkNotNull(fieldName, "Field name must be set via withQuery()");
DataField textColumn = table.rowType().getField(fieldName);
checkNotNull(textColumn, "Text column '%s' does not exist.",
fieldName);
- return Collections.singletonList(textColumn);
+ return textColumn;
+ }
+
+ private Optional<PrimaryKeyIndexDefinition>
primaryKeyFullTextDefinition(DataField textColumn) {
+ if (table.coreOptions().dataEvolutionEnabled()) {
+ return Optional.empty();
+ }
+ if (table.coreOptions().primaryKeyFullTextIndexColumns().isEmpty()) {
+ return Optional.empty();
+ }
+ for (PrimaryKeyIndexDefinition definition :
+
PrimaryKeyIndexDefinitions.create(table.schema()).definitions()) {
+ if (definition.family() ==
PrimaryKeyIndexDefinition.Family.FULL_TEXT
+ && definition.fieldId() == textColumn.id()) {
+ return Optional.of(definition);
+ }
+ }
+ return Optional.empty();
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilderImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilderImpl.java
index 6f5983ef15..48531ca358 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilderImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/HybridSearchBuilderImpl.java
@@ -200,6 +200,11 @@ public class HybridSearchBuilderImpl implements
HybridSearchBuilder {
table.newFullTextSearchBuilder()
.withQuery(route.fieldName(), route.fullTextQuery())
.withLimit(route.limit());
+ if (fullTextSearchBuilder.newFullTextScan() instanceof
PrimaryKeyFullTextScan) {
+ throw new UnsupportedOperationException(
+ "Hybrid search does not support primary-key full-text
indexes because their "
+ + "results use physical file positions instead of
global row ids.");
+ }
if (partitionFilter != null) {
fullTextSearchBuilder.withPartitionFilter(partitionFilter);
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextRead.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextRead.java
new file mode 100644
index 0000000000..3374141a33
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextRead.java
@@ -0,0 +1,224 @@
+/*
+ * 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.CoreOptions.GlobalIndexSearchMode;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexReadThreadPool;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.pk.PrimaryKeyIndexDefinition;
+import org.apache.paimon.index.pkfulltext.PkFullTextIndexFile;
+import org.apache.paimon.index.pkfulltext.PrimaryKeyFullTextBucketSearch;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.types.DataField;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+
+import static org.apache.paimon.CoreOptions.GLOBAL_INDEX_THREAD_NUM;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/** Reads compaction-visible primary-key full-text payloads in fast search
mode. */
+public class PrimaryKeyFullTextRead implements FullTextRead {
+
+ private final int limit;
+ private final BucketRankingSearch indexedSearch;
+
+ public PrimaryKeyFullTextRead(
+ FileStoreTable table,
+ PrimaryKeyIndexDefinition definition,
+ DataField textField,
+ String query,
+ int limit) {
+ checkArgument(limit > 0, "Full-text search limit must be positive:
%s.", limit);
+ checkArgument(
+ definition.family() ==
PrimaryKeyIndexDefinition.Family.FULL_TEXT,
+ "Primary-key full-text read requires a full-text definition.");
+ checkArgument(
+ definition.fieldId() == textField.id(),
+ "Full-text definition does not match field %s.",
+ textField.name());
+ checkFastSearchMode(table.coreOptions().globalIndexSearchMode());
+ ProductionSearch production =
+ new ProductionSearch(table, definition, textField, query,
limit);
+ this.limit = limit;
+ this.indexedSearch = production::searchIndexed;
+ }
+
+ PrimaryKeyFullTextRead(
+ GlobalIndexSearchMode searchMode, int limit, BucketRankingSearch
indexedSearch) {
+ checkArgument(limit > 0, "Full-text search limit must be positive:
%s.", limit);
+ checkFastSearchMode(searchMode);
+ this.limit = limit;
+ this.indexedSearch = indexedSearch;
+ }
+
+ private static void checkFastSearchMode(GlobalIndexSearchMode searchMode) {
+ if (searchMode != GlobalIndexSearchMode.FAST) {
+ throw new UnsupportedOperationException(
+ "Primary-key full-text search only supports the FAST
global-index search mode; "
+ + "FULL and DETAIL require merge-aware logical-row
fallback.");
+ }
+ }
+
+ @Override
+ public PrimaryKeyScoredResult read(FullTextScan.Plan plan) {
+ checkArgument(
+ plan instanceof PrimaryKeyFullTextScan.Plan,
+ "Primary-key full-text read requires a PrimaryKeyFullTextScan
plan.");
+ PrimaryKeyFullTextScan.Plan primaryKeyPlan =
(PrimaryKeyFullTextScan.Plan) plan;
+ return read(primaryKeyPlan.snapshotId(), primaryKeyPlan.splits());
+ }
+
+ @Override
+ public PrimaryKeyScoredResult read(List<FullTextSearchSplit> splits) {
+ if (splits.isEmpty()) {
+ return new PrimaryKeyScoredResult(0, Collections.emptyList(),
Collections.emptyList());
+ }
+ checkArgument(
+ splits.get(0) instanceof PrimaryKeyFullTextSearchSplit,
+ "Primary-key full-text read requires primary-key full-text
splits.");
+ long snapshotId = ((PrimaryKeyFullTextSearchSplit)
splits.get(0)).dataSplit().snapshotId();
+ return read(snapshotId, splits);
+ }
+
+ private PrimaryKeyScoredResult read(long snapshotId,
List<FullTextSearchSplit> splits) {
+ List<DataSplit> sourceSplits = new ArrayList<>(splits.size());
+ List<List<PrimaryKeySearchPosition>> rankings = new ArrayList<>();
+ for (FullTextSearchSplit searchSplit : splits) {
+ checkArgument(
+ searchSplit instanceof PrimaryKeyFullTextSearchSplit,
+ "Primary-key full-text read received an incompatible
split.");
+ PrimaryKeyFullTextSearchSplit split =
(PrimaryKeyFullTextSearchSplit) searchSplit;
+ checkArgument(
+ split.dataSplit().snapshotId() == snapshotId,
+ "Full-text bucket split snapshot does not match its
plan.");
+ sourceSplits.add(split.dataSplit());
+ rankings.addAll(indexedSearch.search(split));
+ }
+ List<PrimaryKeySearchPosition> positions =
+ rankings.isEmpty()
+ ? Collections.emptyList()
+ : PrimaryKeySearchRanker.topKByScore(rankings, limit);
+ return new PrimaryKeyScoredResult(snapshotId, sourceSplits, positions);
+ }
+
+ @FunctionalInterface
+ interface BucketRankingSearch {
+ List<List<PrimaryKeySearchPosition>>
search(PrimaryKeyFullTextSearchSplit split);
+ }
+
+ private static class ProductionSearch {
+
+ private final DataField textField;
+ private final String query;
+ private final int limit;
+ private final FileIO fileIO;
+ private final IndexFileHandler indexFileHandler;
+ private final ExecutorService executor;
+ private final GlobalIndexer indexer;
+ private final GlobalIndexFileReader archiveReader;
+
+ private ProductionSearch(
+ FileStoreTable table,
+ PrimaryKeyIndexDefinition definition,
+ DataField textField,
+ String query,
+ int limit) {
+ this.textField = textField;
+ this.query = checkNotNull(query, "Full-text query must not be
null.");
+ this.limit = limit;
+ this.fileIO = table.fileIO();
+ this.indexFileHandler = table.store().newIndexFileHandler();
+ this.executor =
+ GlobalIndexReadThreadPool.getExecutorService(
+
table.coreOptions().toConfiguration().get(GLOBAL_INDEX_THREAD_NUM));
+ this.indexer =
+ GlobalIndexer.create(
+ PkFullTextIndexFile.INDEX_TYPE, textField,
definition.options());
+ this.archiveReader = meta ->
fileIO.newInputStream(meta.filePath());
+ }
+
+ private List<List<PrimaryKeySearchPosition>> searchIndexed(
+ PrimaryKeyFullTextSearchSplit split) {
+ if (split.payloadFiles().isEmpty()) {
+ return Collections.emptyList();
+ }
+ return bucketSearch(split)
+ .searchRankings(
+ split,
+ deletionVectors(split.dataSplit()),
+ textField.name(),
+ query,
+ limit);
+ }
+
+ private PrimaryKeyFullTextBucketSearch
bucketSearch(PrimaryKeyFullTextSearchSplit split) {
+ PkFullTextIndexFile indexFile =
+ indexFileHandler.pkFullTextIndex(
+ split.dataSplit().partition(),
split.dataSplit().bucket());
+ return new PrimaryKeyFullTextBucketSearch(
+ payload -> {
+ GlobalIndexMeta meta =
checkNotNull(payload.globalIndexMeta());
+ GlobalIndexIOMeta ioMeta =
+ new GlobalIndexIOMeta(
+ indexFile.path(payload),
+ payload.fileSize(),
+ meta.indexMeta());
+ GlobalIndexReader reader =
+ indexer.createReader(
+ archiveReader,
Collections.singletonList(ioMeta), executor);
+ return reader;
+ });
+ }
+
+ private Map<String, DeletionVector> deletionVectors(DataSplit split) {
+ try {
+ DeletionVector.Factory factory =
+ DeletionVector.factory(
+ fileIO, split.dataFiles(),
split.deletionFiles().orElse(null));
+ Map<String, DeletionVector> result = new HashMap<>();
+ for (DataFileMeta file : split.dataFiles()) {
+ Optional<DeletionVector> deletionVector =
factory.create(file.fileName());
+ if (deletionVector.isPresent()) {
+ result.put(file.fileName(), deletionVector.get());
+ }
+ }
+ return result;
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read full-text
deletion vectors.", e);
+ }
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextScan.java
new file mode 100644
index 0000000000..d160523510
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextScan.java
@@ -0,0 +1,303 @@
+/*
+ * 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.Snapshot;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexDefinition;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourcePolicy;
+import org.apache.paimon.index.pkfulltext.PkFullTextBucketIndexState;
+import org.apache.paimon.index.pkfulltext.PkFullTextIndexFile;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.utils.Pair;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Plans compaction-visible full-text inputs from one captured primary-key
snapshot. */
+public class PrimaryKeyFullTextScan implements FullTextScan {
+
+ private final FileStoreTable table;
+ private final PrimaryKeyIndexDefinition definition;
+ @Nullable private final PartitionPredicate partitionFilter;
+
+ public PrimaryKeyFullTextScan(
+ FileStoreTable table,
+ PrimaryKeyIndexDefinition definition,
+ @Nullable PartitionPredicate partitionFilter) {
+ checkArgument(
+ definition.family() ==
PrimaryKeyIndexDefinition.Family.FULL_TEXT,
+ "Primary-key full-text scan requires a full-text index
definition.");
+ this.table = table;
+ this.definition = definition;
+ this.partitionFilter = partitionFilter;
+ }
+
+ @Override
+ public Plan scan() {
+ SnapshotReader snapshotReader = table.newSnapshotReader().keepStats();
+ DataTableScan dataScan = table.newScan(ignored -> snapshotReader);
+ checkArgument(
+ dataScan instanceof PrimaryKeyBatchScan,
+ "Primary-key full-text search requires a primary-key batch
scan.");
+ PrimaryKeyBatchScan batchScan = (PrimaryKeyBatchScan) dataScan;
+ if (partitionFilter != null) {
+ batchScan.withPartitionFilter(partitionFilter);
+ }
+ TableScan.Plan tablePlan = batchScan.planWithoutAuth();
+ if (!(tablePlan instanceof SnapshotReader.Plan)) {
+ checkArgument(
+ tablePlan.splits().isEmpty(),
+ "Primary-key full-text search requires a snapshot plan.");
+ return new Plan(0, Collections.emptyList());
+ }
+ SnapshotReader.Plan snapshotPlan = (SnapshotReader.Plan) tablePlan;
+ if (snapshotPlan.snapshotId() == null) {
+ return new Plan(0, Collections.emptyList());
+ }
+ Snapshot snapshot =
snapshotReader.snapshotManager().snapshot(snapshotPlan.snapshotId());
+ checkArgument(snapshot != null, "Primary-key full-text snapshot does
not exist.");
+
+ IndexFileHandler indexFileHandler = snapshotReader.indexFileHandler();
+ checkArgument(
+ indexFileHandler != null, "Primary-key full-text index handler
is unavailable.");
+ List<IndexManifestEntry> payloadEntries =
+ indexFileHandler.scan(
+ snapshot,
+ entry ->
+ matchesDefinition(entry)
+ && (partitionFilter == null
+ ||
partitionFilter.test(entry.partition())));
+ return plan(snapshot.id(), snapshotPlan.splits(), payloadEntries,
definition.fieldId());
+ }
+
+ private boolean matchesDefinition(IndexManifestEntry entry) {
+ IndexFileMeta payload = entry.indexFile();
+ GlobalIndexMeta globalMeta = payload.globalIndexMeta();
+ if (!PkFullTextIndexFile.INDEX_TYPE.equals(payload.indexType())
+ || globalMeta == null
+ || globalMeta.sourceMeta() == null
+ || globalMeta.indexFieldId() != definition.fieldId()) {
+ return false;
+ }
+ return true;
+ }
+
+ static Plan plan(
+ long snapshotId,
+ List<? extends Split> dataSplits,
+ List<IndexManifestEntry> payloadEntries,
+ int textFieldId) {
+ Map<Pair<BinaryRow, Integer>, List<IndexFileMeta>> payloads = new
LinkedHashMap<>();
+ for (IndexManifestEntry entry : payloadEntries) {
+ checkArgument(
+ entry.kind() == FileKind.ADD,
+ "Primary-key full-text index file %s is not active.",
+ entry.indexFile().fileName());
+ Pair<BinaryRow, Integer> key = Pair.of(entry.partition(),
entry.bucket());
+ payloads.computeIfAbsent(key, ignored -> new
ArrayList<>()).add(entry.indexFile());
+ }
+
+ Map<Pair<BinaryRow, Integer>, BucketAccumulator> buckets = new
LinkedHashMap<>();
+ for (Split split : dataSplits) {
+ DataSplit dataSplit = unwrapDataSplit(split);
+ checkArgument(
+ dataSplit.snapshotId() == snapshotId,
+ "Data split snapshot %s does not match full-text scan
snapshot %s.",
+ dataSplit.snapshotId(),
+ snapshotId);
+ checkArgument(
+ !dataSplit.isStreaming(),
+ "Primary-key full-text search requires a batch split.");
+ if (dataSplit.bucket() < 0) {
+ continue;
+ }
+ Pair<BinaryRow, Integer> key = Pair.of(dataSplit.partition(),
dataSplit.bucket());
+ BucketAccumulator accumulator = buckets.get(key);
+ if (accumulator == null) {
+ accumulator = new BucketAccumulator(dataSplit);
+ buckets.put(key, accumulator);
+ }
+ accumulator.add(dataSplit);
+ }
+
+ List<PrimaryKeyFullTextSearchSplit> result = new ArrayList<>();
+ for (Map.Entry<Pair<BinaryRow, Integer>, BucketAccumulator> entry :
buckets.entrySet()) {
+ BucketAccumulator bucket = entry.getValue();
+ if (bucket.isEmpty()) {
+ continue;
+ }
+ PkFullTextBucketIndexState state =
+ PkFullTextBucketIndexState.fromActivePayloads(
+ textFieldId,
+ payloads.getOrDefault(entry.getKey(),
Collections.emptyList()));
+ Set<String> activeSources = bucket.dataFileNames();
+ Map<String, IndexFileMeta> currentPayloads = new LinkedHashMap<>();
+ Set<String> covered = new HashSet<>();
+ for (Map.Entry<String, IndexFileMeta> payload :
+ state.payloadBySourceFile().entrySet()) {
+ if (activeSources.contains(payload.getKey())) {
+ currentPayloads.put(payload.getValue().fileName(),
payload.getValue());
+ covered.add(payload.getKey());
+ }
+ }
+ List<String> uncovered = new ArrayList<>();
+ for (DataFileMeta dataFile : bucket.dataFiles()) {
+ if (!covered.contains(dataFile.fileName())) {
+ uncovered.add(dataFile.fileName());
+ }
+ }
+ result.add(
+ new PrimaryKeyFullTextSearchSplit(
+ bucket.build(), new
ArrayList<>(currentPayloads.values()), uncovered));
+ }
+ return new Plan(snapshotId, result);
+ }
+
+ private static DataSplit unwrapDataSplit(Split split) {
+ if (split instanceof IndexedSplit) {
+ return ((IndexedSplit) split).dataSplit();
+ }
+ checkArgument(
+ split instanceof DataSplit,
+ "Unsupported primary-key full-text source split: %s.",
+ split.getClass().getName());
+ return (DataSplit) split;
+ }
+
+ /** Immutable snapshot full-text plan. */
+ public static class Plan implements FullTextScan.Plan {
+
+ private final long snapshotId;
+ private final List<FullTextSearchSplit> splits;
+
+ private Plan(long snapshotId, List<PrimaryKeyFullTextSearchSplit>
splits) {
+ this.snapshotId = snapshotId;
+ this.splits = Collections.unmodifiableList(new
ArrayList<FullTextSearchSplit>(splits));
+ }
+
+ public long snapshotId() {
+ return snapshotId;
+ }
+
+ @Override
+ public List<FullTextSearchSplit> splits() {
+ return splits;
+ }
+ }
+
+ private static class BucketAccumulator {
+
+ private final long snapshotId;
+ private final BinaryRow partition;
+ private final int bucket;
+ private final String bucketPath;
+ @Nullable private final Integer totalBuckets;
+ private final List<DataFileMeta> dataFiles = new ArrayList<>();
+ private final List<DeletionFile> deletionFiles = new ArrayList<>();
+ private final Set<String> dataFileNames = new HashSet<>();
+ private boolean hasDeletionMetadata;
+
+ private BucketAccumulator(DataSplit split) {
+ this.snapshotId = split.snapshotId();
+ this.partition = split.partition();
+ this.bucket = split.bucket();
+ this.bucketPath = split.bucketPath();
+ this.totalBuckets = split.totalBuckets();
+ }
+
+ private void add(DataSplit split) {
+ checkArgument(
+ snapshotId == split.snapshotId()
+ && partition.equals(split.partition())
+ && bucket == split.bucket()
+ && bucketPath.equals(split.bucketPath()),
+ "Cannot combine data splits from different snapshot
buckets.");
+ checkArgument(
+ totalBuckets == null
+ ? split.totalBuckets() == null
+ : totalBuckets.equals(split.totalBuckets()),
+ "Bucket split total-bucket metadata is inconsistent.");
+ List<DeletionFile> splitDeletions =
split.deletionFiles().orElse(null);
+ checkArgument(
+ splitDeletions == null || splitDeletions.size() ==
split.dataFiles().size(),
+ "Deletion files must align with data files in a full-text
bucket split.");
+ hasDeletionMetadata |= splitDeletions != null;
+ for (int i = 0; i < split.dataFiles().size(); i++) {
+ DataFileMeta file = split.dataFiles().get(i);
+ if (!PrimaryKeyIndexSourcePolicy.shouldRead(file)) {
+ continue;
+ }
+ checkArgument(
+ dataFileNames.add(file.fileName()),
+ "Data file %s appears more than once in full-text
bucket planning.",
+ file.fileName());
+ dataFiles.add(file);
+ deletionFiles.add(splitDeletions == null ? null :
splitDeletions.get(i));
+ }
+ }
+
+ private boolean isEmpty() {
+ return dataFiles.isEmpty();
+ }
+
+ private List<DataFileMeta> dataFiles() {
+ return dataFiles;
+ }
+
+ private Set<String> dataFileNames() {
+ return dataFileNames;
+ }
+
+ private DataSplit build() {
+ DataSplit.Builder builder =
+ DataSplit.builder()
+ .withSnapshot(snapshotId)
+ .withPartition(partition)
+ .withBucket(bucket)
+ .withBucketPath(bucketPath)
+ .withTotalBuckets(totalBuckets)
+ .withDataFiles(dataFiles)
+ .isStreaming(false)
+ .rawConvertible(false);
+ if (hasDeletionMetadata) {
+ builder.withDataDeletionFiles(deletionFiles);
+ }
+ return builder.build();
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextSearchSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextSearchSplit.java
new file mode 100644
index 0000000000..8a5bd09963
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyFullTextSearchSplit.java
@@ -0,0 +1,167 @@
+/*
+ * 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.index.IndexFileMeta;
+import org.apache.paimon.index.IndexFileMetaSerializer;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataInputViewStreamWrapper;
+import org.apache.paimon.io.DataOutputViewStreamWrapper;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Compaction-visible data files and full-text payloads for one snapshot
bucket. */
+public class PrimaryKeyFullTextSearchSplit extends FullTextSearchSplit {
+
+ private static final long serialVersionUID = 1L;
+ private static final int VERSION = 1;
+
+ private DataSplit dataSplit;
+ private transient List<IndexFileMeta> payloadFiles;
+ private List<String> uncoveredDataFiles;
+
+ public PrimaryKeyFullTextSearchSplit(
+ DataSplit dataSplit,
+ List<IndexFileMeta> payloadFiles,
+ List<String> uncoveredDataFiles) {
+ checkArgument(
+ !dataSplit.isStreaming(), "Primary-key full-text search
requires a batch split.");
+ Set<String> sourceFiles = new HashSet<>();
+ for (DataFileMeta dataFile : dataSplit.dataFiles()) {
+ checkArgument(
+ sourceFiles.add(dataFile.fileName()),
+ "Data file %s appears more than once in a full-text bucket
split.",
+ dataFile.fileName());
+ }
+
+ Set<String> covered = new HashSet<>();
+ for (IndexFileMeta payload : payloadFiles) {
+ boolean coversActiveSource = false;
+ for (PrimaryKeyIndexSourceFile source :
+
PrimaryKeyIndexSourceMeta.fromIndexFile(payload).sourceFiles()) {
+ if (!sourceFiles.contains(source.fileName())) {
+ continue;
+ }
+ coversActiveSource = true;
+ checkArgument(
+ covered.add(source.fileName()),
+ "Data file %s is covered by more than one full-text
payload.",
+ source.fileName());
+ }
+ checkArgument(
+ coversActiveSource,
+ "Full-text payload %s does not cover an active data file
in its bucket split.",
+ payload.fileName());
+ }
+
+ Set<String> uncovered = new HashSet<>();
+ for (String source : uncoveredDataFiles) {
+ checkArgument(
+ sourceFiles.contains(source),
+ "Uncovered full-text data file %s is outside its bucket
split.",
+ source);
+ checkArgument(
+ uncovered.add(source),
+ "Uncovered full-text data file %s appears more than once.",
+ source);
+ checkArgument(
+ !covered.contains(source),
+ "Data file %s cannot be both indexed and uncovered.",
+ source);
+ }
+ checkArgument(
+ covered.size() + uncovered.size() == sourceFiles.size(),
+ "Every full-text source file must be indexed or explicitly
uncovered.");
+
+ this.dataSplit = dataSplit;
+ this.payloadFiles = Collections.unmodifiableList(new
ArrayList<>(payloadFiles));
+ this.uncoveredDataFiles = Collections.unmodifiableList(new
ArrayList<>(uncoveredDataFiles));
+ }
+
+ public DataSplit dataSplit() {
+ return dataSplit;
+ }
+
+ public List<IndexFileMeta> payloadFiles() {
+ return payloadFiles;
+ }
+
+ public List<String> uncoveredDataFiles() {
+ return uncoveredDataFiles;
+ }
+
+ private void writeObject(ObjectOutputStream out) throws IOException {
+ out.defaultWriteObject();
+ out.writeInt(VERSION);
+ out.writeInt(payloadFiles.size());
+ IndexFileMetaSerializer serializer = new IndexFileMetaSerializer();
+ for (IndexFileMeta payloadFile : payloadFiles) {
+ serializer.serialize(payloadFile, new
DataOutputViewStreamWrapper(out));
+ }
+ }
+
+ private void readObject(ObjectInputStream in) throws IOException,
ClassNotFoundException {
+ in.defaultReadObject();
+ int version = in.readInt();
+ if (version != VERSION) {
+ throw new IOException("Unsupported PrimaryKeyFullTextSearchSplit
version: " + version);
+ }
+ int payloadFileCount = in.readInt();
+ if (payloadFileCount < 0) {
+ throw new IOException("Negative primary-key full-text payload file
count.");
+ }
+ List<IndexFileMeta> payloads = new ArrayList<>(payloadFileCount);
+ IndexFileMetaSerializer serializer = new IndexFileMetaSerializer();
+ for (int i = 0; i < payloadFileCount; i++) {
+ payloads.add(serializer.deserialize(new
DataInputViewStreamWrapper(in)));
+ }
+ this.payloadFiles = Collections.unmodifiableList(payloads);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ PrimaryKeyFullTextSearchSplit that = (PrimaryKeyFullTextSearchSplit) o;
+ return Objects.equals(dataSplit, that.dataSplit)
+ && Objects.equals(payloadFiles, that.payloadFiles)
+ && Objects.equals(uncoveredDataFiles, that.uncoveredDataFiles);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(dataSplit, payloadFiles, uncoveredDataFiles);
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchRanker.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchRanker.java
index 779a64a400..d4fab665eb 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchRanker.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchRanker.java
@@ -44,6 +44,35 @@ public final class PrimaryKeySearchRanker {
private PrimaryKeySearchRanker() {}
+ /** Selects globally highest-scored physical positions without rewriting
their scores. */
+ public static List<PrimaryKeySearchPosition> topKByScore(
+ List<List<PrimaryKeySearchPosition>> rankings, int limit) {
+ checkArgument(limit > 0, "Search result limit must be positive: %s.",
limit);
+ Map<PrimaryKeySearchPosition, PrimaryKeySearchPosition> unique = new
HashMap<>();
+ for (List<PrimaryKeySearchPosition> ranking : rankings) {
+ for (PrimaryKeySearchPosition position : ranking) {
+ PrimaryKeySearchPosition previous = unique.get(position);
+ if (previous == null || LOCAL_BEST_FIRST.compare(position,
previous) < 0) {
+ unique.put(position, position);
+ }
+ }
+ }
+
+ PriorityQueue<PrimaryKeySearchPosition> topK =
+ new PriorityQueue<>(limit, LOCAL_BEST_FIRST.reversed());
+ for (PrimaryKeySearchPosition position : unique.values()) {
+ if (topK.size() < limit) {
+ topK.add(position);
+ } else if (LOCAL_BEST_FIRST.compare(position, topK.peek()) < 0) {
+ topK.poll();
+ topK.add(position);
+ }
+ }
+ List<PrimaryKeySearchPosition> result = new ArrayList<>(topK);
+ result.sort(LOCAL_BEST_FIRST);
+ return Collections.unmodifiableList(result);
+ }
+
public static List<PrimaryKeySearchPosition> rrf(
List<List<PrimaryKeySearchPosition>> rankings, int limit) {
List<Ranking> weighted = new ArrayList<>(rankings.size());
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PrimaryKeyFullTextBucketSearchTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PrimaryKeyFullTextBucketSearchTest.java
new file mode 100644
index 0000000000..09d0df4a15
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PrimaryKeyFullTextBucketSearchTest.java
@@ -0,0 +1,416 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.index.pkfulltext;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.FullTextSearch;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.PrimaryKeyFullTextSearchSplit;
+import org.apache.paimon.table.source.PrimaryKeySearchPosition;
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+/** Tests payload-local full-text search and cross-payload score merging. */
+class PrimaryKeyFullTextBucketSearchTest {
+
+ @Test
+ void testFiltersDeletedRowsAndSelectsGlobalScores() {
+ PrimaryKeyFullTextSearchSplit split = split();
+ BitmapDeletionVector deletionVector = new BitmapDeletionVector();
+ deletionVector.delete(0);
+ Map<String, DeletionVector> deletionVectors =
Collections.singletonMap("a", deletionVector);
+ Map<String, AtomicInteger> closes = new HashMap<>();
+
+ PrimaryKeyFullTextBucketSearch search =
+ new PrimaryKeyFullTextBucketSearch(
+ payload -> {
+ String source =
+
PrimaryKeyIndexSourceMeta.fromIndexFile(payload)
+ .sourceFile()
+ .fileName();
+ closes.put(source, new AtomicInteger());
+ if (source.equals("a")) {
+ return reader(scores(0, 10F, 1, 9F, 2, 8F),
closes.get(source));
+ }
+ return reader(scores(0, 4F), closes.get(source));
+ });
+
+ List<PrimaryKeySearchPosition> result =
+ search.search(split, deletionVectors, "content", "hello", 3);
+
+ assertThat(result)
+ .extracting(
+ PrimaryKeySearchPosition::dataFileName,
+ PrimaryKeySearchPosition::rowPosition)
+ .containsExactly(tuple("a", 1L), tuple("a", 2L), tuple("b",
0L));
+
assertThat(result).extracting(PrimaryKeySearchPosition::score).containsExactly(9F,
8F, 4F);
+ assertThat(closes.get("a")).hasValue(1);
+ assertThat(closes.get("b")).hasValue(1);
+ }
+
+ @Test
+ void testOrdersEachPayloadByScore() {
+ AtomicInteger closes = new AtomicInteger();
+ PrimaryKeyFullTextBucketSearch search =
+ new PrimaryKeyFullTextBucketSearch(
+ payload -> reader(scores(0, 1F, 2, 10F), closes));
+ PrimaryKeyFullTextSearchSplit split =
+ new PrimaryKeyFullTextSearchSplit(
+ dataSplit(Collections.singletonList(dataFile("a"))),
+ Collections.singletonList(payload("a", "index-a")),
+ Collections.emptyList());
+
+ List<List<PrimaryKeySearchPosition>> rankings =
+ search.searchRankings(split, Collections.emptyMap(),
"content", "hello", 2);
+
+ assertThat(rankings).hasSize(1);
+ assertThat(rankings.get(0))
+ .extracting(PrimaryKeySearchPosition::rowPosition,
PrimaryKeySearchPosition::score)
+ .containsExactly(tuple(2L, 10F), tuple(0L, 1F));
+ assertThat(closes).hasValue(1);
+ }
+
+ @Test
+ void testMapsMultiSourceRowsAndShiftsDeletionVectors() {
+ BitmapDeletionVector deletionVector = new BitmapDeletionVector();
+ deletionVector.delete(1);
+ AtomicInteger closes = new AtomicInteger();
+ PrimaryKeyFullTextBucketSearch search =
+ new PrimaryKeyFullTextBucketSearch(
+ ignored -> reader(scores(0, 10F, 3, 9F, 4, 8F, 5, 7F),
closes));
+ PrimaryKeyFullTextSearchSplit split =
+ new PrimaryKeyFullTextSearchSplit(
+ dataSplit(Arrays.asList(dataFile("a"), dataFile("b"))),
+ Collections.singletonList(payload(Arrays.asList("a",
"b"), "index-ab")),
+ Collections.emptyList());
+
+ List<List<PrimaryKeySearchPosition>> rankings =
+ search.searchRankings(
+ split,
+ Collections.singletonMap("b", deletionVector),
+ "content",
+ "hello",
+ 4);
+
+ assertThat(rankings).hasSize(1);
+ assertThat(rankings.get(0))
+ .extracting(
+ PrimaryKeySearchPosition::dataFileName,
+ PrimaryKeySearchPosition::rowPosition,
+ PrimaryKeySearchPosition::score)
+ .containsExactly(tuple("a", 0L, 10F), tuple("b", 0L, 9F),
tuple("b", 2L, 7F));
+ assertThat(closes).hasValue(1);
+ }
+
+ @Test
+ void testBuildsDeletionVectorIncludeWithoutEnumeratingLiveRows() {
+ long rowCount = 1_000_000_000L;
+ BitmapDeletionVector deletionVector = new BitmapDeletionVector();
+ deletionVector.delete(1);
+ AtomicInteger closes = new AtomicInteger();
+ PrimaryKeyFullTextBucketSearch search =
+ new PrimaryKeyFullTextBucketSearch(
+ ignored -> reader(Collections.emptyMap(), closes));
+ PrimaryKeyFullTextSearchSplit split =
+ new PrimaryKeyFullTextSearchSplit(
+ dataSplit(Collections.singletonList(dataFile("large",
rowCount))),
+ Collections.singletonList(payload("large",
"index-large", rowCount)),
+ Collections.emptyList());
+
+ assertTimeoutPreemptively(
+ Duration.ofSeconds(1),
+ () ->
+ search.searchRankings(
+ split,
+ Collections.singletonMap("large",
deletionVector),
+ "content",
+ "hello",
+ 1));
+ assertThat(closes).hasValue(1);
+ }
+
+ @Test
+ void testClosesReaderAfterFailedSearch() {
+ AtomicInteger closes = new AtomicInteger();
+ PrimaryKeyFullTextBucketSearch search =
+ new PrimaryKeyFullTextBucketSearch(
+ payload ->
+ new FullTextOnlyReader() {
+ @Override
+ public
CompletableFuture<Optional<ScoredGlobalIndexResult>>
+ visitFullTextSearch(FullTextSearch
fullTextSearch) {
+
CompletableFuture<Optional<ScoredGlobalIndexResult>>
+ failed = new
CompletableFuture<>();
+ failed.completeExceptionally(new
IOException("broken"));
+ return failed;
+ }
+
+ @Override
+ public void close() {
+ closes.incrementAndGet();
+ }
+ });
+
+ assertThatThrownBy(
+ () ->
+ search.search(
+ new PrimaryKeyFullTextSearchSplit(
+
dataSplit(Collections.singletonList(dataFile("a"))),
+
Collections.singletonList(payload("a", "index-a")),
+ Collections.emptyList()),
+ Collections.emptyMap(),
+ "content",
+ "hello",
+ 1))
+ .isInstanceOf(CompletionException.class)
+ .hasRootCauseMessage("broken");
+ assertThat(closes).hasValue(1);
+ }
+
+ private static GlobalIndexReader reader(Map<Long, Float> scores,
AtomicInteger closeCounter) {
+ return new FullTextOnlyReader() {
+ @Override
+ public CompletableFuture<Optional<ScoredGlobalIndexResult>>
visitFullTextSearch(
+ FullTextSearch fullTextSearch) {
+ RoaringNavigableMap64 rows = new RoaringNavigableMap64();
+ RoaringNavigableMap64 include = fullTextSearch.includeRowIds();
+ for (Long row : scores.keySet()) {
+ if (include == null || include.contains(row)) {
+ rows.add(row);
+ }
+ }
+ return CompletableFuture.completedFuture(
+ Optional.of(ScoredGlobalIndexResult.create(rows,
scores::get)));
+ }
+
+ @Override
+ public void close() {
+ closeCounter.incrementAndGet();
+ }
+ };
+ }
+
+ private abstract static class FullTextOnlyReader implements
GlobalIndexReader {
+
+ private CompletableFuture<Optional<GlobalIndexResult>> emptyResult() {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNotNull(FieldRef fieldRef) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNull(FieldRef fieldRef) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitStartsWith(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitEndsWith(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitContains(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitLike(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitLessThan(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitGreaterOrEqual(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitNotEqual(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitLessOrEqual(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitEqual(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitGreaterThan(
+ FieldRef fieldRef, Object literal) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitIn(
+ FieldRef fieldRef, List<Object> literals) {
+ return emptyResult();
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitNotIn(
+ FieldRef fieldRef, List<Object> literals) {
+ return emptyResult();
+ }
+ }
+
+ private static Map<Long, Float> scores(Object... pairs) {
+ Map<Long, Float> scores = new LinkedHashMap<>();
+ for (int i = 0; i < pairs.length; i += 2) {
+ scores.put(((Number) pairs[i]).longValue(), ((Number) pairs[i +
1]).floatValue());
+ }
+ return scores;
+ }
+
+ private static PrimaryKeyFullTextSearchSplit split() {
+ List<DataFileMeta> dataFiles = Arrays.asList(dataFile("a"),
dataFile("b"));
+ return new PrimaryKeyFullTextSearchSplit(
+ dataSplit(dataFiles),
+ Arrays.asList(payload("a", "index-a"), payload("b",
"index-b")),
+ Collections.emptyList());
+ }
+
+ private static DataSplit dataSplit(List<DataFileMeta> dataFiles) {
+ return DataSplit.builder()
+ .withSnapshot(11)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withTotalBuckets(1)
+ .withDataFiles(dataFiles)
+ .build();
+ }
+
+ private static DataFileMeta dataFile(String name) {
+ return dataFile(name, 3);
+ }
+
+ private static DataFileMeta dataFile(String name, long rowCount) {
+ return DataFileMeta.create(
+ name,
+ 100,
+ rowCount,
+ BinaryRow.EMPTY_ROW,
+ BinaryRow.EMPTY_ROW,
+ SimpleStats.EMPTY_STATS,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 0,
+ 0,
+ 1,
+ Collections.emptyList(),
+ 0L,
+ null,
+ FileSource.COMPACT,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ private static IndexFileMeta payload(String source, String name) {
+ return payload(Collections.singletonList(source), name);
+ }
+
+ private static IndexFileMeta payload(String source, String name, long
rowCount) {
+ byte[] sourceMeta =
+ new PrimaryKeyIndexSourceMeta(new
PrimaryKeyIndexSourceFile(source, rowCount))
+ .serialize();
+ return new IndexFileMeta(
+ "full-text",
+ name,
+ 100,
+ rowCount,
+ new GlobalIndexMeta(0, rowCount - 1, 7, null, null,
sourceMeta),
+ null);
+ }
+
+ private static IndexFileMeta payload(List<String> sources, String name) {
+ List<PrimaryKeyIndexSourceFile> sourceFiles = new
java.util.ArrayList<>();
+ for (String source : sources) {
+ sourceFiles.add(new PrimaryKeyIndexSourceFile(source, 3));
+ }
+ byte[] sourceMeta = new
PrimaryKeyIndexSourceMeta(sourceFiles).serialize();
+ long rowCount = 3L * sourceFiles.size();
+ return new IndexFileMeta(
+ "full-text",
+ name,
+ 100,
+ rowCount,
+ new GlobalIndexMeta(0, rowCount - 1, 7, null, null,
sourceMeta),
+ null);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
index c092932ef7..c351f8691a 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
@@ -34,6 +34,8 @@ import
org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
import
org.apache.paimon.globalindex.testfulltext.TestFullTextGlobalIndexerFactory;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
import org.apache.paimon.io.CompactIncrement;
import org.apache.paimon.io.DataIncrement;
import org.apache.paimon.options.Options;
@@ -718,7 +720,8 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
// The reader's projected row layout follows this order, so these are
different index
// definitions even though they contain the same field-id set.
- assertThat(FullTextScanImpl.sameIndexIdentity(titleThenBody,
bodyThenTitle)).isFalse();
+ assertThat(DataEvolutionFullTextScan.sameIndexIdentity(titleThenBody,
bodyThenTitle))
+ .isFalse();
}
@Test
@@ -743,6 +746,25 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
assertThat(searchBuilder.executeLocal().results().isEmpty()).isTrue();
}
+ @Test
+ public void testOrdinaryFullTextScanSkipsSourceBackedPrimaryKeyArchive()
throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] documents = {"Apache Paimon", "vector search"};
+ writeDocuments(table, documents);
+ buildAndCommitSourceBackedIndex(table, documents);
+
+ FullTextScan.Plan plan =
+ table.newFullTextSearchBuilder()
+ .withQuery(TEXT_FIELD_NAME, matchQuery("Paimon"))
+ .withLimit(2)
+ .newFullTextScan()
+ .scan();
+
+ assertThat(plan.splits()).isEmpty();
+ }
+
@Test
public void testFullTextSearchSplitSerialization() throws Exception {
createTableDefault();
@@ -904,6 +926,65 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
}
}
+ private void buildAndCommitSourceBackedIndex(FileStoreTable table,
String[] documents)
+ throws Exception {
+ Options options = table.coreOptions().toConfiguration();
+ DataField textField = table.rowType().getField(TEXT_FIELD_NAME);
+ GlobalIndexSingleColumnWriter writer =
+ (GlobalIndexSingleColumnWriter)
+ GlobalIndexBuilderUtils.createIndexWriter(
+ table,
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ textField,
+ options);
+ for (int i = 0; i < documents.length; i++) {
+ writer.write(documents[i], i);
+ }
+
+ List<IndexFileMeta> indexFiles =
+ GlobalIndexBuilderUtils.toIndexFileMetas(
+ table.fileIO(),
+ table.store().pathFactory().globalIndexFileFactory(),
+ table.coreOptions(),
+ new Range(0, documents.length - 1),
+ Collections.singletonList(textField),
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ writer.finish());
+ byte[] sourceMeta =
+ new PrimaryKeyIndexSourceMeta(
+ new PrimaryKeyIndexSourceFile("data-file",
documents.length))
+ .serialize();
+ List<IndexFileMeta> sourceBackedFiles = new ArrayList<>();
+ for (IndexFileMeta indexFile : indexFiles) {
+ GlobalIndexMeta meta = indexFile.globalIndexMeta();
+ sourceBackedFiles.add(
+ new IndexFileMeta(
+ indexFile.indexType(),
+ indexFile.fileName(),
+ indexFile.fileSize(),
+ indexFile.rowCount(),
+ new GlobalIndexMeta(
+ meta.rowRangeStart(),
+ meta.rowRangeEnd(),
+ meta.indexFieldId(),
+ meta.extraFieldIds(),
+ meta.indexMeta(),
+ sourceMeta),
+ indexFile.externalPath()));
+ }
+
+ CommitMessage message =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ null,
+ DataIncrement.indexIncrement(sourceBackedFiles),
+ CompactIncrement.emptyIncrement());
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(message));
+ }
+ }
+
/**
* Builds and commits a single full-text index file covering rows
[rowStart, rowStart+N-1].
* {@code indexFields} determines the index identity (primary = first
field, the rest are extra
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextReadTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextReadTest.java
new file mode 100644
index 0000000000..1be9fe7c10
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextReadTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.CoreOptions.GlobalIndexSearchMode;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.stats.SimpleStats;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+
+/** Tests PK full-text search modes and physical scored results. */
+class PrimaryKeyFullTextReadTest {
+
+ @Test
+ void testFastPropagatesScores() {
+ PrimaryKeyFullTextRead read =
+ new PrimaryKeyFullTextRead(
+ GlobalIndexSearchMode.FAST,
+ 10,
+ split ->
+ Collections.singletonList(
+
Collections.singletonList(position("indexed", 1, 9F))));
+
+ PrimaryKeyScoredResult result =
read.read(Collections.singletonList(split()));
+
+ assertThat(result.snapshotId()).isEqualTo(11);
+ assertThat(result.positions())
+ .extracting(
+ PrimaryKeySearchPosition::dataFileName,
+ PrimaryKeySearchPosition::rowPosition)
+ .containsExactly(tuple("indexed", 1L));
+ assertThat(result.positions().get(0).score()).isEqualTo(9F);
+ IndexedSplit indexedSplit = result.splits().get(0);
+ assertThat(indexedSplit.scores()).containsExactly(9F);
+ }
+
+ @Test
+ void testSelectsGlobalTopKByFullTextScore() {
+ PrimaryKeyFullTextRead read =
+ new PrimaryKeyFullTextRead(
+ GlobalIndexSearchMode.FAST,
+ 2,
+ split ->
+ Arrays.asList(
+ Arrays.asList(
+ position("indexed", 0, 100F),
+ position("indexed", 1, 99F)),
+
Collections.singletonList(position("raw", 0, 1F))));
+
+ PrimaryKeyScoredResult result =
read.read(Collections.singletonList(split()));
+
+ assertThat(result.positions())
+ .extracting(
+ PrimaryKeySearchPosition::dataFileName,
+ PrimaryKeySearchPosition::rowPosition,
+ PrimaryKeySearchPosition::score)
+ .containsExactly(tuple("indexed", 0L, 100F), tuple("indexed",
1L, 99F));
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = GlobalIndexSearchMode.class,
+ names = {"FULL", "DETAIL"})
+ void testRejectsIncompleteSearchModes(GlobalIndexSearchMode mode) {
+ assertThatThrownBy(
+ () ->
+ new PrimaryKeyFullTextRead(
+ mode, 10, split ->
Collections.emptyList()))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("only supports the FAST global-index
search mode");
+ }
+
+ private static PrimaryKeySearchPosition position(
+ String dataFile, long rowPosition, float score) {
+ return new PrimaryKeySearchPosition(BinaryRow.EMPTY_ROW, 0, dataFile,
rowPosition, score);
+ }
+
+ private static PrimaryKeyFullTextSearchSplit split() {
+ List<DataFileMeta> dataFiles = Arrays.asList(dataFile("indexed"),
dataFile("raw"));
+ DataSplit dataSplit =
+ DataSplit.builder()
+ .withSnapshot(11)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withTotalBuckets(1)
+ .withDataFiles(dataFiles)
+ .build();
+ return new PrimaryKeyFullTextSearchSplit(
+ dataSplit,
+ Collections.singletonList(payload("indexed")),
+ Collections.singletonList("raw"));
+ }
+
+ private static DataFileMeta dataFile(String name) {
+ return DataFileMeta.create(
+ name,
+ 100,
+ 2,
+ BinaryRow.EMPTY_ROW,
+ BinaryRow.EMPTY_ROW,
+ SimpleStats.EMPTY_STATS,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 0,
+ 0,
+ 1,
+ Collections.emptyList(),
+ 0L,
+ null,
+ FileSource.COMPACT,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ private static IndexFileMeta payload(String source) {
+ byte[] sourceMeta =
+ new PrimaryKeyIndexSourceMeta(new
PrimaryKeyIndexSourceFile(source, 2)).serialize();
+ return new IndexFileMeta(
+ "full-text",
+ "index-" + source,
+ 100,
+ 2,
+ new GlobalIndexMeta(0, 1, 7, null, null, sourceMeta),
+ null);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextScanTest.java
new file mode 100644
index 0000000000..ca656e82b2
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextScanTest.java
@@ -0,0 +1,307 @@
+/*
+ * 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.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexDefinition;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Filter;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Answers.CALLS_REAL_METHODS;
+import static org.mockito.Answers.RETURNS_SELF;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Tests snapshot-consistent planning for primary-key full-text search. */
+class PrimaryKeyFullTextScanTest {
+
+ private static final int FIELD_ID = 7;
+
+ @Test
+ void testSeparatesCurrentCoverageAndExcludesIneligibleFiles() {
+ DataFileMeta indexed = dataFile("indexed", 1, FileSource.COMPACT);
+ DataFileMeta uncovered = dataFile("uncovered", 2, FileSource.COMPACT);
+ DataFileMeta levelZero = dataFile("level-zero", 0, FileSource.COMPACT);
+ DataFileMeta appended = dataFile("appended", 1, FileSource.APPEND);
+ DeletionFile indexedDv = new DeletionFile("indexed.dv", 0, 10, 1L);
+ DeletionFile uncoveredDv = new DeletionFile("uncovered.dv", 10, 10,
1L);
+ DataSplit source =
+ dataSplit(
+ Arrays.asList(indexed, uncovered, levelZero, appended),
+ Arrays.asList(indexedDv, uncoveredDv, null, null));
+
+ List<IndexManifestEntry> payloads =
+ Arrays.asList(
+ payloadEntry("indexed", FIELD_ID, "current"),
+ payloadEntry("uncovered", 8, "other-field"));
+
+ PrimaryKeyFullTextScan.Plan plan =
+ PrimaryKeyFullTextScan.plan(
+ 11, Collections.singletonList(source), payloads,
FIELD_ID);
+
+ assertThat(plan.snapshotId()).isEqualTo(11);
+ assertThat(plan.splits()).hasSize(1);
+ PrimaryKeyFullTextSearchSplit split = (PrimaryKeyFullTextSearchSplit)
plan.splits().get(0);
+ assertThat(split.dataSplit().dataFiles()).containsExactly(indexed,
uncovered);
+ assertThat(split.dataSplit().deletionFiles()).isPresent();
+
assertThat(split.dataSplit().deletionFiles().get()).containsExactly(indexedDv,
uncoveredDv);
+ assertThat(split.payloadFiles())
+ .extracting(IndexFileMeta::fileName)
+ .containsExactly("current");
+ assertThat(split.uncoveredDataFiles()).containsExactly("uncovered");
+ }
+
+ @Test
+ void testPlansMultiSourceArchiveOnce() {
+ DataFileMeta first = dataFile("data-1", 1, FileSource.COMPACT);
+ DataFileMeta second = dataFile("data-2", 1, FileSource.COMPACT);
+ IndexManifestEntry payload =
+ payloadEntry(Arrays.asList("data-1", "data-2"), FIELD_ID,
"multi-source");
+
+ PrimaryKeyFullTextScan.Plan plan =
+ PrimaryKeyFullTextScan.plan(
+ 11,
+
Collections.singletonList(dataSplit(Arrays.asList(first, second), null)),
+ Collections.singletonList(payload),
+ FIELD_ID);
+
+ PrimaryKeyFullTextSearchSplit split = (PrimaryKeyFullTextSearchSplit)
plan.splits().get(0);
+ assertThat(split.payloadFiles()).containsExactly(payload.indexFile());
+ assertThat(split.uncoveredDataFiles()).isEmpty();
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ void testCapturesOneSnapshotAndPrunesPartitions() {
+ FileStoreTable table = mock(FileStoreTable.class);
+ Snapshot snapshot = mock(Snapshot.class);
+ when(snapshot.id()).thenReturn(11L);
+ Options tableOptions = new Options();
+ tableOptions.set(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS, "content");
+ when(table.coreOptions()).thenReturn(new CoreOptions(tableOptions));
+
+ SnapshotReader reader = mock(SnapshotReader.class, RETURNS_SELF);
+ SnapshotReader.Plan snapshotPlan = mock(SnapshotReader.Plan.class,
CALLS_REAL_METHODS);
+ when(snapshotPlan.snapshotId()).thenReturn(11L);
+ when(snapshotPlan.splits())
+ .thenReturn(
+ Collections.singletonList(
+ dataSplit(
+ Collections.singletonList(
+ dataFile("indexed", 1,
FileSource.COMPACT)),
+ null)));
+ when(reader.read()).thenReturn(snapshotPlan);
+ when(table.newSnapshotReader()).thenReturn(reader);
+
+ PartitionPredicate partitionFilter = mock(PartitionPredicate.class);
+ when(partitionFilter.test(BinaryRow.EMPTY_ROW)).thenReturn(true);
+ PrimaryKeyIndexDefinition definition = definition();
+ List<IndexManifestEntry> entries =
+ Arrays.asList(
+ payloadEntry("indexed", FIELD_ID, "current"),
+ payloadEntry("indexed", FIELD_ID + 1, "other-field"));
+ IndexFileHandler indexFileHandler = mock(IndexFileHandler.class);
+ when(indexFileHandler.scan(eq(snapshot), any(Filter.class)))
+ .thenAnswer(
+ invocation -> {
+ Filter<IndexManifestEntry> filter =
invocation.getArgument(1);
+ List<IndexManifestEntry> filtered = new
ArrayList<>();
+ for (IndexManifestEntry entry : entries) {
+ if (filter.test(entry)) {
+ filtered.add(entry);
+ }
+ }
+ return filtered;
+ });
+ when(reader.indexFileHandler()).thenReturn(indexFileHandler);
+ configureBatchScan(table, reader, snapshot);
+
+ PrimaryKeyFullTextScan.Plan plan =
+ new PrimaryKeyFullTextScan(table, definition,
partitionFilter).scan();
+
+ assertThat(plan.snapshotId()).isEqualTo(11);
+ PrimaryKeyFullTextSearchSplit split = (PrimaryKeyFullTextSearchSplit)
plan.splits().get(0);
+ assertThat(split.payloadFiles())
+ .extracting(IndexFileMeta::fileName)
+ .containsExactly("current");
+ assertThat(split.uncoveredDataFiles()).isEmpty();
+ verify(reader).withPartitionFilter(partitionFilter);
+ verify(reader).indexFileHandler();
+ }
+
+ @Test
+ void testSplitSerialization() throws Exception {
+ PrimaryKeyFullTextSearchSplit split =
+ new PrimaryKeyFullTextSearchSplit(
+ dataSplit(
+ Arrays.asList(
+ dataFile("indexed", 1,
FileSource.COMPACT),
+ dataFile("raw", 1,
FileSource.COMPACT)),
+ Arrays.asList(new DeletionFile("indexed.dv",
0, 10, 1L), null)),
+ Collections.singletonList(
+ payloadEntry("indexed", FIELD_ID,
"current").indexFile()),
+ Collections.singletonList("raw"));
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {
+ output.writeObject(split);
+ }
+ PrimaryKeyFullTextSearchSplit restored;
+ try (ObjectInputStream input =
+ new ObjectInputStream(new
ByteArrayInputStream(bytes.toByteArray()))) {
+ restored = (PrimaryKeyFullTextSearchSplit) input.readObject();
+ }
+
+ assertThat(restored).isEqualTo(split);
+ }
+
+ private static PrimaryKeyIndexDefinition definition() {
+ return new PrimaryKeyIndexDefinition(
+ "content",
+ FIELD_ID,
+ "full-text",
+ new Options(),
+ PrimaryKeyIndexDefinition.Family.FULL_TEXT,
+ 2,
+ 0.5);
+ }
+
+ private static DataSplit dataSplit(
+ List<DataFileMeta> dataFiles, List<DeletionFile> deletionFiles) {
+ DataSplit.Builder builder =
+ DataSplit.builder()
+ .withSnapshot(11)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withTotalBuckets(1)
+ .withDataFiles(dataFiles);
+ if (deletionFiles != null) {
+ builder.withDataDeletionFiles(deletionFiles);
+ }
+ return builder.build();
+ }
+
+ private static IndexManifestEntry payloadEntry(
+ String sourceFile, int fieldId, String payloadFile) {
+ return payloadEntry(Collections.singletonList(sourceFile), fieldId,
payloadFile);
+ }
+
+ private static IndexManifestEntry payloadEntry(
+ List<String> sourceFiles, int fieldId, String payloadFile) {
+ List<PrimaryKeyIndexSourceFile> sources = new ArrayList<>();
+ for (String sourceFile : sourceFiles) {
+ sources.add(new PrimaryKeyIndexSourceFile(sourceFile, 2));
+ }
+ byte[] sourceMeta = new PrimaryKeyIndexSourceMeta(sources).serialize();
+ long rowCount = 2L * sources.size();
+ return new IndexManifestEntry(
+ FileKind.ADD,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ new IndexFileMeta(
+ "full-text",
+ payloadFile,
+ 100,
+ rowCount,
+ new GlobalIndexMeta(0, rowCount - 1, fieldId, null,
null, sourceMeta),
+ null));
+ }
+
+ private static void configureBatchScan(
+ FileStoreTable table, SnapshotReader snapshotReader, Snapshot
snapshot) {
+ TableSchema schema = mock(TableSchema.class);
+ when(schema.primaryKeys()).thenReturn(Collections.singletonList("id"));
+ when(schema.logicalRowType())
+ .thenReturn(RowType.of(new DataField(1, "id",
DataTypes.INT().notNull())));
+ when(table.schema()).thenReturn(schema);
+ when(table.schemaManager()).thenReturn(mock(SchemaManager.class));
+ SnapshotManager snapshotManager = mock(SnapshotManager.class);
+ when(snapshotManager.latestSnapshot()).thenReturn(snapshot);
+ when(snapshotManager.snapshot(snapshot.id())).thenReturn(snapshot);
+ when(snapshotReader.snapshotManager()).thenReturn(snapshotManager);
+ when(table.newScan(any(FileStoreTable.SnapshotReaderFactory.class)))
+ .thenAnswer(
+ invocation -> {
+ FileStoreTable.SnapshotReaderFactory factory =
+ invocation.getArgument(0);
+ return new PrimaryKeyBatchScan(
+ table, factory.create(table),
mock(TableQueryAuth.class), null);
+ });
+ }
+
+ private static DataFileMeta dataFile(String fileName, int level,
FileSource fileSource) {
+ return DataFileMeta.create(
+ fileName,
+ 100,
+ 2,
+ BinaryRow.EMPTY_ROW,
+ BinaryRow.EMPTY_ROW,
+ SimpleStats.EMPTY_STATS,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 0,
+ 0,
+ level,
+ Collections.emptyList(),
+ 0L,
+ null,
+ fileSource,
+ null,
+ null,
+ null,
+ null);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextSearchTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextSearchTest.java
new file mode 100644
index 0000000000..47a3d5344d
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyFullTextSearchTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.CoreOptions;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests Java API dispatch for configured primary-key full-text indexes. */
+class PrimaryKeyFullTextSearchTest {
+
+ @Test
+ void testDispatchesConfiguredFieldToPrimaryKeyScan() {
+ FileStoreTable table = table(false);
+
+ FullTextScan scan =
+ new FullTextSearchBuilderImpl(table)
+ .withQuery("content", "hello")
+ .withLimit(10)
+ .newFullTextScan();
+
+ assertThat(scan).isInstanceOf(PrimaryKeyFullTextScan.class);
+ }
+
+ @Test
+ void testOtherFieldRetainsGlobalFullTextPath() {
+ FileStoreTable table = table(false);
+
+ FullTextScan scan =
+ new FullTextSearchBuilderImpl(table)
+ .withQuery("other", "hello")
+ .withLimit(10)
+ .newFullTextScan();
+
+ assertThat(scan).isInstanceOf(DataEvolutionFullTextScan.class);
+ }
+
+ @Test
+ void testDataEvolutionRetainsGlobalFullTextPath() {
+ FileStoreTable table = table(true);
+
+ FullTextScan scan =
+ new FullTextSearchBuilderImpl(table)
+ .withQuery("content", "hello")
+ .withLimit(10)
+ .newFullTextScan();
+
+ assertThat(scan).isInstanceOf(DataEvolutionFullTextScan.class);
+ }
+
+ @Test
+ void testHybridRouteRejectsPrimaryKeyFullText() {
+ FileStoreTable table = table(false);
+
+ assertThatThrownBy(
+ () ->
+ new HybridSearchBuilderImpl(table)
+ .addFullTextRoute("content", "hello",
10, 1F)
+ .withLimit(10)
+ .routeBuilders())
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining(
+ "Hybrid search does not support primary-key full-text
indexes");
+ }
+
+ private static FileStoreTable table(boolean dataEvolution) {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.BUCKET.key(), "2");
+ options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), "content");
+ options.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
Boolean.toString(dataEvolution));
+ TableSchema schema =
+ new TableSchema(
+ 1,
+ Arrays.asList(
+ new DataField(0, "id",
DataTypes.INT().notNull()),
+ new DataField(1, "content",
DataTypes.STRING()),
+ new DataField(2, "other", DataTypes.STRING())),
+ 2,
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ options,
+ null);
+ FileStoreTable table = mock(FileStoreTable.class);
+ when(table.schema()).thenReturn(schema);
+ when(table.rowType()).thenReturn(schema.logicalRowType());
+ when(table.coreOptions()).thenReturn(new CoreOptions(options));
+ when(table.newFullTextSearchBuilder()).thenReturn(new
FullTextSearchBuilderImpl(table));
+ return table;
+ }
+}
diff --git
a/paimon-full-text/src/test/java/org/apache/paimon/fulltext/index/NativePrimaryKeyFullTextIndexTest.java
b/paimon-full-text/src/test/java/org/apache/paimon/fulltext/index/NativePrimaryKeyFullTextIndexTest.java
new file mode 100644
index 0000000000..567cb2a56e
--- /dev/null
+++
b/paimon-full-text/src/test/java/org/apache/paimon/fulltext/index/NativePrimaryKeyFullTextIndexTest.java
@@ -0,0 +1,267 @@
+/*
+ * 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.fulltext.index;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ResultEntry;
+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.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.index.pkfulltext.PrimaryKeyFullTextBucketSearch;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.PrimaryKeyFullTextSearchSplit;
+import org.apache.paimon.table.source.PrimaryKeySearchPosition;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.JsonSerdeUtil;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.core.type.TypeReference;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static
org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/** Native SPI tests for file-aligned primary-key full-text archives. */
+class NativePrimaryKeyFullTextIndexTest {
+
+ private static final DataField TEXT_FIELD = new DataField(7, "content",
DataTypes.STRING());
+
+ @TempDir java.nio.file.Path tempDir;
+
+ private FileIO fileIO;
+ private Path indexPath;
+
+ @BeforeEach
+ void before() {
+ assumeTrue(isNativeAvailable(), "Native full-text library not
available, skipping tests");
+ fileIO = LocalFileIO.create();
+ indexPath = new Path(tempDir.toUri());
+ }
+
+ @AfterEach
+ void after() throws IOException {
+ if (fileIO != null) {
+ fileIO.delete(indexPath, true);
+ }
+ }
+
+ @Test
+ void testPreservesNullOrdinalFiltersDeletedRowsAndOrdersByNativeScore()
throws Exception {
+ Options options = new Options();
+ IndexFileMeta archive =
+ buildArchive(
+ Arrays.asList(
+ BinaryString.fromString("paimon vector"),
+ null,
+ BinaryString.fromString("paimon lake"),
+ BinaryString.fromString("paimon storage")),
+ options);
+
+ assertThat(archive.indexType()).isEqualTo("full-text");
+ assertThat(archive.rowCount()).isEqualTo(4);
+ assertThat(archive.globalIndexMeta().rowRangeStart()).isZero();
+ assertThat(archive.globalIndexMeta().rowRangeEnd()).isEqualTo(3);
+
assertThat(archive.globalIndexMeta().indexFieldId()).isEqualTo(TEXT_FIELD.id());
+ PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(archive);
+ assertThat(sourceMeta.sourceFile().fileName()).isEqualTo("data-1");
+ assertThat(sourceMeta.sourceFile().rowCount()).isEqualTo(4);
+
+ DataFileMeta dataFile = dataFile(4);
+ PrimaryKeyFullTextSearchSplit split =
+ new PrimaryKeyFullTextSearchSplit(
+ DataSplit.builder()
+ .withSnapshot(1)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath(indexPath.toString())
+ .withTotalBuckets(1)
+
.withDataFiles(Collections.singletonList(dataFile))
+ .build(),
+ Collections.singletonList(archive),
+ Collections.emptyList());
+ BitmapDeletionVector deletionVector = new BitmapDeletionVector();
+ deletionVector.delete(2);
+ Map<String, DeletionVector> deletionVectors =
+ Collections.singletonMap(dataFile.fileName(), deletionVector);
+ GlobalIndexer indexer = GlobalIndexer.create("full-text", TEXT_FIELD,
options);
+ PrimaryKeyFullTextBucketSearch search =
+ new PrimaryKeyFullTextBucketSearch(
+ payload ->
+ indexer.createReader(
+ fileReader(),
+
Collections.singletonList(toIOMeta(payload)),
+ newDirectExecutorService()));
+
+ List<List<PrimaryKeySearchPosition>> rankings =
+ search.searchRankings(
+ split, deletionVectors, "content",
boostQuery("paimon", "vector", 0.1F), 2);
+
+ assertThat(rankings).hasSize(1);
+ assertThat(rankings.get(0))
+ .extracting(
+ PrimaryKeySearchPosition::rowPosition,
+ PrimaryKeySearchPosition::dataFileName)
+ .containsExactly(tuple(3L, "data-1"), tuple(0L, "data-1"));
+
assertThat(rankings.get(0).get(0).score()).isGreaterThan(rankings.get(0).get(1).score());
+ }
+
+ @Test
+ void testPersistsTokenizerOptionsInPkArchiveMetadata() throws Exception {
+ Options options = new Options();
+ options.set("full-text.tokenizer", "ngram");
+ options.set("full-text.ngram.min-gram", "2");
+ options.set("full-text.ngram.max-gram", "2");
+
+ IndexFileMeta archive =
+
buildArchive(Collections.singletonList(BinaryString.fromString("中文全文检索")),
options);
+
+ assertThat(serializedOptions(archive.globalIndexMeta().indexMeta()))
+ .containsEntry("tokenizer", "ngram")
+ .containsEntry("ngram.min-gram", "2")
+ .containsEntry("ngram.max-gram", "2");
+ }
+
+ private IndexFileMeta buildArchive(List<BinaryString> texts, Options
options) throws Exception {
+ GlobalIndexer indexer = GlobalIndexer.create("full-text", TEXT_FIELD,
options);
+ GlobalIndexSingleColumnWriter writer =
+ (GlobalIndexSingleColumnWriter)
indexer.createWriter(fileWriter());
+ for (int i = 0; i < texts.size(); i++) {
+ writer.write(texts.get(i), i);
+ }
+ List<ResultEntry> results = writer.finish();
+ assertThat(results).hasSize(1);
+ ResultEntry result = results.get(0);
+ Path path = new Path(indexPath, result.fileName());
+ return new IndexFileMeta(
+ "full-text",
+ result.fileName(),
+ fileIO.getFileSize(path),
+ result.rowCount(),
+ new GlobalIndexMeta(
+ 0,
+ texts.size() - 1,
+ TEXT_FIELD.id(),
+ null,
+ result.meta(),
+ new PrimaryKeyIndexSourceMeta(
+ new
PrimaryKeyIndexSourceFile("data-1", texts.size()))
+ .serialize()),
+ null);
+ }
+
+ private GlobalIndexFileWriter fileWriter() {
+ return new GlobalIndexFileWriter() {
+ @Override
+ public String newFileName(String prefix) {
+ return prefix + "-" + UUID.randomUUID();
+ }
+
+ @Override
+ public PositionOutputStream newOutputStream(String fileName)
throws IOException {
+ return fileIO.newOutputStream(new Path(indexPath, fileName),
false);
+ }
+ };
+ }
+
+ private GlobalIndexFileReader fileReader() {
+ return meta -> fileIO.newInputStream(meta.filePath());
+ }
+
+ private GlobalIndexIOMeta toIOMeta(IndexFileMeta archive) {
+ return new GlobalIndexIOMeta(
+ new Path(indexPath, archive.fileName()),
+ archive.fileSize(),
+ archive.globalIndexMeta().indexMeta());
+ }
+
+ private static DataFileMeta dataFile(long rowCount) {
+ return DataFileMeta.forAppend(
+ "data-1",
+ 100,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 1,
+ 1,
+ Collections.emptyList(),
+ null,
+ FileSource.COMPACT,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ private static Map<String, String> serializedOptions(byte[] metadata) {
+ return JsonSerdeUtil.fromJson(
+ new String(metadata, StandardCharsets.UTF_8),
+ new TypeReference<Map<String, String>>() {});
+ }
+
+ private static String boostQuery(String positive, String negative, float
negativeBoost) {
+ return "{\"boost\":{\"positive\":"
+ + matchQuery(positive)
+ + ",\"negative\":"
+ + matchQuery(negative)
+ + ",\"negative_boost\":"
+ + negativeBoost
+ + "}}";
+ }
+
+ private static String matchQuery(String terms) {
+ return "{\"match\":{\"query\":\"" + terms + "\"}}";
+ }
+
+ private static boolean isNativeAvailable() {
+ String path = System.getenv("PAIMON_FTINDEX_JNI_LIB_PATH");
+ return path != null && !path.isEmpty() && new File(path).isFile();
+ }
+}