diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html index 401d451b8693..d22fb24cdf12 100644 --- a/docs/generated/core_configuration.html +++ b/docs/generated/core_configuration.html @@ -1205,6 +1205,12 @@ Boolean Enables clustering by non-primary key fields. When set to true, the physical sort order of data files is determined by the configured 'clustering.columns' instead of the primary key, optimizing query performance for non-PK columns. + +
pk-vector.index.columns
+ (none) + String + Comma-separated VECTOR columns indexed by primary-key vector indexes. Each column owns one index and must define fields.<column>.pk-vector.index.type. Index options and distance metric are also field-scoped. The first release supports exactly one column. +
postpone.batch-write-fixed-bucket
true diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java index b353b5632790..0dd64d8d2543 100644 --- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java +++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java @@ -33,6 +33,7 @@ import org.apache.paimon.options.description.DescribedEnum; import org.apache.paimon.options.description.Description; import org.apache.paimon.options.description.InlineElement; +import org.apache.paimon.utils.JsonSerdeUtil; import org.apache.paimon.utils.MathUtils; import org.apache.paimon.utils.Pair; import org.apache.paimon.utils.StringUtils; @@ -48,11 +49,13 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; import java.util.UUID; import java.util.stream.Collectors; @@ -2726,6 +2729,17 @@ public String toString() { "The batch size for lateral vector search. Each batch executes vector " + "topK search and table lookup for multiple query vectors."); + public static final ConfigOption PK_VECTOR_INDEX_COLUMNS = + key("pk-vector.index.columns") + .stringType() + .noDefaultValue() + .withDescription( + "Comma-separated VECTOR columns indexed by primary-key vector indexes. " + + "Each column owns one index and must define " + + "fields..pk-vector.index.type. Index options and distance " + + "metric are also field-scoped. The first release supports exactly " + + "one column."); + @Immutable public static final ConfigOption PK_CLUSTERING_OVERRIDE = key("pk-clustering-override") @@ -4254,6 +4268,108 @@ public int vectorSearchLateralJoinBatchSize() { return options.get(VECTOR_SEARCH_LATERAL_JOIN_BATCH_SIZE); } + public boolean primaryKeyVectorIndexEnabled() { + return options.getOptional(PK_VECTOR_INDEX_COLUMNS).isPresent(); + } + + public List primaryKeyVectorIndexColumns() { + String columns = options.get(PK_VECTOR_INDEX_COLUMNS); + if (columns == null) { + return Collections.emptyList(); + } + return Arrays.stream(columns.split(",", -1)).map(String::trim).collect(Collectors.toList()); + } + + public String primaryKeyVectorIndexColumn() { + List columns = primaryKeyVectorIndexColumns(); + checkArgument( + columns.size() == 1, + "pk-vector.index.columns must contain exactly one column in the first release, but is %s.", + columns); + return columns.get(0); + } + + @Nullable + public String primaryKeyVectorIndexType(String column) { + return options.get("fields." + column + ".pk-vector.index.type"); + } + + @Nullable + private String primaryKeyVectorIndexOptionsJson(String column) { + return options.get("fields." + column + ".pk-vector.index.options"); + } + + public Options primaryKeyVectorIndexOptions(String column) { + Options resolved = new Options(toConfiguration().toMap()); + for (Map.Entry option : + primaryKeyVectorAlgorithmOptions(column).entrySet()) { + resolved.setString(option.getKey(), option.getValue()); + } + return resolved; + } + + private Map primaryKeyVectorAlgorithmOptions(String column) { + String indexTypeKey = "fields." + column + ".pk-vector.index.type"; + String indexOptionsKey = "fields." + column + ".pk-vector.index.options"; + String algorithm = primaryKeyVectorIndexType(column); + checkArgument( + algorithm != null && !algorithm.trim().isEmpty(), + "%s must be configured before resolving index options.", + indexTypeKey); + TreeMap algorithmOptions = new TreeMap<>(); + String algorithmPrefix = algorithm + "."; + String fieldPrefix = "fields." + column + "."; + for (Map.Entry entry : toConfiguration().toMap().entrySet()) { + if (entry.getKey().startsWith(algorithmPrefix) + || (entry.getKey().startsWith(fieldPrefix) + && !entry.getKey().startsWith(fieldPrefix + "pk-vector."))) { + algorithmOptions.put(entry.getKey(), entry.getValue()); + } + } + String serialized = primaryKeyVectorIndexOptionsJson(column); + if (serialized != null && !serialized.trim().isEmpty()) { + LinkedHashMap parsed; + try { + parsed = JsonSerdeUtil.parseJsonMap(serialized, String.class); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + indexOptionsKey + " must be a JSON object of option key-value pairs.", e); + } + for (Map.Entry entry : parsed.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + checkArgument( + key != null && !key.trim().isEmpty(), + "%s contains an empty option key.", + indexOptionsKey); + checkArgument( + value != null, + "%s value for key %s must not be null.", + indexOptionsKey, + key); + String qualifiedKey = + key.startsWith(algorithmPrefix) || key.startsWith("fields.") + ? key + : algorithmPrefix + key; + String previous = algorithmOptions.put(qualifiedKey, value); + checkArgument( + previous == null || previous.equals(value), + "%s defines conflicting values for %s.", + indexOptionsKey, + qualifiedKey); + } + } + algorithmOptions.put(algorithmPrefix + "metric", primaryKeyVectorDistanceMetric(column)); + return algorithmOptions; + } + + public String primaryKeyVectorDistanceMetric(String column) { + String metric = options.get("fields." + column + ".pk-vector.distance.metric"); + return (metric == null ? "inner_product" : metric) + .toLowerCase(Locale.ROOT) + .replace('-', '_'); + } + /** Specifies the merge engine for table with primary key. */ public enum MergeEngine implements DescribedEnum { DEDUPLICATE("deduplicate", "De-duplicate and keep the last row."), diff --git a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java index 0b6d8d9fd63f..eb0365427af2 100644 --- a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java +++ b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexer.java @@ -28,6 +28,7 @@ import org.apache.paimon.types.ArrayType; import org.apache.paimon.types.DataType; import org.apache.paimon.types.FloatType; +import org.apache.paimon.types.VectorType; import java.io.IOException; import java.util.List; @@ -74,17 +75,27 @@ public class TestVectorGlobalIndexer implements VectorGlobalIndexer { public TestVectorGlobalIndexer(DataType fieldType, Options options) { checkArgument( - fieldType instanceof ArrayType - && ((ArrayType) fieldType).getElementType() instanceof FloatType, - "TestVectorGlobalIndexer only supports ARRAY, but got: " + fieldType); + isFloatVector(fieldType), + "TestVectorGlobalIndexer only supports VECTOR or ARRAY, but got: " + + fieldType); this.fieldType = fieldType; - this.dimension = options.getInteger(OPT_DIMENSION, 0); + this.dimension = + fieldType instanceof VectorType + ? ((VectorType) fieldType).getLength() + : options.getInteger(OPT_DIMENSION, 0); this.metric = options.getString(OPT_METRIC, "l2"); this.reverseScore = options.getBoolean(OPT_REVERSE_SCORE, false); this.requiredOptionKey = options.getString(OPT_REQUIRED_OPTION_KEY, null); this.requiredOptionValue = options.getString(OPT_REQUIRED_OPTION_VALUE, null); } + private static boolean isFloatVector(DataType fieldType) { + return (fieldType instanceof VectorType + && ((VectorType) fieldType).getElementType() instanceof FloatType) + || (fieldType instanceof ArrayType + && ((ArrayType) fieldType).getElementType() instanceof FloatType); + } + @Override public GlobalIndexWriter createWriter(GlobalIndexFileWriter fileWriter) throws IOException { return new TestVectorGlobalIndexWriter(fileWriter, dimension); diff --git a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerTest.java b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerTest.java new file mode 100644 index 000000000000..7ab5c45cefd4 --- /dev/null +++ b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerTest.java @@ -0,0 +1,41 @@ +/* + * 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.globalindex.testvector; + +import org.apache.paimon.options.Options; +import org.apache.paimon.types.DataTypes; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Tests for {@link TestVectorGlobalIndexer}. */ +class TestVectorGlobalIndexerTest { + + @Test + void testUsesDimensionFromVectorType() { + Options options = new Options(); + options.setString(TestVectorGlobalIndexer.OPT_DIMENSION, "99"); + + TestVectorGlobalIndexer indexer = + new TestVectorGlobalIndexer(DataTypes.VECTOR(2, DataTypes.FLOAT()), options); + + assertThat(indexer.dimension()).isEqualTo(2); + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java b/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java index 83a6224f3e06..727a6536e3bc 100644 --- a/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java +++ b/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexMeta.java @@ -43,13 +43,15 @@ public class GlobalIndexMeta { new DataField(2, "_INDEX_FIELD_ID", new IntType(false)), new DataField( 3, "_EXTRA_FIELD_IDS", DataTypes.ARRAY(new IntType(false))), - new DataField(4, "_INDEX_META", DataTypes.BYTES()))); + new DataField(4, "_INDEX_META", DataTypes.BYTES()), + new DataField(5, "_SOURCE_META", DataTypes.BYTES()))); private final long rowRangeStart; private final long rowRangeEnd; private final int indexFieldId; @Nullable private final int[] extraFieldIds; @Nullable private final byte[] indexMeta; + @Nullable private final byte[] sourceMeta; public GlobalIndexMeta( long rowRangeStart, @@ -57,11 +59,22 @@ public GlobalIndexMeta( int indexFieldId, @Nullable int[] extraFieldIds, @Nullable byte[] indexMeta) { + this(rowRangeStart, rowRangeEnd, indexFieldId, extraFieldIds, indexMeta, null); + } + + public GlobalIndexMeta( + long rowRangeStart, + long rowRangeEnd, + int indexFieldId, + @Nullable int[] extraFieldIds, + @Nullable byte[] indexMeta, + @Nullable byte[] sourceMeta) { this.rowRangeStart = rowRangeStart; this.rowRangeEnd = rowRangeEnd; this.indexFieldId = indexFieldId; this.extraFieldIds = extraFieldIds; this.indexMeta = indexMeta; + this.sourceMeta = sourceMeta; } public long rowRangeStart() { @@ -85,11 +98,18 @@ public int[] extraFieldIds() { return extraFieldIds; } + /** Metadata produced and consumed by the global-index implementation. */ @Nullable public byte[] indexMeta() { return indexMeta; } + /** Metadata describing how index row ids map to their source data. */ + @Nullable + public byte[] sourceMeta() { + return sourceMeta; + } + /** All indexed field ids in order: the primary {@link #indexFieldId} followed by the rest. */ public List getIndexedFieldIds() { List ids = new ArrayList<>(); diff --git a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java index 6d98e61248bb..a2acfd0ffcf6 100644 --- a/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java +++ b/paimon-core/src/main/java/org/apache/paimon/index/IndexFileMetaSerializer.java @@ -50,7 +50,8 @@ public InternalRow toRow(IndexFileMeta record) { globalIndexMeta.extraFieldIds() == null ? null : new GenericArray(globalIndexMeta.extraFieldIds()), - globalIndexMeta.indexMeta()); + globalIndexMeta.indexMeta(), + globalIndexMeta.sourceMeta()); return GenericRow.of( fromString(record.indexType()), fromString(record.fileName()), @@ -65,16 +66,25 @@ public InternalRow toRow(IndexFileMeta record) { public IndexFileMeta fromRow(InternalRow row) { GlobalIndexMeta globalIndexMeta = null; if (!row.isNullAt(6)) { - InternalRow globalIndexRow = row.getRow(6, 5); + InternalRow globalIndexRow = row.getRow(6, 6); Long rowRangeStart = globalIndexRow.getLong(0); Long rowRangeEnd = globalIndexRow.getLong(1); Integer indexFieldId = globalIndexRow.getInt(2); int[] extralFields = globalIndexRow.isNullAt(3) ? null : globalIndexRow.getArray(3).toIntArray(); byte[] indexMeta = globalIndexRow.isNullAt(4) ? null : globalIndexRow.getBinary(4); + byte[] sourceMeta = + globalIndexRow.getFieldCount() <= 5 || globalIndexRow.isNullAt(5) + ? null + : globalIndexRow.getBinary(5); globalIndexMeta = new GlobalIndexMeta( - rowRangeStart, rowRangeEnd, indexFieldId, extralFields, indexMeta); + rowRangeStart, + rowRangeEnd, + indexFieldId, + extralFields, + indexMeta, + sourceMeta); } return new IndexFileMeta( row.getString(0).toString(), diff --git a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFile.java b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFile.java new file mode 100644 index 000000000000..9a3306734e36 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFile.java @@ -0,0 +1,269 @@ +/* + * 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.pkvector; + +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.VectorGlobalIndexer; +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.io.DataFileMeta; +import org.apache.paimon.options.Options; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.VectorType; +import org.apache.paimon.utils.IOUtils; + +import javax.annotation.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.LongPredicate; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** Builds immutable ANN payloads whose index ids are source data-file row positions. */ +public class PkVectorAnnSegmentFile extends IndexFile { + + public PkVectorAnnSegmentFile(FileIO fileIO, IndexPathFactory pathFactory) { + super(fileIO, pathFactory); + } + + public IndexFileMeta build( + List sources, + DataField vectorField, + Options indexOptions, + String metric, + String indexType) + throws IOException { + checkArgument(!sources.isEmpty(), "An ANN segment must reference source files."); + long totalRowCount = 0; + List sourceFiles = new ArrayList<>(sources.size()); + for (Source source : sources) { + totalRowCount = Math.addExact(totalRowCount, source.sourceFile.rowCount()); + sourceFiles.add(source.sourceFile); + } + checkArgument(totalRowCount > 0, "An ANN segment must reference at least one source row."); + + GlobalIndexer indexer = GlobalIndexer.create(indexType, vectorField, indexOptions); + checkArgument( + indexer instanceof VectorGlobalIndexer, + "Index algorithm %s does not implement VectorGlobalIndexer.", + indexType); + String indexerMetric = normalizeMetric(((VectorGlobalIndexer) indexer).metric()); + checkArgument( + normalizeMetric(metric).equals(indexerMetric), + "Configured metric %s does not match index algorithm metric %s.", + metric, + indexerMetric); + + SegmentFileWriter fileWriter = new SegmentFileWriter(); + GlobalIndexWriter writer = null; + boolean success = false; + try { + writer = indexer.createWriter(fileWriter); + checkArgument( + writer instanceof GlobalIndexSingleColumnWriter, + "Index algorithm %s does not create a single-column writer.", + indexType); + GlobalIndexSingleColumnWriter singleColumnWriter = + (GlobalIndexSingleColumnWriter) writer; + long liveRowCount = 0; + long fileOffset = 0; + int dimension = -1; + for (Source source : sources) { + PkVectorReader vectors = source.openReader(); + try { + checkArgument( + vectors.rowCount() == source.sourceFile.rowCount(), + "Vector row count %s does not match source file %s row count %s.", + vectors.rowCount(), + source.sourceFile.fileName(), + source.sourceFile.rowCount()); + if (dimension < 0) { + dimension = vectors.dimension(); + } + checkArgument( + vectors.dimension() == dimension, + "Vector source %s dimension %s does not match dimension %s.", + source.sourceFile.fileName(), + vectors.dimension(), + dimension); + if (vectorField.type() instanceof VectorType) { + checkArgument( + ((VectorType) vectorField.type()).getLength() == dimension, + "Vector field dimension %s does not match source vector dimension %s.", + ((VectorType) vectorField.type()).getLength(), + dimension); + } + + float[] vector = new float[dimension]; + for (long rowPosition = 0; rowPosition < vectors.rowCount(); rowPosition++) { + boolean present = vectors.readNextVector(vector); + if (!present || source.excludedPosition.test(rowPosition)) { + continue; + } + singleColumnWriter.write(vector, fileOffset + rowPosition); + liveRowCount++; + } + } finally { + source.closeReader(vectors); + } + fileOffset += source.sourceFile.rowCount(); + } + + List results = writer.finish(); + checkArgument( + results.size() == 1, + "ANN segment build must produce exactly one payload file, but produced %s.", + results.size()); + ResultEntry result = results.get(0); + Path payloadPath = fileWriter.path(result.fileName()); + byte[] payloadMetadata = result.meta() == null ? new byte[0] : result.meta(); + IndexFileMeta segment = + new IndexFileMeta( + indexType, + result.fileName(), + fileIO.getFileSize(payloadPath), + liveRowCount, + new GlobalIndexMeta( + 0, + totalRowCount - 1, + vectorField.id(), + null, + payloadMetadata, + new PkVectorSourceMeta(sourceFiles).serialize()), + pathFactory.isExternalPath() ? payloadPath.toString() : null); + success = true; + return segment; + } finally { + if (writer instanceof AutoCloseable) { + IOUtils.closeQuietly((AutoCloseable) writer); + } + if (!success) { + fileWriter.deleteCreatedFiles(); + } + } + } + + private static PkVectorSourceFile sourceMetadata(DataFileMeta sourceFile) { + return new PkVectorSourceFile(sourceFile.fileName(), sourceFile.rowCount()); + } + + private static String normalizeMetric(String metric) { + return metric.toLowerCase(Locale.ROOT).replace('-', '_'); + } + + private class SegmentFileWriter implements GlobalIndexFileWriter { + + private final Map 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, "ANN payload file %s was not allocated.", fileName); + return path; + } + + private void deleteCreatedFiles() { + for (Path path : createdFiles.values()) { + fileIO.deleteQuietly(path); + } + } + } + + /** One vector source used while building an ANN segment. */ + public static class Source { + + private final PkVectorSourceFile sourceFile; + @Nullable private final PkVectorReader vectors; + @Nullable private final ReaderFactory readerFactory; + private final LongPredicate excludedPosition; + + public Source(DataFileMeta sourceFile, PkVectorReader vectors) { + this(sourceFile, vectors, position -> false); + } + + public Source( + DataFileMeta sourceFile, PkVectorReader vectors, LongPredicate excludedPosition) { + this(sourceMetadata(sourceFile), vectors, excludedPosition); + } + + Source( + PkVectorSourceFile sourceFile, + PkVectorReader vectors, + LongPredicate excludedPosition) { + this.sourceFile = sourceFile; + this.vectors = vectors; + this.readerFactory = null; + this.excludedPosition = excludedPosition; + } + + private Source( + PkVectorSourceFile sourceFile, + ReaderFactory readerFactory, + LongPredicate excludedPosition) { + this.sourceFile = sourceFile; + this.vectors = null; + this.readerFactory = readerFactory; + this.excludedPosition = excludedPosition; + } + + static Source lazy(PkVectorSourceFile sourceFile, ReaderFactory readerFactory) { + return new Source(sourceFile, readerFactory, position -> false); + } + + private PkVectorReader openReader() throws IOException { + return vectors != null ? vectors : readerFactory.open(); + } + + private void closeReader(PkVectorReader reader) throws IOException { + if (vectors == null) { + reader.close(); + } + } + + @FunctionalInterface + interface ReaderFactory { + PkVectorReader open() throws IOException; + } + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java new file mode 100644 index 000000000000..e3b2fbba7f1a --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentSearcher.java @@ -0,0 +1,272 @@ +/* + * 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.pkvector; + +import org.apache.paimon.deletionvectors.DeletionVector; +import org.apache.paimon.fs.FileIO; +import org.apache.paimon.globalindex.GlobalIndexIOMeta; +import org.apache.paimon.globalindex.GlobalIndexReader; +import org.apache.paimon.globalindex.GlobalIndexer; +import org.apache.paimon.globalindex.ScoredGlobalIndexResult; +import org.apache.paimon.globalindex.VectorGlobalIndexer; +import org.apache.paimon.index.GlobalIndexMeta; +import org.apache.paimon.index.IndexFileMeta; +import org.apache.paimon.options.Options; +import org.apache.paimon.predicate.VectorSearch; +import org.apache.paimon.types.DataField; +import org.apache.paimon.utils.IOUtils; +import org.apache.paimon.utils.Range; +import org.apache.paimon.utils.RoaringNavigableMap64; + +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** Searches one ANN payload and maps its segment-local ids back to source row positions. */ +public class PkVectorAnnSegmentSearcher { + + private static final Comparator BEST_FIRST = + Comparator.comparingDouble((Candidate candidate) -> candidate.distance) + .thenComparing(candidate -> candidate.dataFileName) + .thenComparingLong(candidate -> candidate.rowPosition); + + private final FileIO fileIO; + private final PkVectorAnnSegmentFile annSegmentFile; + private final DataField vectorField; + private final Options indexOptions; + private final String metric; + private final ExecutorService executor; + + public PkVectorAnnSegmentSearcher( + FileIO fileIO, + PkVectorAnnSegmentFile annSegmentFile, + DataField vectorField, + Options indexOptions, + String metric, + ExecutorService executor) { + this.fileIO = fileIO; + this.annSegmentFile = annSegmentFile; + this.vectorField = vectorField; + this.indexOptions = indexOptions; + this.metric = normalizeMetric(metric); + this.executor = executor; + } + + public List search( + IndexFileMeta segment, + PkVectorSourceMeta sourceMeta, + float[] query, + int limit, + @Nullable DeletionVector deletionVector, + Map searchOptions) { + Map deletionVectors = new HashMap<>(); + if (deletionVector != null) { + checkArgument( + sourceMeta.sourceFiles().size() == 1, + "A single deletion vector can only search a single-source ANN segment."); + deletionVectors.put(sourceMeta.sourceFiles().get(0).fileName(), deletionVector); + } + return search(segment, sourceMeta, query, limit, deletionVectors, searchOptions); + } + + public List search( + IndexFileMeta segment, + PkVectorSourceMeta sourceMeta, + float[] query, + int limit, + Map deletionVectors, + Map searchOptions) { + checkArgument(limit > 0, "Vector search limit must be positive: %s.", limit); + GlobalIndexMeta globalIndexMeta = segment.globalIndexMeta(); + checkArgument( + globalIndexMeta != null && globalIndexMeta.sourceMeta() != null, + "Vector segment %s has no source metadata.", + segment.fileName()); + GlobalIndexer indexer = + GlobalIndexer.create(segment.indexType(), vectorField, indexOptions); + checkArgument( + indexer instanceof VectorGlobalIndexer, + "Index algorithm %s does not implement VectorGlobalIndexer.", + segment.indexType()); + String readerMetric = normalizeMetric(((VectorGlobalIndexer) indexer).metric()); + checkArgument( + metric.equals(readerMetric), + "ANN segment metric %s does not match index reader metric %s.", + metric, + readerMetric); + + GlobalIndexIOMeta ioMeta = + new GlobalIndexIOMeta( + annSegmentFile.path(segment), + segment.fileSize(), + globalIndexMeta.indexMeta()); + GlobalIndexReader reader = + indexer.createReader( + meta -> fileIO.newInputStream(meta.filePath()), + Collections.singletonList(ioMeta), + executor); + try { + VectorSearch search = new VectorSearch(query, limit, vectorField.name(), searchOptions); + RoaringNavigableMap64 liveRows = + liveRowPositions(sourceMeta.sourceFiles(), deletionVectors); + if (liveRows != null) { + search.withIncludeRowIds(liveRows); + } + Optional result = reader.visitVectorSearch(search).join(); + if (!result.isPresent()) { + return Collections.emptyList(); + } + + long sourceRowCount = totalRowCount(sourceMeta.sourceFiles()); + List candidates = new ArrayList<>(); + ScoredGlobalIndexResult scored = result.get(); + for (long ordinal : scored.results()) { + checkArgument( + ordinal >= 0 && ordinal < sourceRowCount, + "ANN segment %s returned ordinal %s outside [0, %s).", + segment.fileName(), + ordinal, + sourceRowCount); + FilePosition filePosition = filePosition(sourceMeta.sourceFiles(), ordinal); + DeletionVector deletionVector = deletionVectors.get(filePosition.dataFileName); + checkArgument( + deletionVector == null + || !deletionVector.isDeleted(filePosition.rowPosition), + "ANN segment %s returned snapshot-deleted row position %s.", + segment.fileName(), + filePosition.rowPosition); + candidates.add( + new Candidate( + filePosition.dataFileName, + filePosition.rowPosition, + scoreToDistance(scored.scoreGetter().score(ordinal), metric))); + } + Collections.sort(candidates, BEST_FIRST); + return Collections.unmodifiableList(candidates); + } finally { + IOUtils.closeQuietly(reader); + } + } + + @Nullable + private static RoaringNavigableMap64 liveRowPositions( + List sourceFiles, Map deletionVectors) { + if (deletionVectors.isEmpty()) { + return null; + } + RoaringNavigableMap64 live = new RoaringNavigableMap64(); + RoaringNavigableMap64 deleted = new RoaringNavigableMap64(); + long fileOffset = 0; + for (PkVectorSourceFile sourceFile : sourceFiles) { + if (sourceFile.rowCount() > 0) { + live.addRange(new Range(fileOffset, fileOffset + sourceFile.rowCount() - 1)); + } + DeletionVector deletionVector = deletionVectors.get(sourceFile.fileName()); + if (deletionVector != null) { + final long offset = fileOffset; + deletionVector.forEachDeletedPosition(position -> deleted.add(offset + position)); + } + fileOffset += sourceFile.rowCount(); + } + live.andNot(deleted); + return live; + } + + private static long totalRowCount(List sourceFiles) { + long total = 0; + for (PkVectorSourceFile sourceFile : sourceFiles) { + total = Math.addExact(total, sourceFile.rowCount()); + } + return total; + } + + private static FilePosition filePosition(List sourceFiles, long ordinal) { + long fileOffset = 0; + for (PkVectorSourceFile sourceFile : sourceFiles) { + long nextOffset = fileOffset + sourceFile.rowCount(); + if (ordinal < nextOffset) { + return new FilePosition(sourceFile.fileName(), ordinal - fileOffset); + } + fileOffset = nextOffset; + } + throw new IllegalArgumentException("ANN ordinal is outside source files: " + ordinal); + } + + private static float scoreToDistance(float score, String metric) { + if ("l2".equals(metric)) { + return 1F / score - 1F; + } else if ("cosine".equals(metric)) { + return 1F - score; + } else if ("inner_product".equals(metric)) { + return -score; + } + throw new IllegalArgumentException("Unsupported ANN vector metric: " + metric); + } + + private static String normalizeMetric(String metric) { + return metric.toLowerCase(Locale.ROOT).replace('-', '_'); + } + + /** One ANN candidate addressed by source-file row position. */ + public static class Candidate { + + private final long rowPosition; + private final float distance; + private final String dataFileName; + + private Candidate(String dataFileName, long rowPosition, float distance) { + this.dataFileName = dataFileName; + this.rowPosition = rowPosition; + this.distance = distance; + } + + public String dataFileName() { + return dataFileName; + } + + public long rowPosition() { + return rowPosition; + } + + public float distance() { + return distance; + } + } + + private static class FilePosition { + + private final String dataFileName; + private final long rowPosition; + + private FilePosition(String dataFileName, long rowPosition) { + this.dataFileName = dataFileName; + this.rowPosition = rowPosition; + } + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorReader.java b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorReader.java new file mode 100644 index 000000000000..b173607ce082 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorReader.java @@ -0,0 +1,33 @@ +/* + * 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.pkvector; + +import java.io.Closeable; +import java.io.IOException; + +/** Sequential vectors in physical data-file row order. */ +public interface PkVectorReader extends Closeable { + + int dimension(); + + long rowCount(); + + /** Reads the next physical row and returns whether it contains a non-null vector. */ + boolean readNextVector(float[] reuse) throws IOException; +} diff --git a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceFile.java b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceFile.java new file mode 100644 index 000000000000..87429d5694d5 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceFile.java @@ -0,0 +1,61 @@ +/* + * 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.pkvector; + +import java.util.Objects; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** Immutable source data-file identity captured when a vector segment is built. */ +public final class PkVectorSourceFile { + + private final String fileName; + private final long rowCount; + + public PkVectorSourceFile(String fileName, long rowCount) { + this.fileName = Objects.requireNonNull(fileName); + this.rowCount = rowCount; + checkArgument(rowCount >= 0, "Source file row count must not be negative."); + } + + public String fileName() { + return fileName; + } + + public long rowCount() { + return rowCount; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PkVectorSourceFile that = (PkVectorSourceFile) o; + return rowCount == that.rowCount && Objects.equals(fileName, that.fileName); + } + + @Override + public int hashCode() { + return Objects.hash(fileName, rowCount); + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceMeta.java b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceMeta.java new file mode 100644 index 000000000000..d0a6a8171ca4 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/index/pkvector/PkVectorSourceMeta.java @@ -0,0 +1,91 @@ +/* + * 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.pkvector; + +import org.apache.paimon.index.GlobalIndexMeta; +import org.apache.paimon.index.IndexFileMeta; +import org.apache.paimon.io.DataInputDeserializer; +import org.apache.paimon.io.DataOutputSerializer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** Ordered source data files for a primary-key vector index payload. */ +public final class PkVectorSourceMeta { + + private static final int VERSION = 1; + + private final List sourceFiles; + + public PkVectorSourceMeta(List sourceFiles) { + this.sourceFiles = Collections.unmodifiableList(new ArrayList<>(sourceFiles)); + checkArgument(!this.sourceFiles.isEmpty(), "A vector index must reference source files."); + } + + public List sourceFiles() { + return sourceFiles; + } + + public static PkVectorSourceMeta fromIndexFile(IndexFileMeta indexFile) { + GlobalIndexMeta globalIndexMeta = indexFile.globalIndexMeta(); + checkArgument( + globalIndexMeta != null && globalIndexMeta.sourceMeta() != null, + "Vector index file %s has no source metadata.", + indexFile.fileName()); + return deserialize(globalIndexMeta.sourceMeta()); + } + + public byte[] serialize() { + try { + DataOutputSerializer output = new DataOutputSerializer(128); + output.writeInt(VERSION); + output.writeInt(sourceFiles.size()); + for (PkVectorSourceFile sourceFile : sourceFiles) { + output.writeUTF(sourceFile.fileName()); + output.writeLong(sourceFile.rowCount()); + } + return output.getCopyOfBuffer(); + } catch (IOException e) { + throw new RuntimeException("Failed to serialize vector source metadata.", e); + } + } + + public static PkVectorSourceMeta deserialize(byte[] bytes) { + try { + DataInputDeserializer input = new DataInputDeserializer(bytes); + int version = input.readInt(); + checkArgument(version == VERSION, "Unsupported vector source version: %s.", version); + int sourceFileCount = input.readInt(); + checkArgument(sourceFileCount > 0, "A vector index must reference source files."); + List sourceFiles = new ArrayList<>(sourceFileCount); + for (int i = 0; i < sourceFileCount; i++) { + sourceFiles.add(new PkVectorSourceFile(input.readUTF(), input.readLong())); + } + checkArgument( + input.available() == 0, "Unexpected trailing bytes in vector source metadata."); + return new PkVectorSourceMeta(sourceFiles); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to deserialize vector source metadata.", e); + } + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java index 60113adff1c9..ef0b68d14240 100644 --- a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java +++ b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestEntrySerializer.java @@ -60,7 +60,8 @@ public InternalRow convertTo(IndexManifestEntry record) { globalIndexMeta.extraFieldIds() == null ? null : new GenericArray(globalIndexMeta.extraFieldIds()), - globalIndexMeta.indexMeta()); + globalIndexMeta.indexMeta(), + globalIndexMeta.sourceMeta()); return GenericRow.of( record.kind().toByteValue(), serializeBinaryRow(record.partition()), @@ -82,16 +83,25 @@ public IndexManifestEntry convertFrom(int version, InternalRow row) { GlobalIndexMeta globalIndexMeta = null; if (!row.isNullAt(9)) { - InternalRow globalIndexRow = row.getRow(9, 5); + InternalRow globalIndexRow = row.getRow(9, 6); long rowRangeStart = globalIndexRow.getLong(0); long rowRangeEnd = globalIndexRow.getLong(1); int indexFieldId = globalIndexRow.getInt(2); int[] extralFields = globalIndexRow.isNullAt(3) ? null : globalIndexRow.getArray(3).toIntArray(); byte[] indexMeta = globalIndexRow.isNullAt(4) ? null : globalIndexRow.getBinary(4); + byte[] sourceMeta = + globalIndexRow.getFieldCount() <= 5 || globalIndexRow.isNullAt(5) + ? null + : globalIndexRow.getBinary(5); globalIndexMeta = new GlobalIndexMeta( - rowRangeStart, rowRangeEnd, indexFieldId, extralFields, indexMeta); + rowRangeStart, + rowRangeEnd, + indexFieldId, + extralFields, + indexMeta, + sourceMeta); } return new IndexManifestEntry( diff --git a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java index 0791c2539e47..06f6a1e07768 100644 --- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java +++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java @@ -431,6 +431,7 @@ protected void updateLastColumn( } else if (change instanceof RenameColumn) { RenameColumn rename = (RenameColumn) change; assertNotUpdatingPartitionKeys(oldTableSchema, rename.fieldNames(), "rename"); + assertNotRenamingPrimaryKeyVectorIndexColumn(oldTableSchema, rename.fieldNames()); assertNotRenamingBlobColumn(newFields, rename.fieldNames()); new NestedColumnModifier(rename.fieldNames(), lazyIdentifier) { @Override @@ -975,6 +976,19 @@ private static void assertNotRenamingBlobColumn(List fields, String[] } } + private static void assertNotRenamingPrimaryKeyVectorIndexColumn( + TableSchema schema, String[] fieldNames) { + if (fieldNames.length > 1) { + return; + } + String fieldName = fieldNames[0]; + if (new CoreOptions(schema.options()).primaryKeyVectorIndexColumns().contains(fieldName)) { + throw new UnsupportedOperationException( + String.format( + "Cannot rename primary-key vector index column: [%s]", fieldName)); + } + } + private static void assertNotChangingBlobColumnType( List fields, String[] fieldNames, DataType newType) { if (fieldNames.length > 1) { diff --git a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java index 56b4676dcab5..2122c04ed515 100644 --- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java +++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java @@ -350,6 +350,8 @@ public static void validateTableSchema(TableSchema schema, Set dynamicOp fieldNamesSpecifiedAsVector.isEmpty(), "Some of the columns specified as vector-field are unknown."); + validatePrimaryKeyVectorIndex(schema, options); + validateMergeFunctionFactory(schema); validateMapStorageLayout(schema, options); @@ -883,6 +885,75 @@ private static void validateForDeletionVectors(CoreOptions options) { } } + private static void validatePrimaryKeyVectorIndex(TableSchema schema, CoreOptions options) { + if (!options.primaryKeyVectorIndexEnabled()) { + return; + } + + List indexColumns = options.primaryKeyVectorIndexColumns(); + checkArgument( + new HashSet<>(indexColumns).size() == indexColumns.size(), + "pk-vector.index.columns must not contain duplicate columns, but is %s.", + indexColumns); + checkArgument( + indexColumns.size() == 1, + "pk-vector.index.columns must contain exactly one column in the first release, but is %s.", + indexColumns); + String indexColumn = indexColumns.get(0); + String indexType = options.primaryKeyVectorIndexType(indexColumn); + checkArgument( + !StringUtils.isNullOrWhitespaceOnly(indexColumn), + "pk-vector.index.columns must contain a non-empty column."); + checkArgument( + !StringUtils.isNullOrWhitespaceOnly(indexType), + "fields.%s.pk-vector.index.type must be configured when a primary-key vector index is defined.", + indexColumn); + checkArgument( + !schema.primaryKeys().isEmpty(), + "Primary-key vector index requires a primary-key table."); + checkArgument( + options.deletionVectorsEnabled(), + "Primary-key vector index requires deletion-vectors.enabled = true."); + checkArgument( + options.mergeEngine() == MergeEngine.DEDUPLICATE + || options.mergeEngine() == MergeEngine.PARTIAL_UPDATE, + "Primary-key vector index only supports merge-engine = deduplicate or partial-update, but is %s.", + options.mergeEngine()); + checkArgument( + !options.deletionVectorsMergeOnRead(), + "Primary-key vector index with merge-engine = %s requires deletion-vectors.merge-on-read = false.", + options.mergeEngine()); + checkArgument( + options.bucket() > 0, + "Primary-key vector index requires fixed bucket mode (bucket > 0), but bucket is %s.", + options.bucket()); + checkArgument( + !options.pkClusteringOverride(), + "Primary-key vector index does not support pk-clustering-override."); + options.primaryKeyVectorIndexOptions(indexColumn); + + DataField vectorField = + schema.fields().stream() + .filter(field -> field.name().equals(indexColumn)) + .findFirst() + .orElse(null); + checkArgument( + vectorField != null && vectorField.type().getTypeRoot() == VECTOR, + "pk-vector.index.columns entry '%s' must reference a VECTOR column.", + indexColumn); + checkArgument( + ((VectorType) vectorField.type()).getElementType().getTypeRoot() + == DataTypeRoot.FLOAT, + "pk-vector.index.columns entry '%s' must use FLOAT elements.", + indexColumn); + checkArgument( + Arrays.asList("l2", "cosine", "inner_product") + .contains(options.primaryKeyVectorDistanceMetric(indexColumn)), + "fields.%s.pk-vector.distance.metric must be one of l2, cosine, inner_product, but is %s.", + indexColumn, + options.primaryKeyVectorDistanceMetric(indexColumn)); + } + private static void validateSequenceField(TableSchema schema, CoreOptions options) { List sequenceField = options.sequenceField(); if (!sequenceField.isEmpty()) { diff --git a/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java b/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java index 7e4fe92c8d32..00d97d54351e 100644 --- a/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/index/IndexFileMetaSerializerTest.java @@ -22,12 +22,35 @@ import org.apache.paimon.utils.ObjectSerializer; import org.apache.paimon.utils.ObjectSerializerTestBase; +import org.junit.jupiter.api.Test; + import java.util.LinkedHashMap; import java.util.Random; +import static org.assertj.core.api.Assertions.assertThat; + /** Test for {@link org.apache.paimon.index.IndexFileMetaSerializer}. */ public class IndexFileMetaSerializerTest extends ObjectSerializerTestBase { + @Test + void testGlobalIndexSourceMetaRoundTrip() { + IndexFileMetaSerializer serializer = new IndexFileMetaSerializer(); + IndexFileMeta indexFile = + new IndexFileMeta( + "ivf-pq", + "index-file", + 100, + 10, + new GlobalIndexMeta(0, 9, 7, null, new byte[] {3, 4}, new byte[] {1, 2}), + null); + + GlobalIndexMeta restored = + serializer.fromRow(serializer.toRow(indexFile)).globalIndexMeta(); + + assertThat(restored.sourceMeta()).containsExactly(1, 2); + assertThat(restored.indexMeta()).containsExactly(3, 4); + } + @Override protected ObjectSerializer serializer() { return new IndexFileMetaSerializer(); diff --git a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java new file mode 100644 index 000000000000..1a30e207f4bd --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorAnnSegmentFileTest.java @@ -0,0 +1,237 @@ +/* + * 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.pkvector; + +import org.apache.paimon.deletionvectors.BitmapDeletionVector; +import org.apache.paimon.fs.Path; +import org.apache.paimon.fs.local.LocalFileIO; +import org.apache.paimon.index.IndexFileMeta; +import org.apache.paimon.index.IndexPathFactory; +import org.apache.paimon.io.DataFileMeta; +import org.apache.paimon.manifest.FileSource; +import org.apache.paimon.options.Options; +import org.apache.paimon.stats.SimpleStats; +import org.apache.paimon.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.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests ANN construction from generic vector readers. */ +class PkVectorAnnSegmentFileTest { + + @TempDir java.nio.file.Path tempPath; + + @Test + void testBuildSkipsNullAndExcludedPhysicalRows() throws Exception { + LocalFileIO fileIO = LocalFileIO.create(); + IndexFileMeta segment = + annFile(fileIO) + .build( + Collections.singletonList( + new PkVectorAnnSegmentFile.Source( + dataFile("data-1", 3), + new ArrayReader( + new float[][] {{0, 0}, null, {2, 0}}), + position -> position == 0)), + vectorField(), + indexOptions(), + "l2", + "test-vector-ann"); + + assertThat(segment.indexType()).isEqualTo("test-vector-ann"); + assertThat(segment.rowCount()).isEqualTo(1); + PkVectorSourceMeta sourceMeta = PkVectorSourceMeta.fromIndexFile(segment); + assertThat(sourceMeta.sourceFiles()) + .extracting(PkVectorSourceFile::fileName) + .containsExactly("data-1"); + } + + @Test + void testBuildsAndSearchesMultiSourceSegment() throws Exception { + LocalFileIO fileIO = LocalFileIO.create(); + PkVectorAnnSegmentFile annFile = annFile(fileIO); + IndexFileMeta segment = + annFile.build( + Arrays.asList( + new PkVectorAnnSegmentFile.Source( + dataFile("data-1", 2), + new ArrayReader(new float[][] {{5, 0}, {10, 0}})), + new PkVectorAnnSegmentFile.Source( + dataFile("data-2", 2), + new ArrayReader(new float[][] {{0, 0}, {2, 0}}))), + vectorField(), + indexOptions(), + "l2", + "test-vector-ann"); + assertThat(segment.globalIndexMeta().rowRangeStart()).isZero(); + assertThat(segment.globalIndexMeta().rowRangeEnd()).isEqualTo(3); + PkVectorSourceMeta sourceMeta = PkVectorSourceMeta.fromIndexFile(segment); + BitmapDeletionVector data2Deletes = new BitmapDeletionVector(); + data2Deletes.delete(0); + Map deletionVectors = + new HashMap<>(); + deletionVectors.put("data-2", data2Deletes); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + List candidates; + try { + candidates = + new PkVectorAnnSegmentSearcher( + fileIO, annFile, vectorField(), indexOptions(), "l2", executor) + .search( + segment, + sourceMeta, + new float[] {0, 0}, + 3, + deletionVectors, + Collections.emptyMap()); + } finally { + executor.shutdownNow(); + } + + assertThat(candidates) + .extracting( + PkVectorAnnSegmentSearcher.Candidate::dataFileName, + PkVectorAnnSegmentSearcher.Candidate::rowPosition) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("data-2", 1L), + org.assertj.core.groups.Tuple.tuple("data-1", 0L), + org.assertj.core.groups.Tuple.tuple("data-1", 1L)); + } + + @Test + void testRejectsSourcesWithoutRows() { + LocalFileIO fileIO = LocalFileIO.create(); + + assertThatThrownBy( + () -> + annFile(fileIO) + .build( + Collections.singletonList( + new PkVectorAnnSegmentFile.Source( + dataFile("empty", 0), + new ArrayReader(new float[0][]))), + vectorField(), + indexOptions(), + "l2", + "test-vector-ann")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one source row"); + } + + private PkVectorAnnSegmentFile annFile(LocalFileIO fileIO) { + return new PkVectorAnnSegmentFile(fileIO, pathFactory()); + } + + private static DataField vectorField() { + return new DataField(7, "embedding", DataTypes.VECTOR(2, DataTypes.FLOAT())); + } + + private static Options indexOptions() { + Options options = new Options(); + options.setString("test.vector.dimension", "2"); + options.setString("test.vector.metric", "l2"); + return options; + } + + private static DataFileMeta dataFile(String fileName, long rowCount) { + return DataFileMeta.forAppend( + fileName, + 100, + rowCount, + SimpleStats.EMPTY_STATS, + 0, + 0, + 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 ArrayReader implements PkVectorReader { + + private final float[][] vectors; + private int position; + + private ArrayReader(float[][] vectors) { + this.vectors = vectors; + } + + @Override + public int dimension() { + return 2; + } + + @Override + public long rowCount() { + return vectors.length; + } + + @Override + public boolean readNextVector(float[] reuse) { + float[] vector = vectors[position++]; + if (vector == null) { + return false; + } + System.arraycopy(vector, 0, reuse, 0, reuse.length); + return true; + } + + @Override + public void close() throws IOException {} + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorSourceMetaTest.java b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorSourceMetaTest.java new file mode 100644 index 000000000000..9d8e96726752 --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PkVectorSourceMetaTest.java @@ -0,0 +1,56 @@ +/* + * 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.pkvector; + +import org.apache.paimon.io.DataOutputSerializer; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for {@link PkVectorSourceMeta}. */ +class PkVectorSourceMetaTest { + + @Test + void testRoundTrip() { + PkVectorSourceMeta metadata = + new PkVectorSourceMeta( + Arrays.asList( + new PkVectorSourceFile("data-1", 10), + new PkVectorSourceFile("data-2", 20))); + + PkVectorSourceMeta restored = PkVectorSourceMeta.deserialize(metadata.serialize()); + + assertThat(restored.sourceFiles()).isEqualTo(metadata.sourceFiles()); + } + + @Test + void testRejectsTruncatedSourceMetadata() throws Exception { + DataOutputSerializer output = new DataOutputSerializer(128); + output.writeInt(1); + output.writeInt(1); + output.writeUTF("data-1"); + + assertThatThrownBy(() -> PkVectorSourceMeta.deserialize(output.getCopyOfBuffer())) + .hasMessageContaining("Failed to deserialize vector source metadata"); + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorIndexOptionsTest.java b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorIndexOptionsTest.java new file mode 100644 index 000000000000..449d640aa3f6 --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/index/pkvector/PrimaryKeyVectorIndexOptionsTest.java @@ -0,0 +1,157 @@ +/* + * 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.pkvector; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.options.Options; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for primary-key vector index options in {@link CoreOptions}. */ +class PrimaryKeyVectorIndexOptionsTest { + + @Test + void testPluralFieldRegistryEnablesIndex() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + + assertThat(new CoreOptions(options).primaryKeyVectorIndexEnabled()).isTrue(); + } + + @Test + void testResolvesSingleIndexColumn() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + + assertThat(new CoreOptions(options).primaryKeyVectorIndexColumn()).isEqualTo("embedding"); + } + + @Test + void testFieldRegistryIsTheOnlyEnableSwitch() { + Map options = new HashMap<>(); + options.put("pk-vector.index.column", "embedding"); + options.put("pk-vector.index.type", "ivf-pq"); + + assertThat(new CoreOptions(options).primaryKeyVectorIndexEnabled()).isFalse(); + } + + @Test + void testIndexTypeMustBeFieldScoped() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("pk-vector.index.type", "ivf-pq"); + + assertThat(new CoreOptions(options).primaryKeyVectorIndexType("embedding")).isNull(); + } + + @Test + void testIndexOptionsMustBeFieldScoped() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + options.put("pk-vector.index.options", "{\"nlist\":64}"); + + assertThat( + new CoreOptions(options) + .primaryKeyVectorIndexOptions("embedding") + .get("ivf-pq.nlist")) + .isNull(); + } + + @Test + void testDistanceMetricMustBeFieldScoped() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("pk-vector.distance.metric", "l2"); + + assertThat(new CoreOptions(options).primaryKeyVectorDistanceMetric("embedding")) + .isEqualTo("inner_product"); + } + + @Test + void testFieldScopedDistanceMetricOverridesTableDefault() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("pk-vector.distance.metric", "l2"); + options.put("fields.embedding.pk-vector.distance.metric", "cosine"); + + assertThat(new CoreOptions(options).primaryKeyVectorDistanceMetric("embedding")) + .isEqualTo("cosine"); + } + + @Test + void testFieldScopedJsonOptionsOverrideTableDefault() { + Map options = new HashMap<>(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + options.put("pk-vector.index.options", "{\"nlist\":64}"); + options.put("fields.embedding.pk-vector.index.options", "{\"nlist\":128}"); + + Options resolved = new CoreOptions(options).primaryKeyVectorIndexOptions("embedding"); + + assertThat(resolved.get("ivf-pq.nlist")).isEqualTo("128"); + } + + @Test + void testResolvesShortAndQualifiedAlgorithmOptions() { + CoreOptions coreOptions = + coreOptions( + "{\"nlist\":64,\"ivf-pq.pq.m\":\"8\"," + "\"fields.embedding.hnsw.m\":16}"); + + Options resolved = coreOptions.primaryKeyVectorIndexOptions("embedding"); + + assertThat(resolved.get("ivf-pq.nlist")).isEqualTo("64"); + assertThat(resolved.get("ivf-pq.pq.m")).isEqualTo("8"); + assertThat(resolved.get("fields.embedding.hnsw.m")).isEqualTo("16"); + assertThat(resolved.get("ivf-pq.metric")).isEqualTo("l2"); + } + + @Test + void testRejectsNonObjectOptions() { + assertThatThrownBy(() -> coreOptions("[1,2]").primaryKeyVectorIndexOptions("embedding")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pk-vector.index.options") + .hasMessageContaining("JSON object"); + } + + private static CoreOptions coreOptions(String indexOptions) { + return coreOptions(indexOptions, null, null); + } + + private static CoreOptions coreOptions( + String indexOptions, String additionalKey, String additionalValue) { + Map options = new HashMap<>(); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("fields.embedding.pk-vector.distance.metric", "l2"); + if (indexOptions != null) { + options.put("fields.embedding.pk-vector.index.options", indexOptions); + } + if (additionalKey != null) { + options.put(additionalKey, additionalValue); + } + return new CoreOptions(options); + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java index 0429b8dae3e0..0678ed787aaa 100644 --- a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestEntrySerializerTest.java @@ -18,17 +18,55 @@ package org.apache.paimon.manifest; +import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.data.GenericRow; +import org.apache.paimon.data.InternalRow; +import org.apache.paimon.index.GlobalIndexMeta; +import org.apache.paimon.index.IndexFileMeta; import org.apache.paimon.utils.ObjectSerializer; import org.apache.paimon.utils.ObjectSerializerTestBase; +import org.junit.jupiter.api.Test; + import java.util.Random; import static org.apache.paimon.index.IndexFileMetaSerializerTest.randomIndexFile; import static org.apache.paimon.io.DataFileTestUtils.row; +import static org.assertj.core.api.Assertions.assertThat; /** Test for {@link IndexManifestEntrySerializer}. */ public class IndexManifestEntrySerializerTest extends ObjectSerializerTestBase { + @Test + void testReadsGlobalIndexWithoutSourceMeta() { + IndexManifestEntrySerializer serializer = new IndexManifestEntrySerializer(); + IndexManifestEntry entry = + new IndexManifestEntry( + FileKind.ADD, + BinaryRow.EMPTY_ROW, + 0, + new IndexFileMeta( + "btree", + "index-file", + 100, + 10, + new GlobalIndexMeta(0, 9, 7, null, new byte[] {1}), + null)); + GenericRow serialized = (GenericRow) serializer.convertTo(entry); + serialized.setField(9, GenericRow.of(0L, 9L, 7, null, new byte[] {1})); + + InternalRow globalIndexRow = serialized.getRow(9, 5); + assertThat(globalIndexRow.getFieldCount()).isEqualTo(5); + GlobalIndexMeta restored = + serializer + .convertFrom(serializer.getVersion(), serialized) + .indexFile() + .globalIndexMeta(); + + assertThat(restored.indexMeta()).containsExactly(1); + assertThat(restored.sourceMeta()).isNull(); + } + @Override protected ObjectSerializer serializer() { return new IndexManifestEntrySerializer(); diff --git a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java new file mode 100644 index 000000000000..0daa7bc5c473 --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java @@ -0,0 +1,236 @@ +/* + * 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.schema; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataTypes; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.apache.paimon.schema.SchemaValidation.validateTableSchema; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for primary-key vector index option validation. */ +class PrimaryKeyVectorIndexValidationTest { + + @Test + void testValidPluralPrimaryKeyVectorIndexConfiguration() { + Map options = new HashMap<>(); + options.put(CoreOptions.BUCKET.key(), "1"); + options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), " embedding "); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + + assertThatCode(() -> validateTableSchema(schema(options))).doesNotThrowAnyException(); + } + + @Test + void testRejectsMultiplePrimaryKeyVectorIndexColumnsForFirstRelease() { + Map options = new HashMap<>(); + options.put(CoreOptions.BUCKET.key(), "1"); + options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding,other_embedding"); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("exactly one column") + .hasMessageContaining("pk-vector.index.columns"); + } + + @Test + void testRejectsDuplicatePrimaryKeyVectorIndexColumns() { + Map options = new HashMap<>(); + options.put(CoreOptions.BUCKET.key(), "1"); + options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding,embedding"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("pk-vector.index.columns") + .hasMessageContaining("duplicate"); + } + + @Test + void testValidPrimaryKeyVectorIndex() { + assertThatCode(() -> validateTableSchema(schema(enabledOptions()))) + .doesNotThrowAnyException(); + } + + @Test + void testRequiresFieldScopedIndexType() { + Map options = enabledOptions(); + options.remove("fields.embedding.pk-vector.index.type"); + options.put("pk-vector.index.type", "ivf-pq"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("fields.embedding.pk-vector.index.type"); + } + + @Test + void testRequiresPrimaryKeyTable() { + Map options = enabledOptions(); + options.put(CoreOptions.BUCKET_KEY.key(), "id"); + assertThatThrownBy( + () -> + validateTableSchema( + new TableSchema( + 0, + fields(), + 0, + Collections.emptyList(), + Collections.emptyList(), + options, + ""))) + .hasMessageContaining("requires a primary-key table"); + } + + @Test + void testRequiresDeletionVectors() { + Map options = enabledOptions(); + options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "false"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("requires deletion-vectors.enabled = true"); + } + + @Test + void testSupportsPartialUpdateMergeEngine() { + Map options = enabledOptions(); + options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update"); + + assertThatCode(() -> validateTableSchema(schema(options))).doesNotThrowAnyException(); + } + + @Test + void testPartialUpdateRejectsDeletionVectorMergeOnRead() { + Map options = enabledOptions(); + options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update"); + options.put(CoreOptions.DELETION_VECTORS_MERGE_ON_READ.key(), "true"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining( + "partial-update requires deletion-vectors.merge-on-read = false"); + } + + @Test + void testDeduplicateRejectsDeletionVectorMergeOnRead() { + Map options = enabledOptions(); + options.put(CoreOptions.DELETION_VECTORS_MERGE_ON_READ.key(), "true"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("requires deletion-vectors.merge-on-read = false"); + } + + @Test + void testRequiresFixedBucket() { + Map options = enabledOptions(); + options.put(CoreOptions.BUCKET.key(), "-1"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("requires fixed bucket mode"); + } + + @Test + void testRejectsPkClusteringOverride() { + Map options = enabledOptions(); + options.put(CoreOptions.PK_CLUSTERING_OVERRIDE.key(), "true"); + options.put(CoreOptions.CLUSTERING_COLUMNS.key(), "payload"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("does not support pk-clustering-override"); + } + + @Test + void testRequiresVectorColumn() { + Map options = enabledOptions(); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "payload"); + options.put("fields.payload.pk-vector.index.type", "ivf-pq"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("must reference a VECTOR column"); + } + + @Test + void testIndexNameIsNotRequired() { + Map options = enabledOptions(); + assertThatCode(() -> validateTableSchema(schema(options))).doesNotThrowAnyException(); + } + + @Test + void testRequiresFloatVectorElements() { + TableSchema schema = + new TableSchema( + 0, + Arrays.asList( + new DataField(0, "id", DataTypes.INT().notNull()), + new DataField( + 1, "embedding", DataTypes.VECTOR(8, DataTypes.DOUBLE()))), + 1, + Collections.emptyList(), + Collections.singletonList("id"), + enabledOptions(), + ""); + + assertThatThrownBy(() -> validateTableSchema(schema)) + .hasMessageContaining("must use FLOAT elements"); + } + + @Test + void testRejectsUnsupportedDistanceMetric() { + Map options = enabledOptions(); + options.put("fields.embedding.pk-vector.distance.metric", "manhattan"); + + assertThatThrownBy(() -> validateTableSchema(schema(options))) + .hasMessageContaining("pk-vector.distance.metric") + .hasMessageContaining("l2, cosine, inner_product"); + } + + private static Map enabledOptions() { + Map options = new HashMap<>(); + options.put(CoreOptions.BUCKET.key(), "1"); + options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + return options; + } + + private static TableSchema schema(Map options) { + return new TableSchema( + 0, + fields(), + 0, + Collections.emptyList(), + Collections.singletonList("id"), + options, + ""); + } + + private static java.util.List fields() { + return Arrays.asList( + new DataField(0, "id", DataTypes.INT().notNull()), + new DataField(1, "embedding", DataTypes.VECTOR(8, DataTypes.FLOAT())), + new DataField(2, "payload", DataTypes.STRING())); + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java index 59a4f90f6105..b982ff1df1d3 100644 --- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java @@ -170,6 +170,35 @@ public void testUpdateOptions() throws Exception { assertThat(latest.get().options()).containsEntry("new_k", "new_v"); } + @Test + public void testRejectRenamePrimaryKeyVectorIndexColumn() throws Exception { + Map options = new HashMap<>(); + options.put(CoreOptions.BUCKET.key(), "1"); + options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true"); + options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding"); + options.put("fields.embedding.pk-vector.index.type", "ivf-pq"); + Schema schema = + new Schema( + Arrays.asList( + new DataField(0, "id", DataTypes.INT().notNull()), + new DataField( + 1, "embedding", DataTypes.VECTOR(8, DataTypes.FLOAT()))), + Collections.emptyList(), + Collections.singletonList("id"), + options, + ""); + SchemaManager manager = new SchemaManager(LocalFileIO.create(), path); + manager.createTable(schema); + + assertThatThrownBy( + () -> + manager.commitChanges( + SchemaChange.renameColumn( + new String[] {"embedding"}, "renamed_embedding"))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Cannot rename primary-key vector index column: [embedding]"); + } + @Test public void testResetSequenceGroupForAggregateFunction() throws Exception { Map options = new HashMap<>();