diff --git a/docs/docs/flink/procedures.md b/docs/docs/flink/procedures.md
index eb9181955484..927bfe231b01 100644
--- a/docs/docs/flink/procedures.md
+++ b/docs/docs/flink/procedures.md
@@ -1177,8 +1177,10 @@ All available procedures are listed below.
vector_column => 'columnName',
query_vector => 'v1,v2,...',
top_k => topK,
- projection => 'col1,col2',
- options => 'key1=value1;key2=value2')
+ projection => 'col1,col2,__paimon_search_score',
+ options => 'key1=value1;key2=value2',
+ `where` => 'predicate',
+ partitions => 'pt1=v1,pt2=v2;pt1=v3,pt2=v4')
To perform vector similarity search on a table with a global vector index. Returns JSON-serialized rows. Arguments:
@@ -1188,6 +1190,8 @@ All available procedures are listed below.
top_k(required): the number of nearest neighbors to return.
projection(optional): comma-separated column names to include in the result. If omitted, all columns are returned.
options(optional): additional dynamic options of the table.
+ where(optional): a SQL predicate applied before Top-K.
+ partitions(optional): semicolon-separated specs for a partitioned table.
|
CALL sys.vector_search(
@@ -1200,7 +1204,9 @@ All available procedures are listed below.
vector_column => 'embedding',
query_vector => '1.0,2.0,3.0',
top_k => 5,
- projection => 'id,name')
+ projection => 'id,name,__paimon_search_score',
+ options => 'vector-search.distribute.enabled=true;ivf.nprobe=32',
+ `where` => 'status = ''active''')
|
diff --git a/docs/docs/multimodal-table/global-index/vector.mdx b/docs/docs/multimodal-table/global-index/vector.mdx
index 6399d3c2589d..d29d0767692c 100644
--- a/docs/docs/multimodal-table/global-index/vector.mdx
+++ b/docs/docs/multimodal-table/global-index/vector.mdx
@@ -335,7 +335,9 @@ CALL sys.vector_search(
vector_column => 'embedding',
query_vector => '1.0,2.0,3.0',
top_k => 5,
- projection => 'id,name'
+ projection => 'id,name,__paimon_search_score',
+ options => 'ivf.nprobe=32',
+ `where` => 'id > 100'
);
```
diff --git a/docs/docs/primary-key-table/global-index.mdx b/docs/docs/primary-key-table/global-index.mdx
index 3098d71c52e4..d860d002688f 100644
--- a/docs/docs/primary-key-table/global-index.mdx
+++ b/docs/docs/primary-key-table/global-index.mdx
@@ -347,8 +347,9 @@ CALL sys.vector_search(
vector_column => 'embedding',
query_vector => '0.1,0.2,0.3',
top_k => 10,
- projection => 'id,status',
- options => 'ivf.nprobe=32'
+ projection => 'id,status,__paimon_search_score',
+ options => 'ivf.nprobe=32',
+ `where` => 'status = ''active'''
);
```
diff --git a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
index 8a75b8f9d46c..61759b3b4a7b 100644
--- a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
+++ b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/VectorSearchProcedure.java
@@ -18,17 +18,33 @@
package org.apache.paimon.flink.procedure;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.flink.predicate.SimpleSqlPredicateConvertor;
+import org.apache.paimon.flink.vectorsearch.FlinkVectorSearchBuilderImpl;
import org.apache.paimon.format.json.JsonFormatWriter;
import org.apache.paimon.format.json.JsonOptions;
import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.globalindex.GlobalIndexResult;
import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.Table;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.table.source.VectorScan;
+import org.apache.paimon.table.source.VectorSearchBuilder;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.InternalRowUtils;
+import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.StringUtils;
import org.apache.flink.table.annotation.ArgumentHint;
@@ -36,40 +52,39 @@
import org.apache.flink.table.annotation.ProcedureHint;
import org.apache.flink.table.procedure.ProcedureContext;
+import javax.annotation.Nullable;
+
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
+import java.util.Optional;
+import java.util.Set;
-/**
- * Vector search procedure. This procedure takes one vector and searches for topK nearest vectors.
- * Usage:
- *
- *
- * CALL sys.vector_search(
- * `table` => 'tableId',
- * vector_column => 'v',
- * query_vector => '1.0,2.0,3.0',
- * top_k => 5
- * )
- *
- * -- with projection and options
- * CALL sys.vector_search(
- * `table` => 'tableId',
- * vector_column => 'v',
- * query_vector => '1.0,2.0,3.0',
- * top_k => 5,
- * projection => 'id,name',
- * options => 'k1=v1;k2=v2'
- * )
- *
- */
+import static org.apache.paimon.CoreOptions.SCAN_MODE;
+import static org.apache.paimon.CoreOptions.SCAN_SNAPSHOT_ID;
+import static org.apache.paimon.CoreOptions.SCAN_TAG_NAME;
+import static org.apache.paimon.CoreOptions.SCAN_TIMESTAMP;
+import static org.apache.paimon.CoreOptions.SCAN_TIMESTAMP_MILLIS;
+import static org.apache.paimon.CoreOptions.SCAN_VERSION;
+import static org.apache.paimon.CoreOptions.SCAN_WATERMARK;
+import static org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates;
+import static org.apache.paimon.predicate.PredicateVisitor.collectFieldNames;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Procedure for local or distributed vector search with optional filtering and scores. */
public class VectorSearchProcedure extends ProcedureBase {
public static final String IDENTIFIER = "vector_search";
+ public static final String SEARCH_SCORE = "__paimon_search_score";
+
+ private static final int MAX_TOP_K = 10_000;
+ private static final DataField SEARCH_SCORE_FIELD =
+ new DataField(Integer.MAX_VALUE, SEARCH_SCORE, DataTypes.FLOAT());
@ProcedureHint(
argument = {
@@ -81,7 +96,12 @@ public class VectorSearchProcedure extends ProcedureBase {
name = "projection",
type = @DataTypeHint("STRING"),
isOptional = true),
- @ArgumentHint(name = "options", type = @DataTypeHint("STRING"), isOptional = true)
+ @ArgumentHint(name = "options", type = @DataTypeHint("STRING"), isOptional = true),
+ @ArgumentHint(name = "where", type = @DataTypeHint("STRING"), isOptional = true),
+ @ArgumentHint(
+ name = "partitions",
+ type = @DataTypeHint("STRING"),
+ isOptional = true)
})
public String[] call(
ProcedureContext procedureContext,
@@ -90,89 +110,418 @@ public String[] call(
String queryVectorStr,
Integer topK,
String projection,
- String options)
+ String options,
+ String where,
+ String partitions)
throws Exception {
- Table table = table(tableId);
+ validateSearch(vectorColumn, queryVectorStr, topK);
+ Table table = table(tableId);
Map optionsMap = optionalConfigMap(options);
+ String queryAuthOption = CoreOptions.QUERY_AUTH_ENABLED.key();
+ checkArgument(
+ !optionsMap.containsKey(queryAuthOption),
+ "Option '%s' is not allowed",
+ queryAuthOption);
if (!optionsMap.isEmpty()) {
table = table.copy(optionsMap);
}
+ checkArgument(
+ table instanceof FileStoreTable, "Vector search requires a file store table.");
+
+ FileStoreTable fileStoreTable = (FileStoreTable) table;
+ checkArgument(
+ !fileStoreTable.coreOptions().queryAuthEnabled(),
+ "Vector search does not support tables with query auth enabled.");
+ RowType tableType = fileStoreTable.rowType();
+ checkArgument(
+ tableType.containsField(vectorColumn),
+ "Vector column '%s' does not exist in table '%s'.",
+ vectorColumn,
+ tableId);
+ checkArgument(
+ tableType.notContainsField(SEARCH_SCORE),
+ "Table column '%s' conflicts with vector-search metadata.",
+ SEARCH_SCORE);
float[] queryVector = parseVector(queryVectorStr);
+ Predicate filter = parseFilter(where, tableType);
+ PartitionPredicate partitionFilter = parsePartitions(partitions, fileStoreTable);
+ Projection parsedProjection = Projection.parse(projection, tableType, filter);
+ FilterParts filterParts = FilterParts.from(filter, fileStoreTable);
+ validatePrimaryKeyFilter(fileStoreTable, vectorColumn, filterParts);
+
+ fileStoreTable = resolveAndPinSnapshot(fileStoreTable);
+ if (fileStoreTable == null) {
+ return new String[0];
+ }
- GlobalIndexResult result =
- table.newVectorSearchBuilder()
+ VectorSearchBuilder builder =
+ newVectorSearchBuilder(procedureContext, fileStoreTable)
.withVector(queryVector)
.withVectorColumn(vectorColumn)
.withLimit(topK)
- .withOptions(optionsMap)
- .executeLocal();
+ .withOptions(optionsMap);
+ if (filter != null) {
+ builder.withFilter(filter);
+ }
+ if (partitionFilter != null) {
+ builder.withPartitionFilter(partitionFilter);
+ }
+
+ VectorScan.Plan vectorPlan = builder.newVectorScan().scan();
+ GlobalIndexResult result = builder.newVectorRead().read(vectorPlan);
+
+ ReadBuilder readBuilder = fileStoreTable.newReadBuilder();
+ if (filter != null) {
+ readBuilder.withFilter(filter);
+ }
+ PartitionPredicate effectivePartitionFilter =
+ filterParts.mergePartitionFilter(partitionFilter);
+ if (effectivePartitionFilter != null) {
+ readBuilder.withPartitionFilter(effectivePartitionFilter);
+ }
+ if (parsedProjection.readProjection != null) {
+ readBuilder.withProjection(parsedProjection.readProjection);
+ }
+ TableScan.Plan readPlan = readBuilder.newScan().withGlobalIndexResult(result).plan();
+ return readRows(readBuilder, readPlan, parsedProjection);
+ }
+
+ private static VectorSearchBuilder newVectorSearchBuilder(
+ ProcedureContext context, FileStoreTable table) {
+ if (!table.coreOptions().vectorSearchDistributeEnabled()) {
+ return table.newVectorSearchBuilder();
+ }
+ return new FlinkVectorSearchBuilderImpl(table, context.getExecutionEnvironment());
+ }
- RowType tableRowType = table.rowType();
- int[] projectionIndices = parseProjection(projection, tableRowType);
+ @Nullable
+ static FileStoreTable resolveAndPinSnapshot(FileStoreTable table) {
+ Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest(table);
+ if (snapshot == null) {
+ return null;
+ }
+
+ Map snapshotOptions = new LinkedHashMap<>();
+ snapshotOptions.put(SCAN_VERSION.key(), null);
+ snapshotOptions.put(SCAN_TAG_NAME.key(), null);
+ snapshotOptions.put(SCAN_WATERMARK.key(), null);
+ snapshotOptions.put(SCAN_TIMESTAMP.key(), null);
+ snapshotOptions.put(SCAN_TIMESTAMP_MILLIS.key(), null);
+ snapshotOptions.put(SCAN_MODE.key(), CoreOptions.StartupMode.FROM_SNAPSHOT.toString());
+ snapshotOptions.put(SCAN_SNAPSHOT_ID.key(), String.valueOf(snapshot.id()));
+ return table.copyWithoutTimeTravel(snapshotOptions);
+ }
+
+ private static void validateSearch(
+ String vectorColumn, String queryVector, @Nullable Integer topK) {
+ checkArgument(
+ !StringUtils.isNullOrWhitespaceOnly(vectorColumn),
+ "vector_column must not be blank");
+ checkArgument(
+ !StringUtils.isNullOrWhitespaceOnly(queryVector), "query_vector must not be blank");
+ checkArgument(topK != null && topK > 0, "top_k must be positive");
+ checkArgument(topK <= MAX_TOP_K, "top_k must not exceed %s", MAX_TOP_K);
+ }
+
+ private static float[] parseVector(String vectorStr) {
+ String[] parts = vectorStr.split(",", -1);
+ float[] vector = new float[parts.length];
+ for (int i = 0; i < parts.length; i++) {
+ String part = parts[i].trim();
+ checkArgument(!part.isEmpty(), "query_vector contains an empty value");
+ try {
+ vector[i] = Float.parseFloat(part);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ String.format("Invalid query vector value: '%s'", part), e);
+ }
+ checkArgument(
+ !Float.isNaN(vector[i]) && !Float.isInfinite(vector[i]),
+ "query_vector values must be finite");
+ }
+ return vector;
+ }
- ReadBuilder readBuilder = table.newReadBuilder();
- if (projectionIndices != null) {
- readBuilder.withProjection(projectionIndices);
+ @Nullable
+ private static Predicate parseFilter(@Nullable String where, RowType tableType)
+ throws Exception {
+ if (StringUtils.isNullOrWhitespaceOnly(where)) {
+ return null;
}
+ return new SimpleSqlPredicateConvertor(tableType).convertSqlToPredicate(where);
+ }
- TableScan.Plan plan = readBuilder.newScan().withGlobalIndexResult(result).plan();
+ @Nullable
+ private static PartitionPredicate parsePartitions(
+ @Nullable String partitions, FileStoreTable table) {
+ if (StringUtils.isNullOrWhitespaceOnly(partitions)) {
+ return null;
+ }
- RowType readType =
- projectionIndices != null ? tableRowType.project(projectionIndices) : tableRowType;
+ List partitionKeys = table.partitionKeys();
+ checkArgument(
+ !partitionKeys.isEmpty(),
+ "partitions can only be used with a partitioned table '%s'.",
+ table.name());
+ List