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 31a3e0a293 [core] Add physical search and full-text archive primitives
(#8649)
31a3e0a293 is described below
commit 31a3e0a2931a8cb9627eb6c95daf126d90215fff
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 15 14:31:51 2026 +0800
[core] Add physical search and full-text archive primitives (#8649)
Introduce the reusable physical-position search model and immutable
full-text archive primitives needed by primary-key table indexes. This
isolates the storage and ranking foundation before exposing primary-key
full-text configuration or query APIs.
---
.../org/apache/paimon/index/IndexFileHandler.java | 5 +
.../pkfulltext/PkFullTextBucketIndexState.java | 113 +++++++++
.../index/pkfulltext/PkFullTextDataFileReader.java | 108 ++++++++
.../index/pkfulltext/PkFullTextIndexBuilder.java | 70 ++++++
.../index/pkfulltext/PkFullTextIndexFile.java | 233 +++++++++++++++++
.../operation/PrimaryKeyIndexedSplitRead.java | 4 +-
...der.java => PrimaryKeyIndexPositionReader.java} | 6 +-
...ctorResult.java => PrimaryKeyScoredResult.java} | 187 +++++++-------
.../table/source/PrimaryKeySearchPosition.java | 139 +++++++++++
.../table/source/PrimaryKeySearchRanker.java | 183 ++++++++++++++
.../source/PrimaryKeyVectorPositionReader.java | 76 +-----
.../table/source/PrimaryKeyVectorResult.java | 197 +++------------
.../paimon/table/source/PrimaryKeyVectorScan.java | 41 ++-
.../table/source/VectorSearchBuilderImpl.java | 12 +-
.../pkfulltext/PkFullTextBucketIndexStateTest.java | 102 ++++++++
.../pkfulltext/PkFullTextDataFileReaderTest.java | 155 ++++++++++++
.../index/pkfulltext/PkFullTextIndexFileTest.java | 278 +++++++++++++++++++++
.../operation/PrimaryKeyIndexedSplitReadTest.java | 4 +-
.../table/source/PrimaryKeyScoredResultTest.java | 96 +++++++
.../table/source/PrimaryKeySearchRankerTest.java | 103 ++++++++
.../source/PrimaryKeyVectorPositionReaderTest.java | 3 +-
.../table/source/PrimaryKeyVectorResultTest.java | 6 +-
.../table/source/PrimaryKeyVectorScanTest.java | 6 +-
23 files changed, 1783 insertions(+), 344 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
index 9fafedb7aa..8fa6554886 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileHandler.java
@@ -25,6 +25,7 @@ import org.apache.paimon.deletionvectors.DeletionVector;
import org.apache.paimon.deletionvectors.DeletionVectorsIndexFile;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.index.pkfulltext.PkFullTextIndexFile;
import org.apache.paimon.index.pksorted.PkSortedIndexFile;
import org.apache.paimon.index.pkvector.PkVectorAnnSegmentFile;
import org.apache.paimon.manifest.IndexManifestEntry;
@@ -90,6 +91,10 @@ public class IndexFileHandler {
return new PkVectorAnnSegmentFile(fileIO, pathFactories.get(partition,
bucket));
}
+ public PkFullTextIndexFile pkFullTextIndex(BinaryRow partition, int
bucket) {
+ return new PkFullTextIndexFile(fileIO, pathFactories.get(partition,
bucket));
+ }
+
public PkSortedIndexFile pkSortedIndex(BinaryRow partition, int bucket) {
return new PkSortedIndexFile(fileIO, pathFactories.get(partition,
bucket));
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextBucketIndexState.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextBucketIndexState.java
new file mode 100644
index 0000000000..01daa9f5e3
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextBucketIndexState.java
@@ -0,0 +1,113 @@
+/*
+ * 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.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+
+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;
+
+/** Immutable source-aligned primary-key full-text state for one bucket and
definition. */
+public final class PkFullTextBucketIndexState {
+
+ private final int textFieldId;
+ private final List<IndexFileMeta> currentPayloads;
+ private final List<IndexFileMeta> stalePayloads;
+ private final Map<String, IndexFileMeta> payloadBySourceFile;
+
+ public static PkFullTextBucketIndexState fromActivePayloads(
+ int textFieldId, List<IndexFileMeta> activePayloads) {
+ return new PkFullTextBucketIndexState(textFieldId, activePayloads);
+ }
+
+ public PkFullTextBucketIndexState(int textFieldId, List<IndexFileMeta>
activePayloads) {
+ this.textFieldId = textFieldId;
+
+ List<IndexFileMeta> current = new ArrayList<>();
+ List<IndexFileMeta> stale = new ArrayList<>();
+ Map<String, IndexFileMeta> bySource = new LinkedHashMap<>();
+ Set<String> payloadNames = new HashSet<>();
+ for (IndexFileMeta payload : activePayloads) {
+ GlobalIndexMeta globalMeta = payload.globalIndexMeta();
+ if (!PkFullTextIndexFile.INDEX_TYPE.equals(payload.indexType()) ||
globalMeta == null) {
+ continue;
+ }
+ if (globalMeta.indexFieldId() != textFieldId) {
+ if (globalMeta.sourceMeta() != null) {
+ stale.add(payload);
+ }
+ continue;
+ }
+ checkArgument(
+ payloadNames.add(payload.fileName()),
+ "Active full-text payload %s appears more than once in the
index manifest.",
+ payload.fileName());
+ PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(payload);
+ long sourceRowCount = 0;
+ for (PrimaryKeyIndexSourceFile source : sourceMeta.sourceFiles()) {
+ sourceRowCount = Math.addExact(sourceRowCount,
source.rowCount());
+ }
+ checkArgument(
+ payload.rowCount() == sourceRowCount
+ && globalMeta.rowRangeStart() == 0
+ && globalMeta.rowRangeEnd() == sourceRowCount - 1,
+ "Full-text archive %s row metadata does not match its
source files.",
+ payload.fileName());
+ for (PrimaryKeyIndexSourceFile source : sourceMeta.sourceFiles()) {
+ IndexFileMeta previous = bySource.put(source.fileName(),
payload);
+ checkArgument(
+ previous == null,
+ "Source data file %s is covered by both full-text
archives %s and %s.",
+ source.fileName(),
+ previous == null ? "" : previous.fileName(),
+ payload.fileName());
+ }
+ current.add(payload);
+ }
+ this.currentPayloads = Collections.unmodifiableList(current);
+ this.stalePayloads = Collections.unmodifiableList(stale);
+ this.payloadBySourceFile = Collections.unmodifiableMap(bySource);
+ }
+
+ public int textFieldId() {
+ return textFieldId;
+ }
+
+ public List<IndexFileMeta> currentPayloads() {
+ return currentPayloads;
+ }
+
+ public List<IndexFileMeta> stalePayloads() {
+ return stalePayloads;
+ }
+
+ public Map<String, IndexFileMeta> payloadBySourceFile() {
+ return payloadBySourceFile;
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextDataFileReader.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextDataFileReader.java
new file mode 100644
index 0000000000..a076fb817b
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextDataFileReader.java
@@ -0,0 +1,108 @@
+/*
+ * 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.KeyValue;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.reader.RecordReaderIterator;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+
+import javax.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Reads one projected text value from every physical row of a data file. */
+public class PkFullTextDataFileReader implements Closeable {
+
+ private final DataFileMeta dataFile;
+ private final RecordReaderIterator<KeyValue> iterator;
+ private long rowsRead;
+
+ public PkFullTextDataFileReader(KeyValueFileReaderFactory readerFactory,
DataFileMeta dataFile)
+ throws IOException {
+ this.dataFile = dataFile;
+ this.iterator = new
RecordReaderIterator<>(readerFactory.createRecordReader(dataFile));
+ }
+
+ public long rowCount() {
+ return dataFile.rowCount();
+ }
+
+ @Nullable
+ public BinaryString readNextText() {
+ checkArgument(
+ rowsRead < rowCount(), "Read past data file %s row count.",
dataFile.fileName());
+ checkArgument(
+ iterator.hasNext(),
+ "Data file %s ended before its declared row count.",
+ dataFile.fileName());
+ KeyValue keyValue = iterator.next();
+ rowsRead++;
+ if (rowsRead == rowCount()) {
+ checkArgument(
+ !iterator.hasNext(),
+ "Data file %s contains more rows than declared.",
+ dataFile.fileName());
+ }
+ InternalRow value = keyValue.value();
+ return value.isNullAt(0) ? null : value.getString(0);
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ iterator.close();
+ } catch (IOException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException("Failed to close full-text reader for " +
dataFile.fileName(), e);
+ }
+ }
+
+ /** Bucket-scoped factory with text-only value projection and no
deletion-vector filtering. */
+ public static class Factory {
+
+ private final KeyValueFileReaderFactory readerFactory;
+
+ public Factory(
+ KeyValueFileReaderFactory.Builder readerFactoryBuilder,
+ BinaryRow partition,
+ int bucket,
+ DataField textField) {
+ this.readerFactory =
+ readerFactoryBuilder
+ .copyWithoutProjection()
+ .withReadKeyType(RowType.of())
+ .withReadValueType(RowType.of(textField))
+ .buildWithoutDeletionVector(partition, bucket);
+ }
+
+ public PkFullTextDataFileReader create(DataFileMeta dataFile) throws
IOException {
+ return new PkFullTextDataFileReader(readerFactory, dataFile);
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexBuilder.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexBuilder.java
new file mode 100644
index 0000000000..f5718205b9
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexBuilder.java
@@ -0,0 +1,70 @@
+/*
+ * 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.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Opens projected data-file readers and builds an ordered-source full-text
archive. */
+public class PkFullTextIndexBuilder {
+
+ private final PkFullTextIndexFile indexFile;
+ private final PkFullTextDataFileReader.Factory readerFactory;
+ private final DataField textField;
+ private final Options indexOptions;
+
+ public PkFullTextIndexBuilder(
+ PkFullTextIndexFile indexFile,
+ PkFullTextDataFileReader.Factory readerFactory,
+ DataField textField,
+ Options indexOptions) {
+ this.indexFile = indexFile;
+ this.readerFactory = readerFactory;
+ this.textField = textField;
+ this.indexOptions = indexOptions;
+ }
+
+ public IndexFileMeta build(DataFileMeta sourceFile) throws IOException {
+ PkFullTextDataFileReader reader = readerFactory.create(sourceFile);
+ return indexFile.build(sourceFile, reader, textField, indexOptions);
+ }
+
+ public IndexFileMeta build(List<DataFileMeta> sourceFiles) throws
IOException {
+ List<PkFullTextIndexFile.Source> sources = new
ArrayList<>(sourceFiles.size());
+ try {
+ for (DataFileMeta sourceFile : sourceFiles) {
+ sources.add(
+ new PkFullTextIndexFile.Source(
+ sourceFile, readerFactory.create(sourceFile)));
+ }
+ } catch (IOException | RuntimeException | Error failure) {
+ for (PkFullTextIndexFile.Source source : sources) {
+ source.close();
+ }
+ throw failure;
+ }
+ return indexFile.build(sources, textField, indexOptions);
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexFile.java
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexFile.java
new file mode 100644
index 0000000000..361b8f00d0
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexFile.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.index.pkfulltext;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFile;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.IOUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Builds one immutable full-text archive from ordered compact primary-key
data files. */
+public class PkFullTextIndexFile extends IndexFile {
+
+ public static final String INDEX_TYPE = "full-text";
+
+ public PkFullTextIndexFile(FileIO fileIO, IndexPathFactory pathFactory) {
+ super(fileIO, pathFactory);
+ }
+
+ public IndexFileMeta build(
+ DataFileMeta sourceFile,
+ PkFullTextDataFileReader textReader,
+ DataField textField,
+ Options indexOptions)
+ throws IOException {
+ GlobalIndexer indexer;
+ try {
+ indexer = GlobalIndexer.create(INDEX_TYPE, textField,
indexOptions);
+ } catch (RuntimeException | Error failure) {
+ IOUtils.closeQuietly(textReader);
+ throw failure;
+ }
+ return build(
+ Collections.singletonList(new Source(sourceFile, textReader)),
textField, indexer);
+ }
+
+ public IndexFileMeta build(List<Source> sources, DataField textField,
Options indexOptions)
+ throws IOException {
+ GlobalIndexer indexer;
+ try {
+ indexer = GlobalIndexer.create(INDEX_TYPE, textField,
indexOptions);
+ } catch (RuntimeException | Error failure) {
+ closeSources(sources);
+ throw failure;
+ }
+ return build(sources, textField, indexer);
+ }
+
+ IndexFileMeta build(
+ DataFileMeta sourceFile,
+ PkFullTextDataFileReader textReader,
+ DataField textField,
+ GlobalIndexer indexer)
+ throws IOException {
+ return build(
+ Collections.singletonList(new Source(sourceFile, textReader)),
textField, indexer);
+ }
+
+ IndexFileMeta build(List<Source> sources, DataField textField,
GlobalIndexer indexer)
+ throws IOException {
+ checkArgument(!sources.isEmpty(), "A full-text archive must reference
source files.");
+ long totalRowCount = 0;
+ List<PrimaryKeyIndexSourceFile> sourceFiles = new
ArrayList<>(sources.size());
+ for (Source source : sources) {
+ checkArgument(
+ source.sourceFile.rowCount() > 0, "Full-text source file
must contain rows.");
+ totalRowCount = Math.addExact(totalRowCount,
source.sourceFile.rowCount());
+ sourceFiles.add(
+ new PrimaryKeyIndexSourceFile(
+ source.sourceFile.fileName(),
source.sourceFile.rowCount()));
+ }
+
+ ArchiveFileWriter fileWriter = new ArchiveFileWriter();
+ GlobalIndexWriter writer = null;
+ boolean success = false;
+ try {
+ writer = indexer.createWriter(fileWriter);
+ checkArgument(
+ writer instanceof GlobalIndexSingleColumnWriter,
+ "Full-text indexer must create a single-column writer.");
+ GlobalIndexSingleColumnWriter singleColumnWriter =
+ (GlobalIndexSingleColumnWriter) writer;
+ long sourceOffset = 0;
+ for (Source source : sources) {
+ checkArgument(
+ source.textReader.rowCount() ==
source.sourceFile.rowCount(),
+ "Text row count %s does not match source file %s row
count %s.",
+ source.textReader.rowCount(),
+ source.sourceFile.fileName(),
+ source.sourceFile.rowCount());
+ for (long rowPosition = 0;
+ rowPosition < source.sourceFile.rowCount();
+ rowPosition++) {
+ singleColumnWriter.write(
+ source.textReader.readNextText(), sourceOffset +
rowPosition);
+ }
+ sourceOffset += source.sourceFile.rowCount();
+ }
+ List<ResultEntry> results = writer.finish();
+ checkArgument(
+ results.size() == 1,
+ "Full-text build must produce exactly one archive, but
produced %s.",
+ results.size());
+ checkArgument(
+ fileWriter.createdFiles.size() == 1,
+ "Full-text build must allocate exactly one archive, but
allocated %s.",
+ fileWriter.createdFiles.size());
+ ResultEntry result = results.get(0);
+ checkArgument(
+ result.rowCount() == totalRowCount,
+ "Full-text archive row count %s does not match source row
count %s.",
+ result.rowCount(),
+ totalRowCount);
+ Path archivePath = fileWriter.path(result.fileName());
+ byte[] archiveMetadata = result.meta() == null ? new byte[0] :
result.meta();
+ IndexFileMeta archive =
+ new IndexFileMeta(
+ INDEX_TYPE,
+ result.fileName(),
+ fileIO.getFileSize(archivePath),
+ result.rowCount(),
+ new GlobalIndexMeta(
+ 0,
+ totalRowCount - 1,
+ textField.id(),
+ null,
+ archiveMetadata,
+ new
PrimaryKeyIndexSourceMeta(sourceFiles).serialize()),
+ pathFactory.isExternalPath() ?
archivePath.toString() : null);
+ success = true;
+ return archive;
+ } finally {
+ for (Source source : sources) {
+ IOUtils.closeQuietly(source.textReader);
+ }
+ if (writer instanceof AutoCloseable) {
+ IOUtils.closeQuietly((AutoCloseable) writer);
+ }
+ if (!success) {
+ fileWriter.deleteCreatedFiles();
+ }
+ }
+ }
+
+ private static void closeSources(List<Source> sources) {
+ for (Source source : sources) {
+ IOUtils.closeQuietly(source.textReader);
+ }
+ }
+
+ /** One data-file source whose row positions are appended to an archive. */
+ public static class Source {
+
+ private final DataFileMeta sourceFile;
+ private final PkFullTextDataFileReader textReader;
+
+ public Source(DataFileMeta sourceFile, PkFullTextDataFileReader
textReader) {
+ this.sourceFile = sourceFile;
+ this.textReader = textReader;
+ }
+
+ void close() {
+ IOUtils.closeQuietly(textReader);
+ }
+ }
+
+ private class ArchiveFileWriter implements GlobalIndexFileWriter {
+
+ private final Map<String, Path> createdFiles = new HashMap<>();
+
+ @Override
+ public String newFileName(String prefix) {
+ Path path = pathFactory.newPath();
+ createdFiles.put(path.getName(), path);
+ return path.getName();
+ }
+
+ @Override
+ public PositionOutputStream newOutputStream(String fileName) throws
IOException {
+ return fileIO.newOutputStream(path(fileName), false);
+ }
+
+ private Path path(String fileName) {
+ Path path = createdFiles.get(fileName);
+ checkArgument(path != null, "Full-text archive %s was not
allocated.", fileName);
+ return path;
+ }
+
+ private void deleteCreatedFiles() {
+ for (Path path : createdFiles.values()) {
+ fileIO.deleteQuietly(path);
+ }
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
b/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
index bb8d82e32c..519d2c4a94 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
@@ -26,7 +26,7 @@ import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.reader.FileRecordReader;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.table.source.DataSplit;
-import org.apache.paimon.table.source.PrimaryKeyVectorPositionReader;
+import org.apache.paimon.table.source.PrimaryKeyIndexPositionReader;
import org.apache.paimon.table.source.Split;
import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.Range;
@@ -116,6 +116,6 @@ public class PrimaryKeyIndexedSplitRead implements
SplitRead<InternalRow> {
IntFunction<Float> scoreGetter =
scoreByPosition == null ? position -> Float.NaN :
scoreByPosition::get;
FileRecordReader<InternalRow> reader =
rawRead.createFileReader(dataSplit, rowPositions);
- return new PrimaryKeyVectorPositionReader(reader, rowPositions,
scoreGetter);
+ return new PrimaryKeyIndexPositionReader(reader, rowPositions,
scoreGetter);
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyIndexPositionReader.java
similarity index 94%
copy from
paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
copy to
paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyIndexPositionReader.java
index 52f7423a92..cf49df828f 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyIndexPositionReader.java
@@ -32,8 +32,8 @@ import java.util.function.IntFunction;
import static org.apache.paimon.utils.Preconditions.checkArgument;
-/** Reads selected physical file positions and exposes their vector-search
scores. */
-public class PrimaryKeyVectorPositionReader implements
ScoreRecordReader<InternalRow> {
+/** Reads selected primary-key physical file positions and exposes their
search scores. */
+public class PrimaryKeyIndexPositionReader implements
ScoreRecordReader<InternalRow> {
private final FileRecordReader<InternalRow> reader;
private final RoaringBitmap32 rowPositions;
@@ -41,7 +41,7 @@ public class PrimaryKeyVectorPositionReader implements
ScoreRecordReader<Interna
private final int lastPosition;
private boolean exhausted;
- public PrimaryKeyVectorPositionReader(
+ public PrimaryKeyIndexPositionReader(
FileRecordReader<InternalRow> reader,
RoaringBitmap32 rowPositions,
IntFunction<Float> scoreGetter) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyScoredResult.java
similarity index 51%
copy from
paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
copy to
paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyScoredResult.java
index 3eba24b30c..187e2ae430 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyScoredResult.java
@@ -20,6 +20,8 @@ package org.apache.paimon.table.source;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.globalindex.ScoreGetter;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.RoaringNavigableMap64;
@@ -33,67 +35,90 @@ import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
-import static org.apache.paimon.globalindex.VectorSearchMetric.normalize;
import static org.apache.paimon.utils.Preconditions.checkArgument;
-/** Snapshot-scoped vector result addressed by physical primary-key table row
positions. */
-public class PrimaryKeyVectorResult implements GlobalIndexSplitResult {
-
- private final PrimaryKeyVectorScan.Plan plan;
- private final List<PrimaryKeyVectorRead.Candidate> candidates;
- private final String metric;
-
- PrimaryKeyVectorResult(
- PrimaryKeyVectorScan.Plan plan,
- List<PrimaryKeyVectorRead.Candidate> candidates,
- String metric) {
- this.plan = plan;
- this.candidates = Collections.unmodifiableList(new
ArrayList<>(candidates));
- this.metric = normalize(metric);
+/** Snapshot-scoped scored search result addressed by physical primary-key row
positions. */
+public class PrimaryKeyScoredResult implements GlobalIndexSplitResult,
ScoredGlobalIndexResult {
+
+ private final long snapshotId;
+ private final List<PrimaryKeySearchPosition> positions;
+ private final List<IndexedSplit> splits;
+
+ public PrimaryKeyScoredResult(
+ long snapshotId,
+ List<DataSplit> sourceSplits,
+ List<PrimaryKeySearchPosition> positions) {
+ this.snapshotId = snapshotId;
+ this.positions = Collections.unmodifiableList(new
ArrayList<>(positions));
+ this.splits =
+ Collections.unmodifiableList(
+ materializeSplits(snapshotId, sourceSplits,
positions));
}
@Override
public long snapshotId() {
- return plan.snapshotId();
+ return snapshotId;
+ }
+
+ public List<PrimaryKeySearchPosition> positions() {
+ return positions;
}
@Override
public List<IndexedSplit> splits() {
- Map<FileKey, BucketVectorSearchSplit> sourceSplits = sourceSplits();
- Map<FileKey, TreeMap<Integer, Float>> selectedByFile = new
LinkedHashMap<>();
- for (PrimaryKeyVectorRead.Candidate candidate : candidates) {
- FileKey key =
- new FileKey(
- candidate.partition(), candidate.bucket(),
candidate.dataFileName());
- BucketVectorSearchSplit bucketSplit = sourceSplits.get(key);
+ return splits;
+ }
+
+ @Override
+ public RoaringNavigableMap64 results() {
+ throw new UnsupportedOperationException(
+ "Primary-key scored results use physical file positions, not
global row ids.");
+ }
+
+ @Override
+ public ScoreGetter scoreGetter() {
+ throw new UnsupportedOperationException(
+ "Primary-key scored results attach scores to physical file
positions, not global row ids.");
+ }
+
+ private static List<IndexedSplit> materializeSplits(
+ long snapshotId,
+ List<DataSplit> sourceSplits,
+ List<PrimaryKeySearchPosition> positions) {
+ Map<FileKey, SourceFile> sources = sourceFiles(snapshotId,
sourceSplits);
+ TreeMap<PrimaryKeySearchPosition, PrimaryKeySearchPosition>
orderedPositions =
+ new TreeMap<>();
+ for (PrimaryKeySearchPosition position : positions) {
checkArgument(
- bucketSplit != null,
- "Primary-key vector candidate references unknown data file
%s in bucket %s.",
- candidate.dataFileName(),
- candidate.bucket());
- DataFileMeta dataFile = dataFile(bucketSplit.dataSplit(),
candidate.dataFileName());
+ orderedPositions.put(position, position) == null,
+ "Primary-key search result contains duplicate physical
position %s.",
+ position);
+ }
+
+ Map<FileKey, TreeMap<Integer, Float>> selectedByFile = new
LinkedHashMap<>();
+ for (PrimaryKeySearchPosition position : orderedPositions.values()) {
+ FileKey key = FileKey.from(position);
+ SourceFile sourceFile = sources.get(key);
checkArgument(
- candidate.rowPosition() >= 0
- && candidate.rowPosition() < dataFile.rowCount()
- && candidate.rowPosition() <= Integer.MAX_VALUE,
- "Primary-key vector row position %s is outside data file
%s.",
- candidate.rowPosition(),
- candidate.dataFileName());
- TreeMap<Integer, Float> selected =
- selectedByFile.computeIfAbsent(key, ignored -> new
TreeMap<>());
+ sourceFile != null,
+ "Primary-key search position references unknown data file
%s in bucket %s.",
+ position.dataFileName(),
+ position.bucket());
checkArgument(
- selected.put((int) candidate.rowPosition(),
score(candidate.distance()))
- == null,
- "Primary-key vector candidate contains duplicate row
position %s for data file %s.",
- candidate.rowPosition(),
- candidate.dataFileName());
+ position.rowPosition() < sourceFile.dataFile.rowCount()
+ && position.rowPosition() <= Integer.MAX_VALUE,
+ "Primary-key search row position %s is outside data file
%s.",
+ position.rowPosition(),
+ position.dataFileName());
+ selectedByFile
+ .computeIfAbsent(key, ignored -> new TreeMap<>())
+ .put((int) position.rowPosition(), position.score());
}
List<IndexedSplit> result = new ArrayList<>(selectedByFile.size());
for (Map.Entry<FileKey, TreeMap<Integer, Float>> entry :
selectedByFile.entrySet()) {
- BucketVectorSearchSplit bucketSplit =
sourceSplits.get(entry.getKey());
- DataSplit source = bucketSplit.dataSplit();
- int fileIndex = fileIndex(source, entry.getKey().dataFileName);
+ SourceFile sourceFile = sources.get(entry.getKey());
+ DataSplit source = sourceFile.split;
DataSplit.Builder builder =
DataSplit.builder()
.withSnapshot(source.snapshotId())
@@ -101,40 +126,36 @@ public class PrimaryKeyVectorResult implements
GlobalIndexSplitResult {
.withBucket(source.bucket())
.withBucketPath(source.bucketPath())
.withTotalBuckets(source.totalBuckets())
- .withDataFiles(
-
Collections.singletonList(source.dataFiles().get(fileIndex)))
+
.withDataFiles(Collections.singletonList(sourceFile.dataFile))
.isStreaming(false)
.rawConvertible(false);
if (source.deletionFiles().isPresent()) {
builder.withDataDeletionFiles(
-
Collections.singletonList(source.deletionFiles().get().get(fileIndex)));
+ Collections.singletonList(
+
source.deletionFiles().get().get(sourceFile.fileIndex)));
}
result.add(
new IndexedSplit(
builder.build(), ranges(entry.getValue()),
scores(entry.getValue())));
}
- return Collections.unmodifiableList(result);
- }
-
- @Override
- public RoaringNavigableMap64 results() {
- throw new UnsupportedOperationException(
- "Primary-key vector results use physical file positions, not
global row ids.");
+ return result;
}
- private Map<FileKey, BucketVectorSearchSplit> sourceSplits() {
- Map<FileKey, BucketVectorSearchSplit> result = new HashMap<>();
- for (VectorSearchSplit searchSplit : plan.splits()) {
- BucketVectorSearchSplit bucketSplit = (BucketVectorSearchSplit)
searchSplit;
- for (DataFileMeta dataFile : bucketSplit.dataSplit().dataFiles()) {
- FileKey key =
- new FileKey(
- bucketSplit.dataSplit().partition(),
- bucketSplit.dataSplit().bucket(),
- dataFile.fileName());
+ private static Map<FileKey, SourceFile> sourceFiles(
+ long snapshotId, List<DataSplit> sourceSplits) {
+ Map<FileKey, SourceFile> result = new HashMap<>();
+ for (DataSplit source : sourceSplits) {
+ checkArgument(
+ source.snapshotId() == snapshotId,
+ "Primary-key source split snapshot %s does not match
result snapshot %s.",
+ source.snapshotId(),
+ snapshotId);
+ for (int i = 0; i < source.dataFiles().size(); i++) {
+ DataFileMeta dataFile = source.dataFiles().get(i);
+ FileKey key = new FileKey(source.partition(), source.bucket(),
dataFile.fileName());
checkArgument(
- result.put(key, bucketSplit) == null,
- "Data file %s appears more than once in primary-key
vector plan.",
+ result.put(key, new SourceFile(source, dataFile, i))
== null,
+ "Data file %s appears more than once in primary-key
search sources.",
dataFile.fileName());
}
}
@@ -169,29 +190,17 @@ public class PrimaryKeyVectorResult implements
GlobalIndexSplitResult {
return result;
}
- private static DataFileMeta dataFile(DataSplit split, String dataFileName)
{
- return split.dataFiles().get(fileIndex(split, dataFileName));
- }
+ private static class SourceFile {
- private static int fileIndex(DataSplit split, String dataFileName) {
- for (int i = 0; i < split.dataFiles().size(); i++) {
- if (dataFileName.equals(split.dataFiles().get(i).fileName())) {
- return i;
- }
- }
- throw new IllegalArgumentException(
- "Data file " + dataFileName + " does not exist in vector
bucket split.");
- }
+ private final DataSplit split;
+ private final DataFileMeta dataFile;
+ private final int fileIndex;
- private float score(float distance) {
- if ("l2".equals(metric)) {
- return 1F / (1F + distance);
- } else if ("cosine".equals(metric)) {
- return 1F - distance;
- } else if ("inner_product".equals(metric)) {
- return -distance;
+ private SourceFile(DataSplit split, DataFileMeta dataFile, int
fileIndex) {
+ this.split = split;
+ this.dataFile = dataFile;
+ this.fileIndex = fileIndex;
}
- throw new IllegalArgumentException("Unsupported primary-key vector
metric: " + metric);
}
private static class FileKey {
@@ -206,12 +215,16 @@ public class PrimaryKeyVectorResult implements
GlobalIndexSplitResult {
this.dataFileName = dataFileName;
}
+ private static FileKey from(PrimaryKeySearchPosition position) {
+ return new FileKey(position.partition(), position.bucket(),
position.dataFileName());
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
- if (o == null || getClass() != o.getClass()) {
+ if (!(o instanceof FileKey)) {
return false;
}
FileKey fileKey = (FileKey) o;
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchPosition.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchPosition.java
new file mode 100644
index 0000000000..f2d7e09705
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchPosition.java
@@ -0,0 +1,139 @@
+/*
+ * 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.data.BinaryRow;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** A scored physical row position in a primary-key table snapshot. */
+public final class PrimaryKeySearchPosition
+ implements Comparable<PrimaryKeySearchPosition>, Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final BinaryRow partition;
+ private final int bucket;
+ private final String dataFileName;
+ private final long rowPosition;
+ private final float score;
+
+ public PrimaryKeySearchPosition(
+ BinaryRow partition, int bucket, String dataFileName, long
rowPosition, float score) {
+ checkArgument(rowPosition >= 0, "Row position must not be negative:
%s.", rowPosition);
+ checkArgument(
+ !Float.isNaN(score) && !Float.isInfinite(score),
+ "Search score must be finite: %s.",
+ score);
+ this.partition = Objects.requireNonNull(partition, "partition").copy();
+ this.bucket = bucket;
+ this.dataFileName = Objects.requireNonNull(dataFileName,
"dataFileName");
+ this.rowPosition = rowPosition;
+ this.score = score;
+ }
+
+ public BinaryRow partition() {
+ return partition;
+ }
+
+ public int bucket() {
+ return bucket;
+ }
+
+ public String dataFileName() {
+ return dataFileName;
+ }
+
+ public long rowPosition() {
+ return rowPosition;
+ }
+
+ public float score() {
+ return score;
+ }
+
+ public PrimaryKeySearchPosition withScore(float newScore) {
+ return new PrimaryKeySearchPosition(partition, bucket, dataFileName,
rowPosition, newScore);
+ }
+
+ @Override
+ public int compareTo(PrimaryKeySearchPosition other) {
+ int comparison = compareBytes(partition.toBytes(),
other.partition.toBytes());
+ if (comparison != 0) {
+ return comparison;
+ }
+ comparison = Integer.compare(bucket, other.bucket);
+ if (comparison != 0) {
+ return comparison;
+ }
+ comparison = dataFileName.compareTo(other.dataFileName);
+ return comparison != 0 ? comparison : Long.compare(rowPosition,
other.rowPosition);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof PrimaryKeySearchPosition)) {
+ return false;
+ }
+ PrimaryKeySearchPosition that = (PrimaryKeySearchPosition) o;
+ return bucket == that.bucket
+ && rowPosition == that.rowPosition
+ && partition.equals(that.partition)
+ && dataFileName.equals(that.dataFileName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(partition, bucket, dataFileName, rowPosition);
+ }
+
+ @Override
+ public String toString() {
+ return "PrimaryKeySearchPosition{"
+ + "partition="
+ + partition
+ + ", bucket="
+ + bucket
+ + ", dataFileName='"
+ + dataFileName
+ + '\''
+ + ", rowPosition="
+ + rowPosition
+ + ", score="
+ + score
+ + '}';
+ }
+
+ private static int compareBytes(byte[] left, byte[] right) {
+ int count = Math.min(left.length, right.length);
+ for (int i = 0; i < count; i++) {
+ int comparison = Integer.compare(left[i] & 0xFF, right[i] & 0xFF);
+ if (comparison != 0) {
+ return comparison;
+ }
+ }
+ return Integer.compare(left.length, right.length);
+ }
+}
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
new file mode 100644
index 0000000000..779a64a400
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySearchRanker.java
@@ -0,0 +1,183 @@
+/*
+ * 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 java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.PriorityQueue;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Deterministic reciprocal-rank fusion for physical primary-key search
positions. */
+public final class PrimaryKeySearchRanker {
+
+ public static final int DEFAULT_RRF_K = 60;
+
+ private static final Comparator<PrimaryKeySearchPosition> LOCAL_BEST_FIRST
=
+ (left, right) -> {
+ int score = Float.compare(right.score(), left.score());
+ return score != 0 ? score : left.compareTo(right);
+ };
+
+ private PrimaryKeySearchRanker() {}
+
+ public static List<PrimaryKeySearchPosition> rrf(
+ List<List<PrimaryKeySearchPosition>> rankings, int limit) {
+ List<Ranking> weighted = new ArrayList<>(rankings.size());
+ for (List<PrimaryKeySearchPosition> ranking : rankings) {
+ weighted.add(new Ranking(ranking, 1D));
+ }
+ return weightedRrf(weighted, limit);
+ }
+
+ public static List<PrimaryKeySearchPosition> weightedRrf(List<Ranking>
rankings, int limit) {
+ checkArgument(limit > 0, "RRF result limit must be positive: %s.",
limit);
+ Map<PrimaryKeySearchPosition, Double> fusedScores = new HashMap<>();
+ for (Ranking ranking : rankings) {
+ addRanking(fusedScores, ranking);
+ }
+
+ return topK(fusedScores, limit);
+ }
+
+ /** Fuses heterogeneous route scores after independently normalizing each
route to [0, 1]. */
+ public static List<PrimaryKeySearchPosition> weightedScore(List<Ranking>
rankings, int limit) {
+ checkArgument(limit > 0, "Weighted-score result limit must be
positive: %s.", limit);
+ Map<PrimaryKeySearchPosition, Double> fusedScores = new HashMap<>();
+ for (Ranking ranking : rankings) {
+ Set<PrimaryKeySearchPosition> unique = new HashSet<>();
+ float min = Float.POSITIVE_INFINITY;
+ float max = Float.NEGATIVE_INFINITY;
+ for (PrimaryKeySearchPosition position : ranking.positions) {
+ checkArgument(
+ unique.add(position),
+ "One weighted-score ranking contains duplicate
physical position %s.",
+ position);
+ min = Math.min(min, position.score());
+ max = Math.max(max, position.score());
+ }
+ float range = max - min;
+ for (PrimaryKeySearchPosition position : ranking.positions) {
+ double normalized = range > 0F ? (position.score() - min) /
range : 1D;
+ fusedScores.merge(position, ranking.weight * normalized,
Double::sum);
+ }
+ }
+ return topK(fusedScores, limit);
+ }
+
+ /** Fuses routes using weighted reciprocal rank without the RRF smoothing
constant. */
+ public static List<PrimaryKeySearchPosition> weightedMrr(List<Ranking>
rankings, int limit) {
+ checkArgument(limit > 0, "MRR result limit must be positive: %s.",
limit);
+ Map<PrimaryKeySearchPosition, Double> fusedScores = new HashMap<>();
+ for (Ranking ranking : rankings) {
+ List<PrimaryKeySearchPosition> sorted = new
ArrayList<>(ranking.positions);
+ sorted.sort(LOCAL_BEST_FIRST);
+ Set<PrimaryKeySearchPosition> unique = new HashSet<>();
+ for (int i = 0; i < sorted.size(); i++) {
+ PrimaryKeySearchPosition position = sorted.get(i);
+ checkArgument(
+ unique.add(position),
+ "One MRR ranking contains duplicate physical position
%s.",
+ position);
+ fusedScores.merge(position, ranking.weight / (i + 1D),
Double::sum);
+ }
+ }
+ return topK(fusedScores, limit);
+ }
+
+ private static List<PrimaryKeySearchPosition> topK(
+ Map<PrimaryKeySearchPosition, Double> fusedScores, int limit) {
+
+ Comparator<PrimaryKeySearchPosition> bestFirst =
+ (left, right) -> {
+ int score = Float.compare(right.score(), left.score());
+ return score != 0 ? score : left.compareTo(right);
+ };
+ PriorityQueue<PrimaryKeySearchPosition> topK =
+ new PriorityQueue<>(limit, bestFirst.reversed());
+ for (Map.Entry<PrimaryKeySearchPosition, Double> entry :
fusedScores.entrySet()) {
+ PrimaryKeySearchPosition position =
+ entry.getKey().withScore(entry.getValue().floatValue());
+ if (topK.size() < limit) {
+ topK.add(position);
+ } else if (bestFirst.compare(position, topK.peek()) < 0) {
+ topK.poll();
+ topK.add(position);
+ }
+ }
+ List<PrimaryKeySearchPosition> result = new ArrayList<>(topK);
+ result.sort(bestFirst);
+ return Collections.unmodifiableList(result);
+ }
+
+ private static void addRanking(
+ Map<PrimaryKeySearchPosition, Double> fusedScores, Ranking
ranking) {
+ List<PrimaryKeySearchPosition> sorted = new
ArrayList<>(ranking.positions);
+ sorted.sort(LOCAL_BEST_FIRST);
+ Set<PrimaryKeySearchPosition> unique = new HashSet<>();
+ int rank = 0;
+ float previousScore = Float.NaN;
+ for (int i = 0; i < sorted.size(); i++) {
+ PrimaryKeySearchPosition position = sorted.get(i);
+ checkArgument(
+ unique.add(position),
+ "One RRF ranking contains duplicate physical position %s.",
+ position);
+ if (i == 0 || Float.compare(position.score(), previousScore) != 0)
{
+ rank = i + 1;
+ previousScore = position.score();
+ }
+ double contribution = ranking.weight / (DEFAULT_RRF_K + rank);
+ fusedScores.merge(position, contribution, Double::sum);
+ }
+ }
+
+ /** One locally scored ranking and its optional route weight. */
+ public static class Ranking implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final List<PrimaryKeySearchPosition> positions;
+ private final double weight;
+
+ public Ranking(List<PrimaryKeySearchPosition> positions, double
weight) {
+ checkArgument(
+ weight > 0D && !Double.isNaN(weight) &&
!Double.isInfinite(weight),
+ "Search route weight must be finite and positive: %s.",
+ weight);
+ this.positions = Collections.unmodifiableList(new
ArrayList<>(positions));
+ this.weight = weight;
+ }
+
+ public List<PrimaryKeySearchPosition> positions() {
+ return positions;
+ }
+
+ public double weight() {
+ return weight;
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
index 52f7423a92..de6eb0785f 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
@@ -19,88 +19,18 @@
package org.apache.paimon.table.source;
import org.apache.paimon.data.InternalRow;
-import org.apache.paimon.reader.FileRecordIterator;
import org.apache.paimon.reader.FileRecordReader;
-import org.apache.paimon.reader.ScoreRecordIterator;
-import org.apache.paimon.reader.ScoreRecordReader;
import org.apache.paimon.utils.RoaringBitmap32;
-import javax.annotation.Nullable;
-
-import java.io.IOException;
import java.util.function.IntFunction;
-import static org.apache.paimon.utils.Preconditions.checkArgument;
-
-/** Reads selected physical file positions and exposes their vector-search
scores. */
-public class PrimaryKeyVectorPositionReader implements
ScoreRecordReader<InternalRow> {
-
- private final FileRecordReader<InternalRow> reader;
- private final RoaringBitmap32 rowPositions;
- private final IntFunction<Float> scoreGetter;
- private final int lastPosition;
- private boolean exhausted;
+/** Compatibility adapter for the generic primary-key index position reader. */
+public class PrimaryKeyVectorPositionReader extends
PrimaryKeyIndexPositionReader {
public PrimaryKeyVectorPositionReader(
FileRecordReader<InternalRow> reader,
RoaringBitmap32 rowPositions,
IntFunction<Float> scoreGetter) {
- checkArgument(!rowPositions.isEmpty(), "Selected row positions must
not be empty.");
- this.reader = reader;
- this.rowPositions = rowPositions.clone();
- this.scoreGetter = scoreGetter;
- this.lastPosition = rowPositions.last();
- }
-
- @Nullable
- @Override
- public ScoreRecordIterator<InternalRow> readBatch() throws IOException {
- if (exhausted) {
- return null;
- }
- FileRecordIterator<InternalRow> batch = reader.readBatch();
- if (batch == null) {
- return null;
- }
- FileRecordIterator<InternalRow> selected =
batch.selection(rowPositions);
- return new ScoreRecordIterator<InternalRow>() {
-
- private long returnedPosition = -1;
- private float returnedScore = Float.NaN;
-
- @Override
- public float returnedScore() {
- return returnedScore;
- }
-
- @Override
- public long returnedRowId() {
- return returnedPosition;
- }
-
- @Nullable
- @Override
- public InternalRow next() throws IOException {
- InternalRow row = selected.next();
- if (row != null) {
- returnedPosition = selected.returnedPosition();
- returnedScore = scoreGetter.apply((int) returnedPosition);
- if (returnedPosition >= lastPosition) {
- exhausted = true;
- }
- }
- return row;
- }
-
- @Override
- public void releaseBatch() {
- selected.releaseBatch();
- }
- };
- }
-
- @Override
- public void close() throws IOException {
- reader.close();
+ super(reader, rowPositions, scoreGetter);
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
index 3eba24b30c..c86fbf8226 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorResult.java
@@ -18,172 +18,65 @@
package org.apache.paimon.table.source;
-import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.globalindex.IndexedSplit;
-import org.apache.paimon.io.DataFileMeta;
-import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.RoaringNavigableMap64;
import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.TreeMap;
import static org.apache.paimon.globalindex.VectorSearchMetric.normalize;
-import static org.apache.paimon.utils.Preconditions.checkArgument;
-/** Snapshot-scoped vector result addressed by physical primary-key table row
positions. */
+/** Compatibility result adapting primary-key vector candidates to generic
physical positions. */
public class PrimaryKeyVectorResult implements GlobalIndexSplitResult {
- private final PrimaryKeyVectorScan.Plan plan;
- private final List<PrimaryKeyVectorRead.Candidate> candidates;
- private final String metric;
+ private final PrimaryKeyScoredResult delegate;
PrimaryKeyVectorResult(
PrimaryKeyVectorScan.Plan plan,
List<PrimaryKeyVectorRead.Candidate> candidates,
String metric) {
- this.plan = plan;
- this.candidates = Collections.unmodifiableList(new
ArrayList<>(candidates));
- this.metric = normalize(metric);
- }
-
- @Override
- public long snapshotId() {
- return plan.snapshotId();
- }
-
- @Override
- public List<IndexedSplit> splits() {
- Map<FileKey, BucketVectorSearchSplit> sourceSplits = sourceSplits();
- Map<FileKey, TreeMap<Integer, Float>> selectedByFile = new
LinkedHashMap<>();
- for (PrimaryKeyVectorRead.Candidate candidate : candidates) {
- FileKey key =
- new FileKey(
- candidate.partition(), candidate.bucket(),
candidate.dataFileName());
- BucketVectorSearchSplit bucketSplit = sourceSplits.get(key);
- checkArgument(
- bucketSplit != null,
- "Primary-key vector candidate references unknown data file
%s in bucket %s.",
- candidate.dataFileName(),
- candidate.bucket());
- DataFileMeta dataFile = dataFile(bucketSplit.dataSplit(),
candidate.dataFileName());
- checkArgument(
- candidate.rowPosition() >= 0
- && candidate.rowPosition() < dataFile.rowCount()
- && candidate.rowPosition() <= Integer.MAX_VALUE,
- "Primary-key vector row position %s is outside data file
%s.",
- candidate.rowPosition(),
- candidate.dataFileName());
- TreeMap<Integer, Float> selected =
- selectedByFile.computeIfAbsent(key, ignored -> new
TreeMap<>());
- checkArgument(
- selected.put((int) candidate.rowPosition(),
score(candidate.distance()))
- == null,
- "Primary-key vector candidate contains duplicate row
position %s for data file %s.",
- candidate.rowPosition(),
- candidate.dataFileName());
+ String normalizedMetric = normalize(metric);
+ List<DataSplit> sourceSplits = new ArrayList<>(plan.splits().size());
+ for (VectorSearchSplit split : plan.splits()) {
+ sourceSplits.add(((BucketVectorSearchSplit) split).dataSplit());
}
-
- List<IndexedSplit> result = new ArrayList<>(selectedByFile.size());
- for (Map.Entry<FileKey, TreeMap<Integer, Float>> entry :
selectedByFile.entrySet()) {
- BucketVectorSearchSplit bucketSplit =
sourceSplits.get(entry.getKey());
- DataSplit source = bucketSplit.dataSplit();
- int fileIndex = fileIndex(source, entry.getKey().dataFileName);
- DataSplit.Builder builder =
- DataSplit.builder()
- .withSnapshot(source.snapshotId())
- .withPartition(source.partition())
- .withBucket(source.bucket())
- .withBucketPath(source.bucketPath())
- .withTotalBuckets(source.totalBuckets())
- .withDataFiles(
-
Collections.singletonList(source.dataFiles().get(fileIndex)))
- .isStreaming(false)
- .rawConvertible(false);
- if (source.deletionFiles().isPresent()) {
- builder.withDataDeletionFiles(
-
Collections.singletonList(source.deletionFiles().get().get(fileIndex)));
- }
- result.add(
- new IndexedSplit(
- builder.build(), ranges(entry.getValue()),
scores(entry.getValue())));
+ List<PrimaryKeySearchPosition> positions = new
ArrayList<>(candidates.size());
+ for (PrimaryKeyVectorRead.Candidate candidate : candidates) {
+ positions.add(
+ new PrimaryKeySearchPosition(
+ candidate.partition(),
+ candidate.bucket(),
+ candidate.dataFileName(),
+ candidate.rowPosition(),
+ score(normalizedMetric, candidate.distance())));
}
- return Collections.unmodifiableList(result);
+ this.delegate = new PrimaryKeyScoredResult(plan.snapshotId(),
sourceSplits, positions);
}
@Override
- public RoaringNavigableMap64 results() {
- throw new UnsupportedOperationException(
- "Primary-key vector results use physical file positions, not
global row ids.");
+ public long snapshotId() {
+ return delegate.snapshotId();
}
- private Map<FileKey, BucketVectorSearchSplit> sourceSplits() {
- Map<FileKey, BucketVectorSearchSplit> result = new HashMap<>();
- for (VectorSearchSplit searchSplit : plan.splits()) {
- BucketVectorSearchSplit bucketSplit = (BucketVectorSearchSplit)
searchSplit;
- for (DataFileMeta dataFile : bucketSplit.dataSplit().dataFiles()) {
- FileKey key =
- new FileKey(
- bucketSplit.dataSplit().partition(),
- bucketSplit.dataSplit().bucket(),
- dataFile.fileName());
- checkArgument(
- result.put(key, bucketSplit) == null,
- "Data file %s appears more than once in primary-key
vector plan.",
- dataFile.fileName());
- }
- }
- return result;
+ public List<PrimaryKeySearchPosition> positions() {
+ return delegate.positions();
}
- private static List<Range> ranges(TreeMap<Integer, Float> selected) {
- List<Range> result = new ArrayList<>();
- long from = -1;
- long to = -1;
- for (int position : selected.keySet()) {
- if (from < 0) {
- from = position;
- } else if (position != to + 1) {
- result.add(new Range(from, to));
- from = position;
- }
- to = position;
- }
- if (from >= 0) {
- result.add(new Range(from, to));
- }
- return result;
+ PrimaryKeyScoredResult scoredResult() {
+ return delegate;
}
- private static float[] scores(TreeMap<Integer, Float> selected) {
- float[] result = new float[selected.size()];
- int index = 0;
- for (float score : selected.values()) {
- result[index++] = score;
- }
- return result;
- }
-
- private static DataFileMeta dataFile(DataSplit split, String dataFileName)
{
- return split.dataFiles().get(fileIndex(split, dataFileName));
+ @Override
+ public List<IndexedSplit> splits() {
+ return delegate.splits();
}
- private static int fileIndex(DataSplit split, String dataFileName) {
- for (int i = 0; i < split.dataFiles().size(); i++) {
- if (dataFileName.equals(split.dataFiles().get(i).fileName())) {
- return i;
- }
- }
- throw new IllegalArgumentException(
- "Data file " + dataFileName + " does not exist in vector
bucket split.");
+ @Override
+ public RoaringNavigableMap64 results() {
+ return delegate.results();
}
- private float score(float distance) {
+ private static float score(String metric, float distance) {
if ("l2".equals(metric)) {
return 1F / (1F + distance);
} else if ("cosine".equals(metric)) {
@@ -193,36 +86,4 @@ public class PrimaryKeyVectorResult implements
GlobalIndexSplitResult {
}
throw new IllegalArgumentException("Unsupported primary-key vector
metric: " + metric);
}
-
- private static class FileKey {
-
- private final BinaryRow partition;
- private final int bucket;
- private final String dataFileName;
-
- private FileKey(BinaryRow partition, int bucket, String dataFileName) {
- this.partition = partition.copy();
- this.bucket = bucket;
- this.dataFileName = dataFileName;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
- FileKey fileKey = (FileKey) o;
- return bucket == fileKey.bucket
- && partition.equals(fileKey.partition)
- && dataFileName.equals(fileKey.dataFileName);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(partition, bucket, dataFileName);
- }
- }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
index e4bcb15d3c..cc3ef15874 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
@@ -18,6 +18,7 @@
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.globalindex.IndexedSplit;
@@ -53,13 +54,14 @@ public class PrimaryKeyVectorScan implements VectorScan {
private final String indexType;
@Nullable private final PartitionPredicate partitionFilter;
@Nullable private final Predicate filter;
+ @Nullable private final Snapshot pinnedSnapshot;
public PrimaryKeyVectorScan(
FileStoreTable table,
int vectorFieldId,
String indexType,
@Nullable PartitionPredicate partitionFilter) {
- this(table, vectorFieldId, indexType, partitionFilter, null);
+ this(table, vectorFieldId, indexType, partitionFilter, null, null);
}
public PrimaryKeyVectorScan(
@@ -68,11 +70,22 @@ public class PrimaryKeyVectorScan implements VectorScan {
String indexType,
@Nullable PartitionPredicate partitionFilter,
@Nullable Predicate filter) {
+ this(table, vectorFieldId, indexType, partitionFilter, filter, null);
+ }
+
+ PrimaryKeyVectorScan(
+ FileStoreTable table,
+ int vectorFieldId,
+ String indexType,
+ @Nullable PartitionPredicate partitionFilter,
+ @Nullable Predicate filter,
+ @Nullable Snapshot pinnedSnapshot) {
this.table = table;
this.vectorFieldId = vectorFieldId;
this.indexType = indexType;
this.partitionFilter = partitionFilter;
this.filter = filter;
+ this.pinnedSnapshot = pinnedSnapshot;
}
@Override
@@ -85,8 +98,9 @@ public class PrimaryKeyVectorScan implements VectorScan {
|| (table.coreOptions().deletionVectorsEnabled()
&&
!table.coreOptions().deletionVectorsMergeOnRead()),
"Primary-key vector pre-filter requires deletion vectors
without merge-on-read.");
- SnapshotReader snapshotReader = table.newSnapshotReader().keepStats();
- DataTableScan dataScan = table.newScan(ignored -> snapshotReader);
+ FileStoreTable scanTable = scanTable();
+ SnapshotReader snapshotReader =
scanTable.newSnapshotReader().keepStats();
+ DataTableScan dataScan = scanTable.newScan(ignored -> snapshotReader);
checkArgument(
dataScan instanceof PrimaryKeyBatchScan,
"Primary-key vector search requires a primary-key batch
scan.");
@@ -108,8 +122,16 @@ public class PrimaryKeyVectorScan implements VectorScan {
if (snapshotPlan.snapshotId() == null) {
return new Plan(0, Collections.emptyList());
}
- Snapshot snapshot =
snapshotReader.snapshotManager().snapshot(snapshotPlan.snapshotId());
+ Snapshot snapshot =
+ pinnedSnapshot == null
+ ?
snapshotReader.snapshotManager().snapshot(snapshotPlan.snapshotId())
+ : pinnedSnapshot;
checkArgument(snapshot != null, "Primary-key vector snapshot does not
exist.");
+ checkArgument(
+ snapshot.id() == snapshotPlan.snapshotId(),
+ "Primary-key vector plan snapshot %s does not match pinned
snapshot %s.",
+ snapshotPlan.snapshotId(),
+ snapshot.id());
IndexFileHandler indexFileHandler = snapshotReader.indexFileHandler();
checkArgument(indexFileHandler != null, "Primary-key vector index
handler is unavailable.");
@@ -127,6 +149,17 @@ public class PrimaryKeyVectorScan implements VectorScan {
return plan(snapshot.id(), snapshotPlan.splits(), vectorIndexEntries);
}
+ private FileStoreTable scanTable() {
+ if (pinnedSnapshot == null) {
+ return table;
+ }
+ return (FileStoreTable)
+ table.copy(
+ Collections.singletonMap(
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ String.valueOf(pinnedSnapshot.id())));
+ }
+
static Plan plan(
long snapshotId,
List<? extends Split> dataSplits,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
index e37f4e9d43..4b59d43812 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/VectorSearchBuilderImpl.java
@@ -18,6 +18,7 @@
package org.apache.paimon.table.source;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
@@ -26,6 +27,8 @@ import org.apache.paimon.table.InnerTable;
import org.apache.paimon.types.DataField;
import org.apache.paimon.utils.Pair;
+import javax.annotation.Nullable;
+
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -48,6 +51,7 @@ public class VectorSearchBuilderImpl implements
VectorSearchBuilder {
protected DataField vectorColumn;
protected float[] vector;
protected Map<String, String> options = new HashMap<>();
+ @Nullable private Snapshot pinnedSnapshot;
public VectorSearchBuilderImpl(InnerTable table) {
this.table = (FileStoreTable) table;
@@ -130,7 +134,8 @@ public class VectorSearchBuilderImpl implements
VectorSearchBuilder {
vectorColumn.id(),
table.coreOptions().primaryKeyVectorIndexType(vectorColumn.name()),
partitionFilter,
- filter);
+ filter,
+ pinnedSnapshot);
}
return new DataEvolutionVectorScan(table, partitionFilter, filter,
vectorColumn, options);
}
@@ -149,4 +154,9 @@ public class VectorSearchBuilderImpl implements
VectorSearchBuilder {
return vectorColumn != null
&&
table.coreOptions().primaryKeyVectorIndexColumns().contains(vectorColumn.name());
}
+
+ VectorSearchBuilderImpl withSnapshot(Snapshot snapshot) {
+ this.pinnedSnapshot = snapshot;
+ return this;
+ }
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextBucketIndexStateTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextBucketIndexStateTest.java
new file mode 100644
index 0000000000..a2a988c2a9
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextBucketIndexStateTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests restored file-aligned primary-key full-text state. */
+class PkFullTextBucketIndexStateTest {
+
+ @Test
+ void testClassifiesMatchingFieldAndRetiresOtherFields() {
+ IndexFileMeta current = payload("current", "data", 7);
+ IndexFileMeta alsoCurrent = payload("also-current", "other-data", 7);
+ IndexFileMeta otherField = payload("other", "third-data", 8);
+
+ PkFullTextBucketIndexState state =
+ PkFullTextBucketIndexState.fromActivePayloads(
+ 7, Arrays.asList(current, alsoCurrent, otherField));
+
+ assertThat(state.currentPayloads()).containsExactly(current,
alsoCurrent);
+ assertThat(state.stalePayloads()).containsExactly(otherField);
+ assertThat(state.payloadBySourceFile()).containsEntry("data", current);
+ }
+
+ @Test
+ void testRejectsDuplicateCurrentCoverage() {
+ assertThatThrownBy(
+ () ->
+ PkFullTextBucketIndexState.fromActivePayloads(
+ 7,
+ Arrays.asList(
+ payload("first", "data", 7),
+ payload("second", "data", 7))))
+ .hasMessageContaining("data")
+ .hasMessageContaining("covered by both full-text archives");
+ }
+
+ @Test
+ void testMapsEverySourceOfMultiSourceArchive() {
+ PrimaryKeyIndexSourceMeta sourceMeta =
+ new PrimaryKeyIndexSourceMeta(
+ Arrays.asList(
+ new PrimaryKeyIndexSourceFile("data-1", 1),
+ new PrimaryKeyIndexSourceFile("data-2", 1)));
+ IndexFileMeta payload = payload("multi", 7, 2, sourceMeta);
+
+ PkFullTextBucketIndexState state =
+ PkFullTextBucketIndexState.fromActivePayloads(
+ 7, Collections.singletonList(payload));
+
+ assertThat(state.currentPayloads()).containsExactly(payload);
+ assertThat(state.payloadBySourceFile())
+ .containsEntry("data-1", payload)
+ .containsEntry("data-2", payload);
+ }
+
+ private static IndexFileMeta payload(String payloadName, String
sourceName, int fieldId) {
+ return payload(
+ payloadName,
+ fieldId,
+ 1,
+ new PrimaryKeyIndexSourceMeta(new
PrimaryKeyIndexSourceFile(sourceName, 1)));
+ }
+
+ private static IndexFileMeta payload(
+ String payloadName, int fieldId, long rowCount,
PrimaryKeyIndexSourceMeta sourceMeta) {
+ return new IndexFileMeta(
+ "full-text",
+ payloadName,
+ 100,
+ rowCount,
+ new GlobalIndexMeta(0, rowCount - 1, fieldId, null, null,
sourceMeta.serialize()),
+ null);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextDataFileReaderTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextDataFileReaderTest.java
new file mode 100644
index 0000000000..47a6303302
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextDataFileReaderTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.CoreOptions;
+import org.apache.paimon.KeyValue;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.format.FlushingFileFormat;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.io.KeyValueFileWriterFactory;
+import org.apache.paimon.io.RollingFileWriter;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.schema.KeyValueFieldsExtractor;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.options.MemorySize.VALUE_128_MB;
+import static
org.apache.paimon.utils.FileStorePathFactoryTest.createNonPartFactory;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests reading physical text positions from compact data files. */
+class PkFullTextDataFileReaderTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ @Test
+ void testReadsProjectedTextAndPreservesNullPositions() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ DataField keyField = new DataField(0, "id", DataTypes.INT());
+ DataField textField = new DataField(1, "content", DataTypes.STRING());
+ DataField payloadField = new DataField(2, "payload", DataTypes.INT());
+ RowType keyType = RowType.of(keyField);
+ RowType valueType = RowType.of(textField, payloadField);
+ List<DataField> fields = Arrays.asList(keyField, textField,
payloadField);
+ TableSchema schema =
+ new TableSchema(
+ 0,
+ fields,
+ 2,
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ Collections.emptyMap(),
+ "");
+ SchemaManager schemaManager = new SchemaManager(fileIO, tablePath);
+ assertThat(schemaManager.commit(schema)).isTrue();
+ FileStorePathFactory pathFactory = createNonPartFactory(tablePath);
+ FlushingFileFormat format = new FlushingFileFormat("avro");
+ CoreOptions options = new CoreOptions(new Options());
+
+ KeyValueFileWriterFactory writerFactory =
+ KeyValueFileWriterFactory.builder(
+ fileIO,
+ schema.id(),
+ keyType,
+ valueType,
+ format,
+ ignored -> pathFactory,
+ VALUE_128_MB.getBytes())
+ .build(BinaryRow.EMPTY_ROW, 0, options);
+ RollingFileWriter<KeyValue, DataFileMeta> writer =
+ writerFactory.createRollingMergeTreeFileWriter(1,
FileSource.COMPACT);
+ try {
+ writer.write(keyValue(1, "first", 10));
+ writer.write(keyValue(2, null, 20));
+ writer.write(keyValue(3, "third", 30));
+ } finally {
+ writer.close();
+ }
+ DataFileMeta dataFile = writer.result().get(0);
+
+ KeyValueFileReaderFactory.Builder readerFactoryBuilder =
+ KeyValueFileReaderFactory.builder(
+ fileIO,
+ schemaManager,
+ schema,
+ keyType,
+ valueType,
+ ignored -> format,
+ pathFactory,
+ fieldsExtractor(keyType, valueType),
+ options);
+ PkFullTextDataFileReader reader =
+ new PkFullTextDataFileReader.Factory(
+ readerFactoryBuilder, BinaryRow.EMPTY_ROW, 0,
textField)
+ .create(dataFile);
+ try {
+ assertThat(reader.rowCount()).isEqualTo(3);
+ assertThat(reader.readNextText()).hasToString("first");
+ assertThat(reader.readNextText()).isNull();
+ assertThat(reader.readNextText()).hasToString("third");
+
assertThatThrownBy(reader::readNextText).hasMessageContaining("Read past data
file");
+ } finally {
+ reader.close();
+ }
+ }
+
+ private static KeyValue keyValue(int key, String text, int payload) {
+ return new KeyValue()
+ .replace(
+ GenericRow.of(key),
+ RowKind.INSERT,
+ GenericRow.of(
+ text == null ? null :
BinaryString.fromString(text), payload));
+ }
+
+ private static KeyValueFieldsExtractor fieldsExtractor(RowType keyType,
RowType valueType) {
+ return new KeyValueFieldsExtractor() {
+ @Override
+ public List<DataField> keyFields(TableSchema schema) {
+ return keyType.getFields();
+ }
+
+ @Override
+ public List<DataField> valueFields(TableSchema schema) {
+ return valueType.getFields();
+ }
+ };
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexFileTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexFileTest.java
new file mode 100644
index 0000000000..f7b3d82690
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/PkFullTextIndexFileTest.java
@@ -0,0 +1,278 @@
+/*
+ * 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.BinaryString;
+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.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+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.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Tests building one file-aligned primary-key full-text archive. */
+class PkFullTextIndexFileTest {
+
+ @TempDir java.nio.file.Path tempPath;
+
+ @Test
+ void testWritesEveryPhysicalOrdinalAndV1SourceMetadata() throws Exception {
+ LocalFileIO fileIO = LocalFileIO.create();
+ PkFullTextDataFileReader reader = mock(PkFullTextDataFileReader.class);
+ when(reader.rowCount()).thenReturn(3L);
+ when(reader.readNextText())
+ .thenReturn(
+ BinaryString.fromString("first"), null,
BinaryString.fromString("third"));
+ RecordingIndexer indexer = new RecordingIndexer();
+
+ IndexFileMeta archive =
+ new PkFullTextIndexFile(fileIO, pathFactory())
+ .build(
+ dataFile("data-1", 3),
+ reader,
+ new DataField(7, "content",
DataTypes.STRING()),
+ indexer);
+
+ assertThat(indexer.rowIds).containsExactly(0L, 1L, 2L);
+ assertThat(indexer.values)
+ .containsExactly(
+ BinaryString.fromString("first"), null,
BinaryString.fromString("third"));
+ assertThat(archive.indexType()).isEqualTo("full-text");
+ assertThat(archive.rowCount()).isEqualTo(3);
+ assertThat(archive.globalIndexMeta().rowRangeStart()).isZero();
+ assertThat(archive.globalIndexMeta().rowRangeEnd()).isEqualTo(2);
+ assertThat(archive.globalIndexMeta().indexFieldId()).isEqualTo(7);
+ DataInputDeserializer sourceInput =
+ new
DataInputDeserializer(archive.globalIndexMeta().sourceMeta());
+ assertThat(sourceInput.readInt()).isEqualTo(1);
+ PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(archive);
+ assertThat(sourceMeta.sourceFile().fileName()).isEqualTo("data-1");
+ assertThat(sourceMeta.sourceFile().rowCount()).isEqualTo(3);
+ }
+
+ @Test
+ void testBuildsMultiSourceArchiveWithContinuousRowIds() throws Exception {
+ PkFullTextDataFileReader first = mock(PkFullTextDataFileReader.class);
+ when(first.rowCount()).thenReturn(2L);
+ when(first.readNextText())
+ .thenReturn(BinaryString.fromString("first"),
BinaryString.fromString("second"));
+ PkFullTextDataFileReader second = mock(PkFullTextDataFileReader.class);
+ when(second.rowCount()).thenReturn(1L);
+
when(second.readNextText()).thenReturn(BinaryString.fromString("third"));
+ RecordingIndexer indexer = new RecordingIndexer();
+
+ IndexFileMeta archive =
+ new PkFullTextIndexFile(LocalFileIO.create(), pathFactory())
+ .build(
+ Arrays.asList(
+ new PkFullTextIndexFile.Source(
+ dataFile("data-1", 2), first),
+ new PkFullTextIndexFile.Source(
+ dataFile("data-2", 1),
second)),
+ new DataField(7, "content",
DataTypes.STRING()),
+ indexer);
+
+ assertThat(indexer.rowIds).containsExactly(0L, 1L, 2L);
+ assertThat(indexer.values)
+ .containsExactly(
+ BinaryString.fromString("first"),
+ BinaryString.fromString("second"),
+ BinaryString.fromString("third"));
+ assertThat(archive.rowCount()).isEqualTo(3);
+ assertThat(archive.globalIndexMeta().rowRangeEnd()).isEqualTo(2);
+ PrimaryKeyIndexSourceMeta sourceMeta =
PrimaryKeyIndexSourceMeta.fromIndexFile(archive);
+ assertThat(sourceMeta.sourceFiles())
+ .extracting(source -> source.fileName() + ":" +
source.rowCount())
+ .containsExactly("data-1:2", "data-2:1");
+ }
+
+ @Test
+ void testBuilderOpensTheFileReaderAndDelegatesEffectiveOptions() throws
Exception {
+ DataFileMeta source = dataFile("data-1", 3);
+ DataField textField = new DataField(7, "content", DataTypes.STRING());
+ Options options = new Options();
+ PkFullTextIndexFile indexFile = mock(PkFullTextIndexFile.class);
+ PkFullTextDataFileReader.Factory readerFactory =
+ mock(PkFullTextDataFileReader.Factory.class);
+ PkFullTextDataFileReader reader = mock(PkFullTextDataFileReader.class);
+ IndexFileMeta expected = mock(IndexFileMeta.class);
+ when(readerFactory.create(source)).thenReturn(reader);
+ when(indexFile.build(source, reader, textField,
options)).thenReturn(expected);
+
+ IndexFileMeta actual =
+ new PkFullTextIndexBuilder(indexFile, readerFactory,
textField, options)
+ .build(source);
+
+ assertThat(actual).isSameAs(expected);
+ verify(readerFactory).create(source);
+ verify(indexFile).build(source, reader, textField, options);
+ }
+
+ @Test
+ void testBuilderDelegatesOrderedSources() throws Exception {
+ DataFileMeta first = dataFile("data-1", 2);
+ DataFileMeta second = dataFile("data-2", 1);
+ DataField textField = new DataField(7, "content", DataTypes.STRING());
+ Options options = new Options();
+ PkFullTextIndexFile indexFile = mock(PkFullTextIndexFile.class);
+ PkFullTextDataFileReader.Factory readerFactory =
+ mock(PkFullTextDataFileReader.Factory.class);
+
when(readerFactory.create(first)).thenReturn(mock(PkFullTextDataFileReader.class));
+
when(readerFactory.create(second)).thenReturn(mock(PkFullTextDataFileReader.class));
+ IndexFileMeta expected = mock(IndexFileMeta.class);
+ when(indexFile.build(anyList(), same(textField),
same(options))).thenReturn(expected);
+
+ IndexFileMeta actual =
+ new PkFullTextIndexBuilder(indexFile, readerFactory,
textField, options)
+ .build(Arrays.asList(first, second));
+
+ assertThat(actual).isSameAs(expected);
+ verify(readerFactory).create(first);
+ verify(readerFactory).create(second);
+ verify(indexFile).build(anyList(), same(textField), same(options));
+ }
+
+ @Test
+ void testClosesReaderWhenIndexerCreationFails() throws Exception {
+ PkFullTextDataFileReader reader = mock(PkFullTextDataFileReader.class);
+
+ assertThatThrownBy(
+ () ->
+ new PkFullTextIndexFile(LocalFileIO.create(),
pathFactory())
+ .build(
+ dataFile("data-1", 1),
+ reader,
+ new DataField(7, "content",
DataTypes.STRING()),
+ new Options()))
+ .isInstanceOf(RuntimeException.class);
+
+ verify(reader).close();
+ }
+
+ private static DataFileMeta dataFile(String fileName, long rowCount) {
+ return DataFileMeta.forAppend(
+ fileName,
+ 100,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 1,
+ 1,
+ Collections.emptyList(),
+ null,
+ FileSource.COMPACT,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ private IndexPathFactory pathFactory() {
+ Path directory = new Path(tempPath.toUri());
+ return new IndexPathFactory() {
+ @Override
+ public Path toPath(String fileName) {
+ return new Path(directory, fileName);
+ }
+
+ @Override
+ public Path newPath() {
+ return new Path(directory, UUID.randomUUID().toString());
+ }
+
+ @Override
+ public boolean isExternalPath() {
+ return false;
+ }
+ };
+ }
+
+ private static class RecordingIndexer implements GlobalIndexer {
+
+ private final List<Object> values = new ArrayList<>();
+ private final List<Long> rowIds = new ArrayList<>();
+
+ @Override
+ public GlobalIndexWriter createWriter(GlobalIndexFileWriter
fileWriter) {
+ return new GlobalIndexSingleColumnWriter() {
+ @Override
+ public void write(Object key, long relativeRowId) {
+ values.add(key);
+ rowIds.add(relativeRowId);
+ }
+
+ @Override
+ public List<ResultEntry> finish() {
+ try {
+ String fileName = fileWriter.newFileName("full-text");
+ try (PositionOutputStream out =
fileWriter.newOutputStream(fileName)) {
+ out.write(1);
+ }
+ return Collections.singletonList(
+ new ResultEntry(fileName, rowIds.size(), new
byte[] {2}));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+ }
+
+ @Override
+ public GlobalIndexReader createReader(
+ GlobalIndexFileReader fileReader,
+ List<GlobalIndexIOMeta> files,
+ ExecutorService executor) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
index 3d4ef2413f..29266ee7a0 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
@@ -27,7 +27,7 @@ import org.apache.paimon.reader.FileRecordReader;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.stats.SimpleStats;
import org.apache.paimon.table.source.DataSplit;
-import org.apache.paimon.table.source.PrimaryKeyVectorPositionReader;
+import org.apache.paimon.table.source.PrimaryKeyIndexPositionReader;
import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.RoaringBitmap32;
@@ -63,7 +63,7 @@ class PrimaryKeyIndexedSplitReadTest {
RecordReader<InternalRow> reader =
new PrimaryKeyIndexedSplitRead(rawRead).createReader(split);
- assertThat(reader).isInstanceOf(PrimaryKeyVectorPositionReader.class);
+ assertThat(reader).isInstanceOf(PrimaryKeyIndexPositionReader.class);
ArgumentCaptor<RoaringBitmap32> positions =
ArgumentCaptor.forClass(RoaringBitmap32.class);
verify(rawRead).createFileReader(eq(dataSplit), positions.capture());
assertThat(positions.getValue()).isEqualTo(RoaringBitmap32.bitmapOf(1,
3));
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyScoredResultTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyScoredResultTest.java
new file mode 100644
index 0000000000..86669b56be
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyScoredResultTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.data.BinaryRow;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.utils.Range;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for snapshot-scoped scored physical primary-key results. */
+class PrimaryKeyScoredResultTest {
+
+ @Test
+ void testMaterializesDeterministicFileSplitsAndAlignedScores() {
+ DataFileMeta fileB = dataFile("file-b");
+ DataFileMeta fileA = dataFile("file-a");
+ DeletionFile deletionB = new DeletionFile("dv-b", 10, 20, 1L);
+ DeletionFile deletionA = new DeletionFile("dv-a", 10, 20, 1L);
+ DataSplit source =
+ DataSplit.builder()
+ .withSnapshot(7)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(2)
+ .withBucketPath("bucket-2")
+ .withTotalBuckets(3)
+ .withDataFiles(Arrays.asList(fileB, fileA))
+ .withDataDeletionFiles(Arrays.asList(deletionB,
deletionA))
+ .build();
+ List<PrimaryKeySearchPosition> positions =
+ Arrays.asList(
+ new PrimaryKeySearchPosition(BinaryRow.EMPTY_ROW, 2,
"file-b", 4, 0.25F),
+ new PrimaryKeySearchPosition(BinaryRow.EMPTY_ROW, 2,
"file-a", 3, 0.5F),
+ new PrimaryKeySearchPosition(BinaryRow.EMPTY_ROW, 2,
"file-a", 1, 0.75F));
+
+ PrimaryKeyScoredResult result =
+ new PrimaryKeyScoredResult(7,
Collections.singletonList(source), positions);
+
+ assertThat(result.snapshotId()).isEqualTo(7);
+ assertThat(result.positions()).containsExactlyElementsOf(positions);
+ assertThat(result.splits()).hasSize(2);
+ IndexedSplit first = result.splits().get(0);
+ assertThat(first.dataSplit().dataFiles()).containsExactly(fileA);
+
assertThat(first.dataSplit().deletionFiles().get()).containsExactly(deletionA);
+ assertThat(first.rowRanges()).containsExactly(new Range(1, 1), new
Range(3, 3));
+ assertThat(first.scores()).containsExactly(0.75F, 0.5F);
+ IndexedSplit second = result.splits().get(1);
+ assertThat(second.dataSplit().dataFiles()).containsExactly(fileB);
+
assertThat(second.dataSplit().deletionFiles().get()).containsExactly(deletionB);
+ assertThat(second.rowRanges()).containsExactly(new Range(4, 4));
+ assertThat(second.scores()).containsExactly(0.25F);
+ }
+
+ private static DataFileMeta dataFile(String fileName) {
+ return DataFileMeta.forAppend(
+ fileName,
+ 100,
+ 5,
+ SimpleStats.EMPTY_STATS,
+ 0,
+ 1,
+ 1,
+ Collections.emptyList(),
+ null,
+ FileSource.COMPACT,
+ null,
+ null,
+ null,
+ null);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySearchRankerTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySearchRankerTest.java
new file mode 100644
index 0000000000..aef7fbf003
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySearchRankerTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.data.BinaryRow;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for deterministic reciprocal-rank fusion of physical search
positions. */
+class PrimaryKeySearchRankerTest {
+
+ @Test
+ void testFusesDuplicatePositionsAndAssignsTheSameRankToLocalTies() {
+ PrimaryKeySearchPosition a = position("a", 0, 10F);
+ PrimaryKeySearchPosition b = position("b", 0, 10F);
+ PrimaryKeySearchPosition c = position("c", 0, 5F);
+ List<PrimaryKeySearchPosition> fused =
+ PrimaryKeySearchRanker.rrf(
+ Arrays.asList(
+ Arrays.asList(c, b, a),
+ Arrays.asList(a.withScore(1F),
c.withScore(8F))),
+ 3);
+
+ assertThat(fused)
+ .extracting(PrimaryKeySearchPosition::dataFileName)
+ .containsExactly("a", "c", "b");
+ assertThat(fused.get(0).score())
+ .isCloseTo((float) (1D / 61D + 1D / 62D), within(0.000001F));
+ assertThat(fused.get(1).score())
+ .isCloseTo((float) (1D / 63D + 1D / 61D), within(0.000001F));
+ assertThat(fused.get(2).score()).isCloseTo((float) (1D / 61D),
within(0.000001F));
+ }
+
+ @Test
+ void testUsesRouteWeightsAndDeterministicPhysicalTieBreaking() {
+ PrimaryKeySearchPosition a = position("a", 0, 1F);
+ PrimaryKeySearchPosition b = position("b", 0, 1F);
+ List<PrimaryKeySearchPosition> fused =
+ PrimaryKeySearchRanker.weightedRrf(
+ Arrays.asList(
+ new PrimaryKeySearchRanker.Ranking(
+ Collections.singletonList(a), 2D),
+ new PrimaryKeySearchRanker.Ranking(
+ Collections.singletonList(b), 2D)),
+ 1);
+
+ assertThat(fused).hasSize(1);
+ assertThat(fused.get(0).dataFileName()).isEqualTo("a");
+ assertThat(fused.get(0).score()).isCloseTo((float) (2D / 61D),
within(0.000001F));
+ }
+
+ @Test
+ void testWeightedScoreNormalizesEachPhysicalRoute() {
+ PrimaryKeySearchPosition a = position("a", 0, 0F);
+ PrimaryKeySearchPosition b = position("b", 0, 10F);
+ PrimaryKeySearchPosition c = position("c", 0, 0F);
+
+ List<PrimaryKeySearchPosition> fused =
+ PrimaryKeySearchRanker.weightedScore(
+ Arrays.asList(
+ new
PrimaryKeySearchRanker.Ranking(Arrays.asList(a, b), 1D),
+ new PrimaryKeySearchRanker.Ranking(
+ Arrays.asList(a.withScore(100F), c),
2D)),
+ 3);
+
+ assertThat(fused)
+ .extracting(PrimaryKeySearchPosition::dataFileName)
+ .containsExactly("a", "b", "c");
+
assertThat(fused).extracting(PrimaryKeySearchPosition::score).containsExactly(2F,
1F, 0F);
+ }
+
+ private static PrimaryKeySearchPosition position(
+ String dataFileName, long rowPosition, float score) {
+ return new PrimaryKeySearchPosition(
+ BinaryRow.EMPTY_ROW, 0, dataFileName, rowPosition, score);
+ }
+
+ private static org.assertj.core.data.Offset<Float> within(float value) {
+ return org.assertj.core.data.Offset.offset(value);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
index c99f1d0c41..cd2f4bd65c 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
@@ -97,8 +97,7 @@ class PrimaryKeyVectorPositionReaderTest {
() -> rawRead,
mock(TableSchema.class));
- assertThat(tableRead.createReader(split))
- .isInstanceOf(PrimaryKeyVectorPositionReader.class);
+
assertThat(tableRead.createReader(split)).isInstanceOf(PrimaryKeyIndexPositionReader.class);
verify(rawRead, never()).createReader(split);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorResultTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorResultTest.java
index 849fd76ba7..8769fff1a7 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorResultTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorResultTest.java
@@ -52,9 +52,13 @@ class PrimaryKeyVectorResultTest {
new PrimaryKeyVectorRead.Candidate(
BinaryRow.EMPTY_ROW, 0, "data-1", 2, 4F));
- List<IndexedSplit> splits = new PrimaryKeyVectorResult(plan,
candidates, "l2").splits();
+ PrimaryKeyVectorResult result = new PrimaryKeyVectorResult(plan,
candidates, "l2");
+ List<IndexedSplit> splits = result.splits();
assertThat(splits).hasSize(1);
+ assertThat(result.positions())
+ .extracting(PrimaryKeySearchPosition::score)
+ .containsExactly(1F / (1F + 0.1F), 1F / (1F + 1F), 1F / (1F +
4F));
IndexedSplit split = splits.get(0);
assertThat(split.dataSplit().snapshotId()).isEqualTo(11);
assertThat(split.dataSplit().dataFiles()).containsExactly(dataFile);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
index 5de2923507..64b1731fc4 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
@@ -109,6 +109,8 @@ class PrimaryKeyVectorScanTest {
when(snapshot.id()).thenReturn(11L);
when(table.coreOptions()).thenReturn(coreOptions);
when(table.latestSnapshot()).thenReturn(Optional.of(snapshot));
+
when(table.copy(Collections.singletonMap(CoreOptions.SCAN_SNAPSHOT_ID.key(),
"11")))
+ .thenReturn(table);
SnapshotReader snapshotReader = mock(SnapshotReader.class,
RETURNS_SELF);
SnapshotReader.Plan snapshotPlan = mock(SnapshotReader.Plan.class,
CALLS_REAL_METHODS);
@@ -139,7 +141,8 @@ class PrimaryKeyVectorScanTest {
when(snapshotReader.indexFileHandler()).thenReturn(indexFileHandler);
configureBatchScan(table, snapshotReader, snapshot);
- PrimaryKeyVectorScan.Plan plan = new PrimaryKeyVectorScan(table, 7,
"ivf-pq", null).scan();
+ PrimaryKeyVectorScan.Plan plan =
+ new PrimaryKeyVectorScan(table, 7, "ivf-pq", null, null,
snapshot).scan();
assertThat(plan.snapshotId()).isEqualTo(11);
assertThat(plan.splits()).hasSize(1);
@@ -147,6 +150,7 @@ class PrimaryKeyVectorScanTest {
assertThat(bucketSplit.payloadFiles())
.extracting(IndexFileMeta::fileName)
.containsExactly("ann-match");
+
verify(table).copy(Collections.singletonMap(CoreOptions.SCAN_SNAPSHOT_ID.key(),
"11"));
}
@Test