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 a81d129702 [core][spark] Support multi-vector search with rankers
(#8258)
a81d129702 is described below
commit a81d12970293ea2386bb8c6c1b8a177062fe7467
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jun 17 20:07:30 2026 +0800
[core][spark] Support multi-vector search with rankers (#8258)
Add first-phase multi-vector search support across Spark SQL and the
Java builder APIs. The implementation fans out to existing per-column
global vector indexes, ranks scored row ids with a configured ranker,
and then continues with the normal Paimon scan, so it does not require
any index format changes.
Spark SQL example:
```sql
SELECT id, __paimon_vector_search_score
FROM multi_vector_search(
'T',
array(
named_struct(
'vector_column', 'title_vec',
'query_vector', array(1.0f, 0.0f),
'limit', 20,
'weight', 2.0f,
'options', map('ivf.nprobe', '32')),
named_struct(
'vector_column', 'body_vec',
'query_vector', array(0.0f, 1.0f),
'limit', 20,
'weight', 1.0f,
'options', map('ivf.nprobe', '16'))),
10,
'rrf')
```
Java builder example:
```java
MultiVectorSearchBuilder searchBuilder =
table.newMultiVectorSearchBuilder()
.addRoute(
"title_vec",
new float[] {1.0f, 0.0f},
20,
2.0f,
java.util.Collections.singletonMap("ivf.nprobe",
"32"))
.addRoute(
"body_vec",
new float[] {0.0f, 1.0f},
20,
1.0f,
java.util.Collections.singletonMap("ivf.nprobe",
"16"))
.withLimit(10)
.withRrfRanker();
GlobalIndexResult result = searchBuilder.executeLocal();
ReadBuilder readBuilder = table.newReadBuilder();
TableScan.Plan plan =
readBuilder.newScan().withGlobalIndexResult(result).plan();
```
---
docs/docs/multimodal-table/global-index.mdx | 114 ++++++++++++
.../globalindex/MultiVectorSearchRanker.java | 192 +++++++++++++++++++++
.../apache/paimon/predicate/MultiVectorSearch.java | 70 ++++++++
.../paimon/predicate/MultiVectorSearchRoute.java | 159 +++++++++++++++++
.../org/apache/paimon/predicate/VectorSearch.java | 7 +-
.../globalindex/MultiVectorSearchRankerTest.java | 72 ++++++++
.../java/org/apache/paimon/table/FormatTable.java | 7 +
.../java/org/apache/paimon/table/InnerTable.java | 7 +
.../paimon/table/MultiVectorSearchTable.java | 103 +++++++++++
.../main/java/org/apache/paimon/table/Table.java | 7 +
.../table/source/MultiVectorSearchBuilder.java | 139 +++++++++++++++
.../table/source/MultiVectorSearchBuilderImpl.java | 160 +++++++++++++++++
.../table/source/VectorSearchBuilderTest.java | 118 +++++++++----
.../paimon/format/lance/LanceVectorSearchTest.java | 19 +-
.../scala/org/apache/paimon/spark/PaimonScan.scala | 3 +-
.../apache/paimon/spark/PaimonScanBuilder.scala | 17 +-
.../scala/org/apache/paimon/spark/PaimonScan.scala | 3 +-
.../read/SparkMultiVectorSearchBuilderImpl.java | 51 ++++++
.../org/apache/paimon/spark/PaimonBaseScan.scala | 32 +++-
.../scala/org/apache/paimon/spark/PaimonScan.scala | 3 +-
.../apache/paimon/spark/PaimonScanBuilder.scala | 17 +-
.../apache/paimon/spark/PaimonSparkTableBase.scala | 2 +-
.../plans/logical/PaimonTableValuedFunctions.scala | 169 +++++++++++++++++-
.../org/apache/paimon/spark/read/BaseScan.scala | 4 +-
.../plans/logical/VectorSearchQueryTest.scala | 99 ++++++++++-
.../paimon/spark/sql/MultiVectorSearchTest.scala | 85 +++++++++
26 files changed, 1578 insertions(+), 81 deletions(-)
diff --git a/docs/docs/multimodal-table/global-index.mdx
b/docs/docs/multimodal-table/global-index.mdx
index a7698f124a..7b9712a8f9 100644
--- a/docs/docs/multimodal-table/global-index.mdx
+++ b/docs/docs/multimodal-table/global-index.mdx
@@ -229,6 +229,120 @@ try (RecordReader<InternalRow> reader =
readBuilder.newRead().createReader(plan)
}
```
+For Java, use `Table.newVectorSearchBuilder()` to produce a global index
result, then pass
+the result to `TableScan.withGlobalIndexResult`.
+
+</TabItem>
+
+</Tabs>
+
+## Multi-Vector Search
+
+Multi-vector search queries multiple vector columns in one request and ranks
the scored results
+before reading table rows. This is useful when one table stores several
embeddings for the same
+record, such as title, body, image, or audio embeddings.
+
+Before running multi-vector search, create a vector global index for every
vector column used by
+the query:
+
+```sql
+CALL sys.create_global_index(
+ table => 'db.my_table',
+ index_column => 'title_embedding',
+ index_type => 'ivf-pq',
+ options => 'ivf-pq.distance.metric=cosine,ivf-pq.nlist=256,ivf-pq.pq.m=16'
+);
+
+CALL sys.create_global_index(
+ table => 'db.my_table',
+ index_column => 'body_embedding',
+ index_type => 'ivf-pq',
+ options => 'ivf-pq.distance.metric=cosine,ivf-pq.nlist=256,ivf-pq.pq.m=16'
+);
+```
+
+For Spark SQL, use the `multi_vector_search(table_name, routes, limit[,
ranker])`
+table-valued function. The third argument is the final number of ranked
results to return.
+The optional fourth argument selects the ranker. Supported rankers are `rrf`
and
+`weighted_score`; the default is `rrf`.
+
+The second argument is an array of route configs created by `named_struct`:
+
+| Field | Required | Default | Description |
+|---|---|---|---|
+| `vector_column` | Yes | N/A | Vector column to search. |
+| `query_vector` | Yes | N/A | Query vector for this route. |
+| `limit` | No | Final limit | Top K results to retrieve from this vector
column before ranking. |
+| `weight` | No | `1.0` | Weight for this route when ranking results. |
+| `options` | No | Empty map | Route-specific vector search options such as
`ivf.nprobe` and `hnsw.ef_search`. |
+
+When using an array of route configs, every `named_struct` should use the same
fields because
+Spark requires array elements to have the same struct type.
+
+<Tabs groupId="multi-vector-search">
+
+<TabItem value="spark-sql" label="Spark SQL">
+
+```sql
+SELECT id, __paimon_vector_search_score
+FROM multi_vector_search(
+ 'my_table',
+ array(
+ named_struct(
+ 'vector_column', 'title_embedding',
+ 'query_vector', array(1.0f, 0.0f, 0.0f),
+ 'limit', 50,
+ 'weight', 2.0f,
+ 'options', map('ivf.nprobe', '32')),
+ named_struct(
+ 'vector_column', 'body_embedding',
+ 'query_vector', array(0.0f, 1.0f, 0.0f),
+ 'limit', 50,
+ 'weight', 1.0f,
+ 'options', map('ivf.nprobe', '16'))),
+ 10,
+ 'rrf');
+```
+
+Spark SQL adds `__paimon_vector_search_score` to expose the ranked score.
+
+</TabItem>
+
+<TabItem value="java-api" label="Java API">
+
+```java
+Table table = catalog.getTable(identifier);
+
+MultiVectorSearchBuilder searchBuilder =
+ table.newMultiVectorSearchBuilder()
+ .addRoute(
+ "title_embedding",
+ new float[] {1.0f, 0.0f, 0.0f},
+ 50,
+ 2.0f,
+ java.util.Collections.singletonMap("ivf.nprobe", "32"))
+ .addRoute(
+ "body_embedding",
+ new float[] {0.0f, 1.0f, 0.0f},
+ 50,
+ 1.0f,
+ java.util.Collections.singletonMap("ivf.nprobe", "16"))
+ .withLimit(10)
+ .withRrfRanker();
+GlobalIndexResult result = searchBuilder.executeLocal();
+
+ReadBuilder readBuilder = table.newReadBuilder();
+TableScan.Plan plan =
readBuilder.newScan().withGlobalIndexResult(result).plan();
+try (RecordReader<InternalRow> reader =
readBuilder.newRead().createReader(plan)) {
+ reader.forEachRemaining(row -> {
+ System.out.println("id=" + row.getInt(0));
+ });
+}
+```
+
+For Java, use `Table.newMultiVectorSearchBuilder()` to configure routes, final
limit, and ranker
+directly. `VectorSearch` and `MultiVectorSearch` are internal pushdown
representations.
+
</TabItem>
</Tabs>
diff --git
a/paimon-common/src/main/java/org/apache/paimon/globalindex/MultiVectorSearchRanker.java
b/paimon-common/src/main/java/org/apache/paimon/globalindex/MultiVectorSearchRanker.java
new file mode 100644
index 0000000000..fc29adb1d3
--- /dev/null
+++
b/paimon-common/src/main/java/org/apache/paimon/globalindex/MultiVectorSearchRanker.java
@@ -0,0 +1,192 @@
+/*
+ * 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;
+
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Ranker utilities for multi-vector search results. */
+public class MultiVectorSearchRanker {
+
+ public static final String RRF_RANKER = "rrf";
+ public static final String WEIGHTED_SCORE_RANKER = "weighted_score";
+
+ private static final float RRF_K = 60.0f;
+
+ private MultiVectorSearchRanker() {}
+
+ public static ScoredGlobalIndexResult rank(
+ String ranker, List<ScoredGlobalIndexResult> results, float[]
weights, int limit) {
+ List<WeightedResult> weightedResults = new ArrayList<>(results.size());
+ for (int i = 0; i < results.size(); i++) {
+ weightedResults.add(new WeightedResult(results.get(i),
weightAt(weights, i)));
+ }
+ return rank(ranker, weightedResults, limit);
+ }
+
+ public static ScoredGlobalIndexResult rank(
+ String ranker, List<WeightedResult> results, int limit) {
+ if (WEIGHTED_SCORE_RANKER.equals(normalizeRanker(ranker))) {
+ return weightedScore(results, limit);
+ }
+ return rrf(results, limit);
+ }
+
+ public static String normalizeRanker(String ranker) {
+ if (ranker == null || ranker.trim().isEmpty()) {
+ return RRF_RANKER;
+ }
+ String normalized = ranker.trim().toLowerCase();
+ if (!RRF_RANKER.equals(normalized) &&
!WEIGHTED_SCORE_RANKER.equals(normalized)) {
+ throw new IllegalArgumentException("Unsupported multi-vector
ranker: " + ranker);
+ }
+ return normalized;
+ }
+
+ public static ScoredGlobalIndexResult rrf(
+ List<ScoredGlobalIndexResult> results, float[] weights, int limit)
{
+ List<WeightedResult> weightedResults = new ArrayList<>(results.size());
+ for (int i = 0; i < results.size(); i++) {
+ weightedResults.add(new WeightedResult(results.get(i),
weightAt(weights, i)));
+ }
+ return rrf(weightedResults, limit);
+ }
+
+ public static ScoredGlobalIndexResult rrf(List<WeightedResult> results,
int limit) {
+ Map<Long, Float> scores = new HashMap<>();
+ for (WeightedResult weightedResult : results) {
+ ScoredGlobalIndexResult result = weightedResult.result();
+ float weight = weightedResult.weight();
+ List<Long> ranked = rankedRowIds(result);
+ for (int rank = 0; rank < ranked.size(); rank++) {
+ Long rowId = ranked.get(rank);
+ float contribution = weight / (RRF_K + rank + 1.0f);
+ Float oldScore = scores.get(rowId);
+ scores.put(rowId, oldScore == null ? contribution : oldScore +
contribution);
+ }
+ }
+ return topK(scores, limit);
+ }
+
+ public static ScoredGlobalIndexResult weightedScore(
+ List<ScoredGlobalIndexResult> results, float[] weights, int limit)
{
+ List<WeightedResult> weightedResults = new ArrayList<>(results.size());
+ for (int i = 0; i < results.size(); i++) {
+ weightedResults.add(new WeightedResult(results.get(i),
weightAt(weights, i)));
+ }
+ return weightedScore(weightedResults, limit);
+ }
+
+ public static ScoredGlobalIndexResult weightedScore(List<WeightedResult>
results, int limit) {
+ Map<Long, Float> scores = new HashMap<>();
+ for (WeightedResult weightedResult : results) {
+ ScoredGlobalIndexResult result = weightedResult.result();
+ float weight = weightedResult.weight();
+ ScoreGetter scoreGetter = result.scoreGetter();
+ for (long rowId : result.results()) {
+ float contribution = weight * scoreGetter.score(rowId);
+ Float oldScore = scores.get(rowId);
+ scores.put(rowId, oldScore == null ? contribution : oldScore +
contribution);
+ }
+ }
+ return topK(scores, limit);
+ }
+
+ private static List<Long> rankedRowIds(ScoredGlobalIndexResult result) {
+ List<Long> rowIds = new ArrayList<>();
+ for (long rowId : result.results()) {
+ rowIds.add(rowId);
+ }
+ final ScoreGetter scoreGetter = result.scoreGetter();
+ Collections.sort(
+ rowIds,
+ new Comparator<Long>() {
+ @Override
+ public int compare(Long left, Long right) {
+ return Float.compare(scoreGetter.score(right),
scoreGetter.score(left));
+ }
+ });
+ return rowIds;
+ }
+
+ private static ScoredGlobalIndexResult topK(Map<Long, Float> scores, int
limit) {
+ if (scores.isEmpty()) {
+ return ScoredGlobalIndexResult.createEmpty();
+ }
+ List<Map.Entry<Long, Float>> ranked = new
ArrayList<>(scores.entrySet());
+ Collections.sort(
+ ranked,
+ new Comparator<Map.Entry<Long, Float>>() {
+ @Override
+ public int compare(Map.Entry<Long, Float> left,
Map.Entry<Long, Float> right) {
+ int scoreCompare = Float.compare(right.getValue(),
left.getValue());
+ if (scoreCompare != 0) {
+ return scoreCompare;
+ }
+ return Long.compare(left.getKey(), right.getKey());
+ }
+ });
+
+ int size = Math.min(limit, ranked.size());
+ RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+ Map<Long, Float> topScores = new HashMap<>();
+ for (int i = 0; i < size; i++) {
+ Map.Entry<Long, Float> entry = ranked.get(i);
+ bitmap.add(entry.getKey());
+ topScores.put(entry.getKey(), entry.getValue());
+ }
+ return ScoredGlobalIndexResult.create(bitmap, topScores::get);
+ }
+
+ private static float weightAt(float[] weights, int index) {
+ if (weights == null || index >= weights.length) {
+ return 1.0f;
+ }
+ return weights[index];
+ }
+
+ /** Weighted result from one vector-search route. */
+ public static class WeightedResult implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final ScoredGlobalIndexResult result;
+ private final float weight;
+
+ public WeightedResult(ScoredGlobalIndexResult result, float weight) {
+ this.result = result;
+ this.weight = weight;
+ }
+
+ public ScoredGlobalIndexResult result() {
+ return result;
+ }
+
+ public float weight() {
+ return weight;
+ }
+ }
+}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/predicate/MultiVectorSearch.java
b/paimon-common/src/main/java/org/apache/paimon/predicate/MultiVectorSearch.java
new file mode 100644
index 0000000000..0541edf473
--- /dev/null
+++
b/paimon-common/src/main/java/org/apache/paimon/predicate/MultiVectorSearch.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.predicate;
+
+import org.apache.paimon.globalindex.MultiVectorSearchRanker;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Multi-vector search over multiple vector columns.
+ *
+ * <p>This is an internal pushdown representation. Use {@code
Table.newMultiVectorSearchBuilder()}
+ * to configure multi-vector search from Java.
+ */
+public class MultiVectorSearch implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final List<MultiVectorSearchRoute> routes;
+ private final int limit;
+ private final String ranker;
+
+ public MultiVectorSearch(List<MultiVectorSearchRoute> routes, int limit,
String ranker) {
+ if (routes == null || routes.isEmpty()) {
+ throw new IllegalArgumentException("Routes cannot be null or
empty");
+ }
+ if (limit <= 0) {
+ throw new IllegalArgumentException("Limit must be positive, got: "
+ limit);
+ }
+ this.routes = Collections.unmodifiableList(new ArrayList<>(routes));
+ this.limit = limit;
+ this.ranker = MultiVectorSearchRanker.normalizeRanker(ranker);
+ }
+
+ public List<MultiVectorSearchRoute> routes() {
+ return routes;
+ }
+
+ public int limit() {
+ return limit;
+ }
+
+ public String ranker() {
+ return ranker;
+ }
+
+ @Override
+ public String toString() {
+ return "Ranker(" + ranker + "), Limit(" + limit + "), Routes(" +
routes + ")";
+ }
+}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/predicate/MultiVectorSearchRoute.java
b/paimon-common/src/main/java/org/apache/paimon/predicate/MultiVectorSearchRoute.java
new file mode 100644
index 0000000000..070828a963
--- /dev/null
+++
b/paimon-common/src/main/java/org/apache/paimon/predicate/MultiVectorSearchRoute.java
@@ -0,0 +1,159 @@
+/*
+ * 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.predicate;
+
+import org.apache.paimon.annotation.Experimental;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/** A single vector-search route in a multi-vector search. */
+public class MultiVectorSearchRoute implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String fieldName;
+ private final float[] vector;
+ private final int limit;
+ private final float weight;
+ private final Map<String, String> options;
+
+ public MultiVectorSearchRoute(String fieldName, float[] vector, int limit,
float weight) {
+ this(fieldName, vector, limit, weight, Collections.emptyMap());
+ }
+
+ public MultiVectorSearchRoute(
+ String fieldName,
+ float[] vector,
+ int limit,
+ float weight,
+ Map<String, String> options) {
+ if (fieldName == null || fieldName.isEmpty()) {
+ throw new IllegalArgumentException("Field name cannot be null or
empty");
+ }
+ if (vector == null) {
+ throw new IllegalArgumentException("Search vector cannot be null");
+ }
+ if (limit <= 0) {
+ throw new IllegalArgumentException("Limit must be positive, got: "
+ limit);
+ }
+ if (weight <= 0) {
+ throw new IllegalArgumentException("Weight must be positive, got:
" + weight);
+ }
+ this.fieldName = fieldName;
+ this.vector = vector;
+ this.limit = limit;
+ this.weight = weight;
+ this.options =
+ options == null
+ ? Collections.emptyMap()
+ : Collections.unmodifiableMap(new HashMap<>(options));
+ }
+
+ @Experimental
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String fieldName() {
+ return fieldName;
+ }
+
+ public float[] vector() {
+ return vector;
+ }
+
+ public int limit() {
+ return limit;
+ }
+
+ public float weight() {
+ return weight;
+ }
+
+ public Map<String, String> options() {
+ return options;
+ }
+
+ public VectorSearch toVectorSearch() {
+ return new VectorSearch(vector, limit, fieldName, options);
+ }
+
+ @Override
+ public String toString() {
+ return "FieldName(" + fieldName + "), Limit(" + limit + "), Weight(" +
weight + ")";
+ }
+
+ /** Builder for {@link MultiVectorSearchRoute}. */
+ @Experimental
+ public static class Builder {
+
+ private String fieldName;
+ private float[] vector;
+ private int limit;
+ private float weight = 1.0f;
+ private Map<String, String> options = new HashMap<>();
+
+ public Builder vectorColumn(String fieldName) {
+ this.fieldName = fieldName;
+ return this;
+ }
+
+ public Builder field(String fieldName) {
+ return vectorColumn(fieldName);
+ }
+
+ public Builder queryVector(float[] vector) {
+ this.vector = vector;
+ return this;
+ }
+
+ public Builder vector(float[] vector) {
+ return queryVector(vector);
+ }
+
+ public Builder limit(int limit) {
+ this.limit = limit;
+ return this;
+ }
+
+ public Builder weight(float weight) {
+ this.weight = weight;
+ return this;
+ }
+
+ public Builder option(String key, String value) {
+ this.options.put(key, value);
+ return this;
+ }
+
+ public Builder options(Map<String, String> options) {
+ if (options != null) {
+ this.options.putAll(options);
+ }
+ return this;
+ }
+
+ public MultiVectorSearchRoute build() {
+ return new MultiVectorSearchRoute(fieldName, vector, limit,
weight, options);
+ }
+ }
+}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
b/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
index c2b608346b..5f046c6307 100644
--- a/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
+++ b/paimon-common/src/main/java/org/apache/paimon/predicate/VectorSearch.java
@@ -28,7 +28,12 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
-/** VectorSearch to perform vector similarity search. * */
+/**
+ * VectorSearch to perform vector similarity search.
+ *
+ * <p>This is an internal pushdown representation. Use {@code
Table.newVectorSearchBuilder()} to
+ * configure vector search from Java.
+ */
public class VectorSearch implements Serializable {
private static final long serialVersionUID = 1L;
diff --git
a/paimon-common/src/test/java/org/apache/paimon/globalindex/MultiVectorSearchRankerTest.java
b/paimon-common/src/test/java/org/apache/paimon/globalindex/MultiVectorSearchRankerTest.java
new file mode 100644
index 0000000000..3aa5be4fd9
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/globalindex/MultiVectorSearchRankerTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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;
+
+import org.apache.paimon.utils.RoaringNavigableMap64;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/** Tests for {@link MultiVectorSearchRanker}. */
+public class MultiVectorSearchRankerTest {
+
+ @Test
+ public void testRrfFavorsRowsReturnedByMultipleRoutes() {
+ ScoredGlobalIndexResult first = result(new long[] {1, 2}, new float[]
{0.9f, 0.8f});
+ ScoredGlobalIndexResult second = result(new long[] {2, 3}, new float[]
{0.7f, 0.6f});
+
+ ScoredGlobalIndexResult ranked =
+ MultiVectorSearchRanker.rrf(
+ Arrays.asList(first, second), new float[] {1.0f,
1.0f}, 2);
+
+ assertThat(ranked.results().getIntCardinality()).isEqualTo(2);
+ assertThat(ranked.results()).contains(2L);
+
assertThat(ranked.scoreGetter().score(2L)).isGreaterThan(ranked.scoreGetter().score(1L));
+ }
+
+ @Test
+ public void testWeightedScoreUsesAlignedWeightsAfterEmptyRouteIsSkipped() {
+ ScoredGlobalIndexResult result = result(new long[] {1, 2}, new float[]
{0.3f, 0.2f});
+
+ ScoredGlobalIndexResult ranked =
+ MultiVectorSearchRanker.weightedScore(
+ Collections.singletonList(result), new float[] {3.0f},
1);
+
+ assertThat(ranked.results().getIntCardinality()).isEqualTo(1);
+ assertThat(ranked.results()).contains(1L);
+ assertThat(ranked.scoreGetter().score(1L)).isCloseTo(0.9f,
within(0.000001f));
+ }
+
+ private ScoredGlobalIndexResult result(long[] rowIds, float[] scores) {
+ RoaringNavigableMap64 bitmap = new RoaringNavigableMap64();
+ Map<Long, Float> scoreMap = new HashMap<>();
+ for (int i = 0; i < rowIds.length; i++) {
+ bitmap.add(rowIds[i]);
+ scoreMap.put(rowIds[i], scores[i]);
+ }
+ return ScoredGlobalIndexResult.create(bitmap, scoreMap::get);
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
index 4149683d7a..ae23dcd8f1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/FormatTable.java
@@ -32,6 +32,7 @@ import org.apache.paimon.table.format.FormatReadBuilder;
import org.apache.paimon.table.sink.BatchWriteBuilder;
import org.apache.paimon.table.sink.StreamWriteBuilder;
import org.apache.paimon.table.source.FullTextSearchBuilder;
+import org.apache.paimon.table.source.MultiVectorSearchBuilder;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.table.source.VectorSearchBuilder;
import org.apache.paimon.types.RowType;
@@ -280,6 +281,12 @@ public interface FormatTable extends Table {
throw new UnsupportedOperationException("FormatTable does not
support vector search.");
}
+ @Override
+ public MultiVectorSearchBuilder newMultiVectorSearchBuilder() {
+ throw new UnsupportedOperationException(
+ "FormatTable does not support multi-vector search.");
+ }
+
@Override
public FullTextSearchBuilder newFullTextSearchBuilder() {
throw new UnsupportedOperationException(
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
index d360597744..fd863d643f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/InnerTable.java
@@ -29,6 +29,8 @@ import org.apache.paimon.table.source.FullTextSearchBuilder;
import org.apache.paimon.table.source.FullTextSearchBuilderImpl;
import org.apache.paimon.table.source.InnerTableRead;
import org.apache.paimon.table.source.InnerTableScan;
+import org.apache.paimon.table.source.MultiVectorSearchBuilder;
+import org.apache.paimon.table.source.MultiVectorSearchBuilderImpl;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.table.source.ReadBuilderImpl;
import org.apache.paimon.table.source.StreamDataTableScan;
@@ -62,6 +64,11 @@ public interface InnerTable extends Table {
return new VectorSearchBuilderImpl(this);
}
+ @Override
+ default MultiVectorSearchBuilder newMultiVectorSearchBuilder() {
+ return new MultiVectorSearchBuilderImpl(this);
+ }
+
@Override
default FullTextSearchBuilder newFullTextSearchBuilder() {
return new FullTextSearchBuilderImpl(this);
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/MultiVectorSearchTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/MultiVectorSearchTable.java
new file mode 100644
index 0000000000..a1c0322ba8
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/MultiVectorSearchTable.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;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.predicate.MultiVectorSearch;
+import org.apache.paimon.table.source.InnerTableRead;
+import org.apache.paimon.table.source.InnerTableScan;
+import org.apache.paimon.types.RowType;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A table wrapper to hold multi-vector search information. This is used to
pass multi-vector search
+ * pushdown information from logical plan optimization to physical plan
execution. For now, it is
+ * only used by internal for Spark engine.
+ */
+public class MultiVectorSearchTable implements ReadonlyTable {
+
+ private final InnerTable origin;
+ private final MultiVectorSearch multiVectorSearch;
+
+ private MultiVectorSearchTable(InnerTable origin, MultiVectorSearch
multiVectorSearch) {
+ this.origin = origin;
+ this.multiVectorSearch = multiVectorSearch;
+ }
+
+ public static MultiVectorSearchTable create(
+ InnerTable origin, MultiVectorSearch multiVectorSearch) {
+ return new MultiVectorSearchTable(origin, multiVectorSearch);
+ }
+
+ public MultiVectorSearch multiVectorSearch() {
+ return multiVectorSearch;
+ }
+
+ public InnerTable origin() {
+ return origin;
+ }
+
+ @Override
+ public String name() {
+ return origin.name();
+ }
+
+ @Override
+ public RowType rowType() {
+ return origin.rowType();
+ }
+
+ @Override
+ public List<String> primaryKeys() {
+ return origin.primaryKeys();
+ }
+
+ @Override
+ public List<String> partitionKeys() {
+ return origin.partitionKeys();
+ }
+
+ @Override
+ public Map<String, String> options() {
+ return origin.options();
+ }
+
+ @Override
+ public FileIO fileIO() {
+ return origin.fileIO();
+ }
+
+ @Override
+ public InnerTableRead newRead() {
+ return origin.newRead();
+ }
+
+ @Override
+ public InnerTableScan newScan() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Table copy(Map<String, String> dynamicOptions) {
+ return new MultiVectorSearchTable(
+ (InnerTable) origin.copy(dynamicOptions), multiVectorSearch);
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/Table.java
b/paimon-core/src/main/java/org/apache/paimon/table/Table.java
index f58259b1fc..aa05ba4660 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/Table.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/Table.java
@@ -29,6 +29,7 @@ import org.apache.paimon.stats.Statistics;
import org.apache.paimon.table.sink.BatchWriteBuilder;
import org.apache.paimon.table.sink.StreamWriteBuilder;
import org.apache.paimon.table.source.FullTextSearchBuilder;
+import org.apache.paimon.table.source.MultiVectorSearchBuilder;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.table.source.VectorSearchBuilder;
import org.apache.paimon.types.RowType;
@@ -226,6 +227,12 @@ public interface Table extends Serializable {
/** Returns a new vector search builder. */
VectorSearchBuilder newVectorSearchBuilder();
+ /** Returns a new multi-vector search builder. */
+ default MultiVectorSearchBuilder newMultiVectorSearchBuilder() {
+ throw new UnsupportedOperationException(
+ getClass().getName() + " does not support multi-vector
search.");
+ }
+
/** Returns a new full-text search builder. */
FullTextSearchBuilder newFullTextSearchBuilder();
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/MultiVectorSearchBuilder.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/MultiVectorSearchBuilder.java
new file mode 100644
index 0000000000..b45d8fd2e0
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/MultiVectorSearchBuilder.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.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.MultiVectorSearchRoute;
+import org.apache.paimon.predicate.Predicate;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Builder to build multi-vector search. */
+public interface MultiVectorSearchBuilder extends Serializable {
+
+ /** Push partition filters. */
+ MultiVectorSearchBuilder withPartitionFilter(PartitionPredicate
partitionPredicate);
+
+ /** Push pre-filter for vector search. */
+ MultiVectorSearchBuilder withFilter(Predicate predicate);
+
+ /** Add a vector-search route. */
+ MultiVectorSearchBuilder addRoute(MultiVectorSearchRoute route);
+
+ /** Add a vector-search route. */
+ default MultiVectorSearchBuilder addRoute(String vectorColumn, float[]
vector, int limit) {
+ return addRoute(vectorColumn, vector, limit, 1.0f);
+ }
+
+ /** Add a vector-search route. */
+ default MultiVectorSearchBuilder addRoute(
+ String vectorColumn, float[] vector, int limit, float weight) {
+ return addRoute(new MultiVectorSearchRoute(vectorColumn, vector,
limit, weight));
+ }
+
+ /** Add a vector-search route. */
+ default MultiVectorSearchBuilder addRoute(
+ String vectorColumn,
+ float[] vector,
+ int limit,
+ float weight,
+ Map<String, String> options) {
+ return addRoute(new MultiVectorSearchRoute(vectorColumn, vector,
limit, weight, options));
+ }
+
+ /** The final top k ranked results to return. */
+ MultiVectorSearchBuilder withLimit(int limit);
+
+ /** Ranker for combining route results. */
+ MultiVectorSearchBuilder withRanker(String ranker);
+
+ /** Use reciprocal rank fusion to combine route results. */
+ MultiVectorSearchBuilder withRrfRanker();
+
+ /** Use weighted score to combine route results. */
+ MultiVectorSearchBuilder withWeightedScoreRanker();
+
+ /** Create one vector-search builder for each route so engines can
dispatch route work. */
+ List<Route> routeBuilders();
+
+ /** Convert a vector-search result into a weighted route result. */
+ RouteResult toRouteResult(Route route, GlobalIndexResult result);
+
+ /** Rank route results. */
+ ScoredGlobalIndexResult rank(List<RouteResult> routeResults);
+
+ /** Execute multi-vector index search in local. */
+ default ScoredGlobalIndexResult executeLocal() {
+ List<Route> routes = routeBuilders();
+ List<RouteResult> routeResults = new ArrayList<>(routes.size());
+ for (Route route : routes) {
+ routeResults.add(toRouteResult(route,
route.vectorSearchBuilder().executeLocal()));
+ }
+ return rank(routeResults);
+ }
+
+ /** A route and its configured vector-search builder. */
+ class Route implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final MultiVectorSearchRoute route;
+ private final VectorSearchBuilder vectorSearchBuilder;
+
+ public Route(MultiVectorSearchRoute route, VectorSearchBuilder
vectorSearchBuilder) {
+ this.route = route;
+ this.vectorSearchBuilder = vectorSearchBuilder;
+ }
+
+ public MultiVectorSearchRoute route() {
+ return route;
+ }
+
+ public VectorSearchBuilder vectorSearchBuilder() {
+ return vectorSearchBuilder;
+ }
+ }
+
+ /** A scored result produced by one route. */
+ class RouteResult implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final MultiVectorSearchRoute route;
+ private final ScoredGlobalIndexResult result;
+
+ public RouteResult(MultiVectorSearchRoute route,
ScoredGlobalIndexResult result) {
+ this.route = route;
+ this.result = result;
+ }
+
+ public MultiVectorSearchRoute route() {
+ return route;
+ }
+
+ public ScoredGlobalIndexResult result() {
+ return result;
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/MultiVectorSearchBuilderImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/MultiVectorSearchBuilderImpl.java
new file mode 100644
index 0000000000..247301301a
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/MultiVectorSearchBuilderImpl.java
@@ -0,0 +1,160 @@
+/*
+ * 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.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.MultiVectorSearchRanker;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.MultiVectorSearchRoute;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.table.InnerTable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/** Implementation for {@link MultiVectorSearchBuilder}. */
+public class MultiVectorSearchBuilderImpl implements MultiVectorSearchBuilder {
+
+ private static final long serialVersionUID = 1L;
+
+ protected final InnerTable table;
+
+ protected final List<MultiVectorSearchRoute> routes = new ArrayList<>();
+ protected int limit;
+ protected String ranker = MultiVectorSearchRanker.RRF_RANKER;
+ protected PartitionPredicate partitionFilter;
+ protected Predicate filter;
+
+ public MultiVectorSearchBuilderImpl(InnerTable table) {
+ this.table = table;
+ }
+
+ @Override
+ public MultiVectorSearchBuilder withPartitionFilter(PartitionPredicate
partitionFilter) {
+ this.partitionFilter = partitionFilter;
+ return this;
+ }
+
+ @Override
+ public MultiVectorSearchBuilder withFilter(Predicate predicate) {
+ if (this.filter == null) {
+ this.filter = predicate;
+ } else {
+ this.filter = PredicateBuilder.and(this.filter, predicate);
+ }
+ return this;
+ }
+
+ @Override
+ public MultiVectorSearchBuilder addRoute(MultiVectorSearchRoute route) {
+ this.routes.add(Objects.requireNonNull(route, "Route cannot be null"));
+ return this;
+ }
+
+ @Override
+ public MultiVectorSearchBuilder withLimit(int limit) {
+ this.limit = limit;
+ return this;
+ }
+
+ @Override
+ public MultiVectorSearchBuilder withRanker(String ranker) {
+ this.ranker = MultiVectorSearchRanker.normalizeRanker(ranker);
+ return this;
+ }
+
+ @Override
+ public MultiVectorSearchBuilder withRrfRanker() {
+ return withRanker(MultiVectorSearchRanker.RRF_RANKER);
+ }
+
+ @Override
+ public MultiVectorSearchBuilder withWeightedScoreRanker() {
+ return withRanker(MultiVectorSearchRanker.WEIGHTED_SCORE_RANKER);
+ }
+
+ @Override
+ public List<Route> routeBuilders() {
+ validateSearch();
+
+ List<Route> routeBuilders = new ArrayList<>(routes.size());
+ for (MultiVectorSearchRoute route : routes) {
+ VectorSearchBuilder vectorSearchBuilder =
newVectorSearchBuilder(route);
+ routeBuilders.add(new Route(route, vectorSearchBuilder));
+ }
+ return routeBuilders;
+ }
+
+ private void validateSearch() {
+ if (routes.isEmpty()) {
+ throw new IllegalArgumentException("Routes cannot be empty");
+ }
+ if (limit <= 0) {
+ throw new IllegalArgumentException("Limit must be positive, got: "
+ limit);
+ }
+ }
+
+ @Override
+ public ScoredGlobalIndexResult rank(List<RouteResult> routeResults) {
+ validateSearch();
+
+ List<MultiVectorSearchRanker.WeightedResult> weightedResults =
+ new ArrayList<>(routeResults.size());
+ for (RouteResult routeResult : routeResults) {
+ if (!routeResult.result().results().isEmpty()) {
+ weightedResults.add(
+ new MultiVectorSearchRanker.WeightedResult(
+ routeResult.result(),
routeResult.route().weight()));
+ }
+ }
+ return MultiVectorSearchRanker.rank(ranker, weightedResults, limit);
+ }
+
+ @Override
+ public RouteResult toRouteResult(Route route, GlobalIndexResult result) {
+ if (result instanceof ScoredGlobalIndexResult) {
+ return new RouteResult(route.route(), (ScoredGlobalIndexResult)
result);
+ } else if (result.results().isEmpty()) {
+ return new RouteResult(route.route(),
ScoredGlobalIndexResult.createEmpty());
+ } else {
+ throw new UnsupportedOperationException(
+ "Multi-vector search requires scored vector index results,
but got: "
+ + result.getClass().getName());
+ }
+ }
+
+ protected VectorSearchBuilder
newVectorSearchBuilder(MultiVectorSearchRoute route) {
+ VectorSearchBuilder vectorSearchBuilder =
+ table.newVectorSearchBuilder()
+ .withVector(route.vector())
+ .withVectorColumn(route.fieldName())
+ .withLimit(route.limit())
+ .withOptions(route.options());
+ if (partitionFilter != null) {
+ vectorSearchBuilder.withPartitionFilter(partitionFilter);
+ }
+ if (filter != null) {
+ vectorSearchBuilder.withFilter(filter);
+ }
+ return vectorSearchBuilder;
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
index 17ff21bb61..39379c66c1 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
@@ -74,15 +74,65 @@ public class VectorSearchBuilderTest extends TableTestBase {
@Override
protected Schema schemaDefault() {
- return Schema.newBuilder()
- .column("id", DataTypes.INT())
- .column(VECTOR_FIELD_NAME, new ArrayType(DataTypes.FLOAT()))
- .option(CoreOptions.BUCKET.key(), "-1")
+ return vectorSchemaBuilder(VECTOR_FIELD_NAME).build();
+ }
+
+ protected Schema.Builder vectorSchemaBuilder(String vectorFieldName) {
+ return withVectorSchemaOptions(
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column(vectorFieldName, new
ArrayType(DataTypes.FLOAT())));
+ }
+
+ protected Schema.Builder multiVectorSchemaBuilder() {
+ return withVectorSchemaOptions(
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("title_vec", new ArrayType(DataTypes.FLOAT()))
+ .column("body_vec", new ArrayType(DataTypes.FLOAT())));
+ }
+
+ protected Schema.Builder withVectorSchemaOptions(Schema.Builder builder) {
+ return builder.option(CoreOptions.BUCKET.key(), "-1")
.option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
.option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true")
.option("test.vector.dimension", String.valueOf(DIMENSION))
- .option("test.vector.metric", "l2")
- .build();
+ .option("test.vector.metric", "l2");
+ }
+
+ @Test
+ public void testMultiVectorSearchBuilderExposesRouteBuilders() throws
Exception {
+ catalog.createTable(
+ identifier("multi_vector_builder_table"),
+ multiVectorSchemaBuilder().build(),
+ false);
+ FileStoreTable table =
getTable(identifier("multi_vector_builder_table"));
+
+ float[][] titleVectors = {{1.0f, 0.0f}, {0.9f, 0.1f}, {0.0f, 1.0f}};
+ float[][] bodyVectors = {{0.0f, 1.0f}, {0.1f, 0.9f}, {1.0f, 0.0f}};
+ writeTwoVectorColumns(table, titleVectors, bodyVectors);
+ buildAndCommitIndex(table, "title_vec", titleVectors);
+ buildAndCommitIndex(table, "body_vec", bodyVectors);
+
+ MultiVectorSearchBuilder builder =
+ table.newMultiVectorSearchBuilder()
+ .addRoute("title_vec", new float[] {1.0f, 0.0f}, 2)
+ .addRoute("body_vec", new float[] {0.0f, 1.0f}, 2,
2.0f)
+ .withLimit(2)
+ .withWeightedScoreRanker();
+ List<MultiVectorSearchBuilder.Route> routes = builder.routeBuilders();
+
+ assertThat(routes).hasSize(2);
+
+ List<MultiVectorSearchBuilder.RouteResult> routeResults = new
ArrayList<>();
+ for (MultiVectorSearchBuilder.Route route : routes) {
+ routeResults.add(
+ builder.toRouteResult(route,
route.vectorSearchBuilder().executeLocal()));
+ }
+ ScoredGlobalIndexResult ranked = builder.rank(routeResults);
+
+ assertThat(ranked.results().getIntCardinality()).isEqualTo(2);
+ assertThat(ranked.results()).contains(1L);
}
@Test
@@ -133,13 +183,7 @@ public class VectorSearchBuilderTest extends TableTestBase
{
// Create a table with cosine metric
catalog.createTable(
identifier("cosine_table"),
- Schema.newBuilder()
- .column("id", DataTypes.INT())
- .column(VECTOR_FIELD_NAME, new
ArrayType(DataTypes.FLOAT()))
- .option(CoreOptions.BUCKET.key(), "-1")
- .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
- .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
- .option("test.vector.dimension",
String.valueOf(DIMENSION))
+ vectorSchemaBuilder(VECTOR_FIELD_NAME)
.option("test.vector.metric", "cosine")
.build(),
false);
@@ -231,14 +275,7 @@ public class VectorSearchBuilderTest extends TableTestBase
{
public void testVectorSearchThreadsOptions() throws Exception {
catalog.createTable(
identifier("options_table"),
- Schema.newBuilder()
- .column("id", DataTypes.INT())
- .column(VECTOR_FIELD_NAME, new
ArrayType(DataTypes.FLOAT()))
- .option(CoreOptions.BUCKET.key(), "-1")
- .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
- .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
- .option("test.vector.dimension",
String.valueOf(DIMENSION))
- .option("test.vector.metric", "l2")
+ vectorSchemaBuilder(VECTOR_FIELD_NAME)
.option("test.vector.required-option.key",
"ivf.nprobe")
.option("test.vector.required-option.value", "16")
.build(),
@@ -311,16 +348,12 @@ public class VectorSearchBuilderTest extends
TableTestBase {
// Create a partitioned table
catalog.createTable(
identifier("partitioned_table"),
- Schema.newBuilder()
- .column("pt", DataTypes.INT())
- .column("id", DataTypes.INT())
- .column(VECTOR_FIELD_NAME, new
ArrayType(DataTypes.FLOAT()))
- .partitionKeys("pt")
- .option(CoreOptions.BUCKET.key(), "-1")
- .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
- .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
- .option("test.vector.dimension",
String.valueOf(DIMENSION))
- .option("test.vector.metric", "l2")
+ withVectorSchemaOptions(
+ Schema.newBuilder()
+ .column("pt", DataTypes.INT())
+ .column("id", DataTypes.INT())
+ .column(VECTOR_FIELD_NAME, new
ArrayType(DataTypes.FLOAT()))
+ .partitionKeys("pt"))
.build(),
false);
FileStoreTable table = getTable(identifier("partitioned_table"));
@@ -553,9 +586,30 @@ public class VectorSearchBuilderTest extends TableTestBase
{
}
}
+ private void writeTwoVectorColumns(
+ FileStoreTable table, float[][] titleVectors, float[][]
bodyVectors) throws Exception {
+ BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = writeBuilder.newWrite();
+ BatchTableCommit commit = writeBuilder.newCommit()) {
+ for (int i = 0; i < titleVectors.length; i++) {
+ write.write(
+ GenericRow.of(
+ i,
+ new GenericArray(titleVectors[i]),
+ new GenericArray(bodyVectors[i])));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
private void buildAndCommitIndex(FileStoreTable table, float[][] vectors)
throws Exception {
+ buildAndCommitIndex(table, VECTOR_FIELD_NAME, vectors);
+ }
+
+ private void buildAndCommitIndex(FileStoreTable table, String fieldName,
float[][] vectors)
+ throws Exception {
Options options = table.coreOptions().toConfiguration();
- DataField vectorField = table.rowType().getField(VECTOR_FIELD_NAME);
+ DataField vectorField = table.rowType().getField(fieldName);
GlobalIndexSingletonWriter writer =
(GlobalIndexSingletonWriter)
diff --git
a/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceVectorSearchTest.java
b/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceVectorSearchTest.java
index a8107977bb..d02f55db8e 100644
---
a/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceVectorSearchTest.java
+++
b/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceVectorSearchTest.java
@@ -26,8 +26,6 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.options.Options;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.table.source.VectorSearchBuilderTest;
-import org.apache.paimon.types.ArrayType;
-import org.apache.paimon.types.DataTypes;
import org.apache.paimon.utils.TraceableFileIO;
import org.junit.jupiter.api.BeforeEach;
@@ -52,16 +50,13 @@ public class LanceVectorSearchTest extends
VectorSearchBuilderTest {
@Override
protected Schema schemaDefault() {
- return Schema.newBuilder()
- .column("id", DataTypes.INT())
- .column("vec", new ArrayType(DataTypes.FLOAT()))
- .option(CoreOptions.BUCKET.key(), "-1")
- .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
- .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true")
- .option(CoreOptions.FILE_FORMAT.key(), "lance")
- .option("test.vector.dimension", "2")
- .option("test.vector.metric", "l2")
- .build();
+ return vectorSchemaBuilder("vec").build();
+ }
+
+ @Override
+ protected Schema.Builder withVectorSchemaOptions(Schema.Builder builder) {
+ return super.withVectorSchemaOptions(builder)
+ .option(CoreOptions.FILE_FORMAT.key(), "lance");
}
@Disabled("Cosine metric uses Tantivy index which requires Hadoop
dependencies")
diff --git
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index 9af6c8f8b5..ec652fad3b 100644
---
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -19,7 +19,7 @@
package org.apache.paimon.spark
import org.apache.paimon.partition.PartitionPredicate
-import org.apache.paimon.predicate.{FullTextSearch, Predicate, TopN,
VectorSearch}
+import org.apache.paimon.predicate.{FullTextSearch, MultiVectorSearch,
Predicate, TopN, VectorSearch}
import org.apache.paimon.spark.read.VariantExtractionInfo
import org.apache.paimon.table.InnerTable
@@ -33,6 +33,7 @@ case class PaimonScan(
override val pushedLimit: Option[Int] = None,
override val pushedTopN: Option[TopN] = None,
override val pushedVectorSearch: Option[VectorSearch] = None,
+ override val pushedMultiVectorSearch: Option[MultiVectorSearch] = None,
override val pushedFullTextSearch: Option[FullTextSearch] = None,
override val pushedVariantExtractions: Map[Seq[String],
Seq[VariantExtractionInfo]] = Map.empty,
bucketedScanDisabled: Boolean = true)
diff --git
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
index ef1f68c09f..daa1a56122 100644
---
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
+++
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
@@ -25,18 +25,14 @@ import org.apache.spark.sql.connector.read.Scan
class PaimonScanBuilder(val table: InnerTable) extends PaimonBaseScanBuilder {
override def build(): Scan = {
- val (actualTable, vectorSearch, fullTextSearch) = table match {
+ val (actualTable, vectorSearch, multiVectorSearch, fullTextSearch) = table
match {
case vst: org.apache.paimon.table.VectorSearchTable =>
- val tableVectorSearch = Option(vst.vectorSearch())
- val vs = (tableVectorSearch, pushedVectorSearch) match {
- case (Some(_), _) => tableVectorSearch
- case (None, Some(_)) => pushedVectorSearch
- case (None, None) => None
- }
- (vst.origin(), vs, None)
+ (vst.origin(), Option(vst.vectorSearch()), None, None)
+ case mvst: org.apache.paimon.table.MultiVectorSearchTable =>
+ (mvst.origin(), None, Option(mvst.multiVectorSearch()), None)
case ftst: org.apache.paimon.table.FullTextSearchTable =>
- (ftst.origin(), None, Option(ftst.fullTextSearch()))
- case _ => (table, pushedVectorSearch, pushedFullTextSearch)
+ (ftst.origin(), None, None, Option(ftst.fullTextSearch()))
+ case _ => (table, pushedVectorSearch, None, pushedFullTextSearch)
}
PaimonScan(
actualTable,
@@ -46,6 +42,7 @@ class PaimonScanBuilder(val table: InnerTable) extends
PaimonBaseScanBuilder {
pushedLimit,
pushedTopN,
vectorSearch,
+ multiVectorSearch,
fullTextSearch)
}
}
diff --git
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index 9bedc9bdf1..c35589075b 100644
---
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -19,7 +19,7 @@
package org.apache.paimon.spark
import org.apache.paimon.partition.PartitionPredicate
-import org.apache.paimon.predicate.{FullTextSearch, Predicate, TopN,
VectorSearch}
+import org.apache.paimon.predicate.{FullTextSearch, MultiVectorSearch,
Predicate, TopN, VectorSearch}
import org.apache.paimon.spark.read.VariantExtractionInfo
import org.apache.paimon.table.{BucketMode, FileStoreTable, InnerTable}
import org.apache.paimon.table.source.{DataSplit, Split}
@@ -37,6 +37,7 @@ case class PaimonScan(
override val pushedLimit: Option[Int],
override val pushedTopN: Option[TopN],
override val pushedVectorSearch: Option[VectorSearch],
+ override val pushedMultiVectorSearch: Option[MultiVectorSearch] = None,
override val pushedFullTextSearch: Option[FullTextSearch] = None,
override val pushedVariantExtractions: Map[Seq[String],
Seq[VariantExtractionInfo]] = Map.empty,
bucketedScanDisabled: Boolean = false)
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkMultiVectorSearchBuilderImpl.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkMultiVectorSearchBuilderImpl.java
new file mode 100644
index 0000000000..cc42783094
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/read/SparkMultiVectorSearchBuilderImpl.java
@@ -0,0 +1,51 @@
+/*
+ * 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.spark.read;
+
+import org.apache.paimon.predicate.MultiVectorSearchRoute;
+import org.apache.paimon.table.InnerTable;
+import org.apache.paimon.table.source.MultiVectorSearchBuilderImpl;
+import org.apache.paimon.table.source.VectorSearchBuilder;
+
+/** Spark-aware {@link MultiVectorSearchBuilderImpl}. */
+public class SparkMultiVectorSearchBuilderImpl extends
MultiVectorSearchBuilderImpl {
+
+ private static final long serialVersionUID = 1L;
+
+ public SparkMultiVectorSearchBuilderImpl(InnerTable table) {
+ super(table);
+ }
+
+ @Override
+ protected VectorSearchBuilder
newVectorSearchBuilder(MultiVectorSearchRoute route) {
+ VectorSearchBuilder vectorSearchBuilder =
+ new SparkVectorSearchBuilderImpl(table)
+ .withVector(route.vector())
+ .withVectorColumn(route.fieldName())
+ .withLimit(route.limit())
+ .withOptions(route.options());
+ if (partitionFilter != null) {
+ vectorSearchBuilder.withPartitionFilter(partitionFilter);
+ }
+ if (filter != null) {
+ vectorSearchBuilder.withFilter(filter);
+ }
+ return vectorSearchBuilder;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
index d7f6cd23d3..274c5b841c 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
@@ -23,7 +23,7 @@ import org.apache.paimon.globalindex.GlobalIndexResult
import org.apache.paimon.partition.PartitionPredicate
import org.apache.paimon.predicate.PredicateBuilder
import org.apache.paimon.spark.metric.SparkMetricRegistry
-import org.apache.paimon.spark.read.{BaseScan, BatchReadTagCleanupListener,
PaimonSupportsRuntimeFiltering, SparkVectorSearchBuilderImpl}
+import org.apache.paimon.spark.read.{BaseScan, BatchReadTagCleanupListener,
PaimonSupportsRuntimeFiltering, SparkMultiVectorSearchBuilderImpl,
SparkVectorSearchBuilderImpl}
import org.apache.paimon.spark.sources.PaimonMicroBatchStream
import org.apache.paimon.spark.util.OptionUtils
import org.apache.paimon.table.{DataTable, FileStoreTable, InnerTable}
@@ -64,13 +64,18 @@ abstract class PaimonBaseScan(table: InnerTable)
}
private def evalGlobalIndexSearch(): GlobalIndexResult = {
- if (pushedVectorSearch.isDefined && pushedFullTextSearch.isDefined) {
+ val globalSearchCount =
+ Seq(pushedVectorSearch, pushedMultiVectorSearch,
pushedFullTextSearch).count(_.isDefined)
+ if (globalSearchCount > 1) {
throw new UnsupportedOperationException(
- "Cannot push down both vector search and full-text search
simultaneously.")
+ "Cannot push down vector search, multi-vector search and full-text
search simultaneously.")
}
if (pushedVectorSearch.isDefined) {
return evalVectorSearch()
}
+ if (pushedMultiVectorSearch.isDefined) {
+ return evalMultiVectorSearch()
+ }
if (pushedFullTextSearch.isDefined) {
return evalFullTextSearch()
}
@@ -99,6 +104,27 @@ abstract class PaimonBaseScan(table: InnerTable)
vectorBuilder.newVectorRead().read(vectorBuilder.newVectorScan().scan())
}
+ private def evalMultiVectorSearch(): GlobalIndexResult = {
+ val multiVectorSearch = pushedMultiVectorSearch.get
+ val multiVectorSearchBuilder =
+ if (CoreOptions.fromMap(table.options).vectorSearchDistributeEnabled()) {
+ new SparkMultiVectorSearchBuilderImpl(table)
+ } else {
+ table.newMultiVectorSearchBuilder()
+ }
+ val builder = multiVectorSearchBuilder
+ .withLimit(multiVectorSearch.limit())
+ .withRanker(multiVectorSearch.ranker())
+ multiVectorSearch.routes().asScala.foreach(route =>
builder.addRoute(route))
+ if (pushedPartitionFilters.nonEmpty) {
+
builder.withPartitionFilter(PartitionPredicate.and(pushedPartitionFilters.asJava))
+ }
+ if (pushedDataFilters.nonEmpty) {
+ builder.withFilter(PredicateBuilder.and(pushedDataFilters.asJava))
+ }
+ builder.executeLocal()
+ }
+
private def evalFullTextSearch(): GlobalIndexResult = {
val fullTextSearch = pushedFullTextSearch.get
val ftBuilder = table
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index a2b04c3ac4..09ece0bfea 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -20,7 +20,7 @@ package org.apache.paimon.spark
import org.apache.paimon.CoreOptions.BucketFunctionType
import org.apache.paimon.partition.PartitionPredicate
-import org.apache.paimon.predicate.{FullTextSearch, Predicate, TopN,
VectorSearch}
+import org.apache.paimon.predicate.{FullTextSearch, MultiVectorSearch,
Predicate, TopN, VectorSearch}
import org.apache.paimon.spark.commands.BucketExpression.quote
import org.apache.paimon.spark.read.VariantExtractionInfo
import org.apache.paimon.table.{BucketMode, FileStoreTable, InnerTable}
@@ -43,6 +43,7 @@ case class PaimonScan(
override val pushedLimit: Option[Int],
override val pushedTopN: Option[TopN],
override val pushedVectorSearch: Option[VectorSearch],
+ override val pushedMultiVectorSearch: Option[MultiVectorSearch] = None,
override val pushedFullTextSearch: Option[FullTextSearch] = None,
override val pushedVariantExtractions: Map[Seq[String],
Seq[VariantExtractionInfo]] = Map.empty,
bucketedScanDisabled: Boolean = false)
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
index e6f98363a0..9e2e853ee2 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
@@ -129,18 +129,14 @@ class PaimonScanBuilder(val table: InnerTable)
localScan match {
case Some(scan) => scan
case None =>
- val (actualTable, vectorSearch, fullTextSearch) = table match {
+ val (actualTable, vectorSearch, multiVectorSearch, fullTextSearch) =
table match {
case vst: org.apache.paimon.table.VectorSearchTable =>
- val tableVectorSearch = Option(vst.vectorSearch())
- val vs = (tableVectorSearch, pushedVectorSearch) match {
- case (Some(_), _) => tableVectorSearch
- case (None, Some(_)) => pushedVectorSearch
- case (None, None) => None
- }
- (vst.origin(), vs, None)
+ (vst.origin(), Option(vst.vectorSearch()), None, None)
+ case mvst: org.apache.paimon.table.MultiVectorSearchTable =>
+ (mvst.origin(), None, Option(mvst.multiVectorSearch()), None)
case ftst: org.apache.paimon.table.FullTextSearchTable =>
- (ftst.origin(), None, Option(ftst.fullTextSearch()))
- case _ => (table, pushedVectorSearch, pushedFullTextSearch)
+ (ftst.origin(), None, None, Option(ftst.fullTextSearch()))
+ case _ => (table, pushedVectorSearch, None, pushedFullTextSearch)
}
PaimonScan(
@@ -151,6 +147,7 @@ class PaimonScanBuilder(val table: InnerTable)
pushedLimit,
pushedTopN,
vectorSearch,
+ multiVectorSearch,
fullTextSearch,
acceptedVariantExtractions
)
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
index 94b9128444..084174a823 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
@@ -118,7 +118,7 @@ abstract class PaimonSparkTableBase(val table: Table)
_metadataColumns.append(PaimonMetadataColumn.ROW_ID)
_metadataColumns.append(PaimonMetadataColumn.SEQUENCE_NUMBER)
}
- if (table.isInstanceOf[VectorSearchTable]) {
+ if (table.isInstanceOf[VectorSearchTable] ||
table.isInstanceOf[MultiVectorSearchTable]) {
_metadataColumns.append(PaimonMetadataColumn.VECTOR_SEARCH_SCORE)
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
index 6dc0c77ced..7efd67960e 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/plans/logical/PaimonTableValuedFunctions.scala
@@ -19,10 +19,11 @@
package org.apache.paimon.spark.catalyst.plans.logical
import org.apache.paimon.CoreOptions
-import org.apache.paimon.predicate.{FullTextSearch, VectorSearch}
+import org.apache.paimon.globalindex.MultiVectorSearchRanker
+import org.apache.paimon.predicate.{FullTextSearch, MultiVectorSearch,
MultiVectorSearchRoute, VectorSearch}
import org.apache.paimon.spark.SparkTable
import
org.apache.paimon.spark.catalyst.plans.logical.PaimonTableValuedFunctions._
-import org.apache.paimon.table.{DataTable, FullTextSearchTable, InnerTable,
VectorSearchTable}
+import org.apache.paimon.table.{DataTable, FullTextSearchTable, InnerTable,
MultiVectorSearchTable, VectorSearchTable}
import
org.apache.paimon.table.source.snapshot.TimeTravelUtil.InconsistentTagBucketException
import org.apache.spark.sql.PaimonUtils.createDataset
@@ -30,7 +31,7 @@ import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.FunctionIdentifier
import org.apache.spark.sql.catalyst.analysis.FunctionRegistryBase
import
org.apache.spark.sql.catalyst.analysis.TableFunctionRegistry.TableFunctionBuilder
-import org.apache.spark.sql.catalyst.expressions.{Attribute, CreateArray,
CreateMap, Expression, ExpressionInfo, Literal}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, CreateArray,
CreateMap, CreateNamedStruct, Expression, ExpressionInfo, Literal}
import org.apache.spark.sql.catalyst.plans.logical.{LeafNode, LogicalPlan}
import org.apache.spark.sql.catalyst.util.MapData
import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog}
@@ -46,6 +47,7 @@ object PaimonTableValuedFunctions {
val INCREMENTAL_BETWEEN_TIMESTAMP = "paimon_incremental_between_timestamp"
val INCREMENTAL_TO_AUTO_TAG = "paimon_incremental_to_auto_tag"
val VECTOR_SEARCH = "vector_search"
+ val MULTI_VECTOR_SEARCH = "multi_vector_search"
val FULL_TEXT_SEARCH = "full_text_search"
val supportedFnNames: Seq[String] =
@@ -54,6 +56,7 @@ object PaimonTableValuedFunctions {
INCREMENTAL_BETWEEN_TIMESTAMP,
INCREMENTAL_TO_AUTO_TAG,
VECTOR_SEARCH,
+ MULTI_VECTOR_SEARCH,
FULL_TEXT_SEARCH)
def parsePositiveLimit(value: Any): Int = {
@@ -85,6 +88,8 @@ object PaimonTableValuedFunctions {
FunctionRegistryBase.build[IncrementalToAutoTag](fnName, since = None)
case VECTOR_SEARCH =>
FunctionRegistryBase.build[VectorSearchQuery](fnName, since = None)
+ case MULTI_VECTOR_SEARCH =>
+ FunctionRegistryBase.build[MultiVectorSearchQuery](fnName, since =
None)
case FULL_TEXT_SEARCH =>
FunctionRegistryBase.build[FullTextSearchQuery](fnName, since = None)
case _ =>
@@ -121,6 +126,8 @@ object PaimonTableValuedFunctions {
tvf match {
case vsq: VectorSearchQuery =>
resolveVectorSearchQuery(sparkTable, sparkCatalog, ident, vsq,
args.tail)
+ case mvsq: MultiVectorSearchQuery =>
+ resolveMultiVectorSearchQuery(sparkTable, sparkCatalog, ident, mvsq,
args.tail)
case ftsq: FullTextSearchQuery =>
resolveFullTextSearchQuery(sparkTable, sparkCatalog, ident, ftsq,
args.tail)
case _ =>
@@ -160,6 +167,28 @@ object PaimonTableValuedFunctions {
}
}
+ private def resolveMultiVectorSearchQuery(
+ sparkTable: Table,
+ sparkCatalog: TableCatalog,
+ ident: Identifier,
+ mvsq: MultiVectorSearchQuery,
+ argsWithoutTable: Seq[Expression]): LogicalPlan = {
+ sparkTable match {
+ case st @ SparkTable(innerTable: InnerTable) =>
+ val multiVectorSearch = mvsq.createMultiVectorSearch(innerTable,
argsWithoutTable)
+ val multiVectorSearchTable = MultiVectorSearchTable.create(innerTable,
multiVectorSearch)
+ DataSourceV2Relation.create(
+ st.copy(table = multiVectorSearchTable),
+ Some(sparkCatalog),
+ Some(ident),
+ CaseInsensitiveStringMap.empty())
+ case _ =>
+ throw new RuntimeException(
+ "multi_vector_search only supports Paimon SparkTable backed by
InnerTable, " +
+ s"but got table implementation: ${sparkTable.getClass.getName}")
+ }
+ }
+
private def resolveFullTextSearchQuery(
sparkTable: Table,
sparkCatalog: TableCatalog,
@@ -338,7 +367,7 @@ case class VectorSearchQuery(override val args:
Seq[Expression])
new VectorSearch(queryVector, limit, columnName, options.asJava)
}
- private def extractQueryVector(expr: Expression): Array[Float] = {
+ def extractQueryVector(expr: Expression): Array[Float] = {
expr match {
case Literal(arrayData, _) if arrayData != null =>
val arr =
arrayData.asInstanceOf[org.apache.spark.sql.catalyst.util.ArrayData]
@@ -356,7 +385,7 @@ case class VectorSearchQuery(override val args:
Seq[Expression])
}
}
- private def extractOptions(expr: Expression): Map[String, String] = {
+ def extractOptions(expr: Expression): Map[String, String] = {
expr match {
case CreateMap(children, _) if children != null =>
children
@@ -410,7 +439,7 @@ case class VectorSearchQuery(override val args:
Seq[Expression])
}
}
- private def extractString(expr: Expression): String =
stringValue(expr.eval())
+ def extractString(expr: Expression): String = stringValue(expr.eval())
private def stringValue(value: Any): String = {
if (value == null) {
@@ -420,6 +449,134 @@ case class VectorSearchQuery(override val args:
Seq[Expression])
}
}
+/**
+ * Plan for the [[MULTI_VECTOR_SEARCH]] table-valued function.
+ *
+ * Usage: multi_vector_search(table_name, routes, limit[, ranker])
+ * - table_name: the Paimon table to search
+ * - routes: route config array with vector_column, query_vector, limit,
weight, and options
+ * fields
+ * - limit: the final number of ranked top results to return
+ * - ranker: optional ranker for combining results from multiple vector
columns
+ */
+case class MultiVectorSearchQuery(override val args: Seq[Expression])
+ extends PaimonTableValueFunction(MULTI_VECTOR_SEARCH) {
+
+ override def parseArgs(args: Seq[Expression]): Map[String, String] = {
+ Map.empty
+ }
+
+ def createMultiVectorSearch(
+ innerTable: InnerTable,
+ argsWithoutTable: Seq[Expression]): MultiVectorSearch = {
+ if (argsWithoutTable.size != 2 && argsWithoutTable.size != 3) {
+ throw new RuntimeException(
+ s"$MULTI_VECTOR_SEARCH needs two or three parameters after table_name:
" +
+ s"routes, limit[, ranker]. " +
+ s"Got ${argsWithoutTable.size} parameters after table_name.")
+ }
+ val finalLimit = parsePositiveLimit(argsWithoutTable(1).eval())
+ val ranker =
+ if (argsWithoutTable.size == 3) {
+ VectorSearchQuery(Seq.empty).extractString(argsWithoutTable(2))
+ } else {
+ MultiVectorSearchRanker.RRF_RANKER
+ }
+
+ val routes = extractRoutes(argsWithoutTable.head, finalLimit).map {
+ route =>
+ val columnName = route.fieldName()
+ if (!innerTable.rowType().containsField(columnName)) {
+ throw new RuntimeException(
+ s"Column $columnName does not exist in table ${innerTable.name()}")
+ }
+ route
+ }.toList
+
+ new MultiVectorSearch(routes.asJava, finalLimit, ranker)
+ }
+
+ private def extractRoutes(expr: Expression, defaultLimit: Int):
Seq[MultiVectorSearchRoute] = {
+ expr match {
+ case CreateArray(elements, _) if elements != null =>
+ elements.map(extractRoute(_, defaultLimit))
+ case _ =>
+ throw new RuntimeException(s"Cannot extract multi-vector routes from
expression: $expr")
+ }
+ }
+
+ private def extractRoute(expr: Expression, defaultLimit: Int):
MultiVectorSearchRoute = {
+ expr match {
+ case CreateNamedStruct(children) if children != null =>
+ extractConfiguredRoute(children, defaultLimit)
+ case _ =>
+ throw new RuntimeException(s"Cannot extract multi-vector route from
expression: $expr")
+ }
+ }
+
+ private def extractConfiguredRoute(
+ children: Seq[Expression],
+ defaultLimit: Int): MultiVectorSearchRoute = {
+ var columnName: Option[String] = None
+ var queryVector: Option[Array[Float]] = None
+ var limit: Option[Int] = None
+ var weight: Option[Float] = None
+ var options = Map.empty[String, String]
+
+ children.grouped(2).foreach {
+ case Seq(keyExpr, valueExpr) =>
+ VectorSearchQuery(Seq.empty).extractString(keyExpr) match {
+ case "vector_column" =>
+ columnName =
Some(VectorSearchQuery(Seq.empty).extractString(valueExpr))
+ case "query_vector" =>
+ queryVector =
Some(VectorSearchQuery(Seq.empty).extractQueryVector(valueExpr))
+ case "limit" =>
+ limit = Some(parsePositiveLimit(valueExpr.eval()))
+ case "weight" =>
+ weight = Some(parsePositiveFloat(valueExpr.eval(), "weight"))
+ case "options" =>
+ options = VectorSearchQuery(Seq.empty).extractOptions(valueExpr)
+ case key =>
+ throw new IllegalArgumentException(
+ s"Unsupported multi-vector route field '$key'. " +
+ "Supported fields are vector_column, query_vector, limit,
weight, and options.")
+ }
+ case other =>
+ throw new RuntimeException(s"Invalid route config entries: $other")
+ }
+
+ val routeColumn =
+ columnName.getOrElse(
+ throw new IllegalArgumentException("Multi-vector route must define
vector_column."))
+ new MultiVectorSearchRoute(
+ routeColumn,
+ queryVector.getOrElse(
+ throw new IllegalArgumentException(
+ s"Multi-vector route for column $routeColumn must define
query_vector.")),
+ limit.getOrElse(defaultLimit),
+ weight.getOrElse(1.0f),
+ options.asJava
+ )
+ }
+
+ private def parsePositiveFloat(value: Any, name: String): Float = {
+ val parsed = value match {
+ case f: Float => f
+ case d: Double => d.toFloat
+ case i: Int => i.toFloat
+ case l: Long => l.toFloat
+ case s: String => s.toFloat
+ case u: UTF8String => u.toString.toFloat
+ case other => throw new RuntimeException(s"Invalid $name type:
${other.getClass.getName}")
+ }
+ if (parsed <= 0) {
+ throw new IllegalArgumentException(s"$name must be positive, but got:
$parsed")
+ }
+ parsed
+ }
+
+}
+
/**
* Plan for the [[FULL_TEXT_SEARCH]] table-valued function.
*
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
index 33b5d4e176..f06807ec44 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
@@ -20,7 +20,7 @@ package org.apache.paimon.spark.read
import org.apache.paimon.CoreOptions
import org.apache.paimon.partition.PartitionPredicate
-import org.apache.paimon.predicate.{FullTextSearch, Predicate, TopN,
VectorSearch}
+import org.apache.paimon.predicate.{FullTextSearch, MultiVectorSearch,
Predicate, TopN, VectorSearch}
import org.apache.paimon.spark.{PaimonBatch, PaimonInputPartition,
PaimonNumSplitMetric, PaimonPartitionSizeMetric, PaimonReadBatchTimeMetric,
PaimonResultedTableFilesMetric, PaimonResultedTableFilesTaskMetric,
SparkTypeUtils}
import org.apache.paimon.spark.schema.PaimonMetadataColumn
import org.apache.paimon.spark.schema.PaimonMetadataColumn._
@@ -51,6 +51,7 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
def pushedLimit: Option[Int] = None
def pushedTopN: Option[TopN] = None
def pushedVectorSearch: Option[VectorSearch] = None
+ def pushedMultiVectorSearch: Option[MultiVectorSearch] = None
def pushedFullTextSearch: Option[FullTextSearch] = None
def pushedVariantExtractions: Map[Seq[String], Seq[VariantExtractionInfo]] =
Map.empty
@@ -204,6 +205,7 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
pushedTopN.map(topN => s", TopN: [$topN]").getOrElse("") +
pushedLimit.map(limit => s", Limit: [$limit]").getOrElse("") +
pushedVectorSearch.map(vs => s", VectorSearch: [$vs]").getOrElse("") +
+ pushedMultiVectorSearch.map(mvs => s", MultiVectorSearch:
[$mvs]").getOrElse("") +
pushedFullTextSearch.map(fts => s", FullTextSearch:
[$fts]").getOrElse("") +
pushedVariantsStr
}
diff --git
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
index d3f1b8d265..12775ea540 100644
---
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
+++
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/catalyst/plans/logical/VectorSearchQueryTest.scala
@@ -21,7 +21,7 @@ package org.apache.paimon.spark.catalyst.plans.logical
import org.apache.paimon.table.InnerTable
import org.apache.paimon.types.{ArrayType, DataType, DataTypes, RowType}
-import org.apache.spark.sql.catalyst.expressions.{CreateArray, CreateMap,
Expression, Literal}
+import org.apache.spark.sql.catalyst.expressions.{CreateArray, CreateMap,
CreateNamedStruct, Expression, Literal}
import org.scalatest.funsuite.AnyFunSuite
import java.lang.reflect.{InvocationHandler, Method, Proxy}
@@ -29,6 +29,95 @@ import java.lang.reflect.{InvocationHandler, Method, Proxy}
/** Tests for [[VectorSearchQuery]]. */
class VectorSearchQueryTest extends AnyFunSuite {
+ test("create multi vector search with route configs") {
+ val search = MultiVectorSearchQuery(Seq.empty).createMultiVectorSearch(
+ innerTable,
+ Seq(
+ CreateArray(
+ Seq(
+ CreateNamedStruct(Seq(
+ Literal("vector_column"),
+ Literal("title_vec"),
+ Literal("query_vector"),
+ CreateArray(Seq(Literal(1.0f), Literal(0.0f))),
+ Literal("limit"),
+ Literal(20),
+ Literal("weight"),
+ Literal(2.0f),
+ Literal("options"),
+ CreateMap(Seq(Literal("ivf.nprobe"), Literal("32")))
+ )),
+ CreateNamedStruct(Seq(
+ Literal("vector_column"),
+ Literal("body_vec"),
+ Literal("query_vector"),
+ CreateArray(Seq(Literal(0.0f), Literal(1.0f))),
+ Literal("limit"),
+ Literal(10),
+ Literal("weight"),
+ Literal(1.0f),
+ Literal("options"),
+ CreateMap(Seq(Literal("ivf.nprobe"), Literal("16")))
+ ))
+ )),
+ Literal(3),
+ Literal("weighted_score")
+ )
+ )
+
+ assert(search.ranker() == "weighted_score")
+ assert(search.routes().size() == 2)
+ assert(search.routes().get(0).limit() == 20)
+ assert(search.routes().get(0).weight() == 2.0f)
+ assert(search.routes().get(0).options().get("ivf.nprobe") == "32")
+ assert(search.routes().get(1).limit() == 10)
+ assert(search.routes().get(1).weight() == 1.0f)
+ assert(search.routes().get(1).options().get("ivf.nprobe") == "16")
+ }
+
+ test("default multi vector route limit to final limit") {
+ val search = MultiVectorSearchQuery(Seq.empty).createMultiVectorSearch(
+ innerTable,
+ Seq(
+ CreateArray(
+ Seq(
+ CreateNamedStruct(
+ Seq(
+ Literal("vector_column"),
+ Literal("title_vec"),
+ Literal("query_vector"),
+ CreateArray(Seq(Literal(1.0f), Literal(0.0f)))
+ )))),
+ Literal(7)
+ )
+ )
+
+ assert(search.limit() == 7)
+ assert(search.ranker() == "rrf")
+ assert(search.routes().get(0).limit() == 7)
+ assert(search.routes().get(0).weight() == 1.0f)
+ assert(search.routes().get(0).options().isEmpty)
+ }
+
+ test("reject multi vector search query map") {
+ val exception = intercept[RuntimeException] {
+ MultiVectorSearchQuery(Seq.empty).createMultiVectorSearch(
+ innerTable,
+ Seq(
+ CreateMap(
+ Seq(
+ Literal("title_vec"),
+ CreateArray(Seq(Literal(1.0f), Literal(0.0f))),
+ Literal("body_vec"),
+ CreateArray(Seq(Literal(0.0f), Literal(1.0f))))),
+ Literal(3)
+ )
+ )
+ }
+
+ assert(exception.getMessage.contains("Cannot extract multi-vector routes"))
+ }
+
test("create vector search with string options") {
val vectorSearch = createVectorSearch(
Literal("v"),
@@ -62,7 +151,13 @@ class VectorSearchQueryTest extends AnyFunSuite {
Array(classOf[InnerTable]),
new InvocationHandler {
private val rowType =
- RowType.of(Array[DataType](new ArrayType(DataTypes.FLOAT())),
Array[String]("v"))
+ RowType.of(
+ Array[DataType](
+ new ArrayType(DataTypes.FLOAT()),
+ new ArrayType(DataTypes.FLOAT()),
+ new ArrayType(DataTypes.FLOAT())),
+ Array[String]("v", "title_vec", "body_vec")
+ )
override def invoke(proxy: Any, method: Method, args:
Array[AnyRef]): AnyRef = {
method.getName match {
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/MultiVectorSearchTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/MultiVectorSearchTest.scala
new file mode 100644
index 0000000000..e3a3bbe8b3
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/MultiVectorSearchTest.scala
@@ -0,0 +1,85 @@
+/*
+ * 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.spark.sql
+
+import org.apache.paimon.globalindex.testvector.TestVectorGlobalIndexerFactory
+import org.apache.paimon.spark.PaimonSparkTestBase
+
+/** Tests for multi-vector search. */
+class MultiVectorSearchTest extends PaimonSparkTestBase {
+
+ test("multi vector search ranks results from multiple vector columns") {
+ withTable("T") {
+ spark.sql("""
+ |CREATE TABLE T (id INT, title_vec ARRAY<FLOAT>, body_vec
ARRAY<FLOAT>)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'global-index.row-count-per-shard' = '10000',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'test.vector.dimension' = '2',
+ | 'test.vector.required-option.key' = 'ivf.nprobe',
+ | 'test.vector.required-option.value' = '16')
+ |""".stripMargin)
+
+ spark.sql("""
+ |INSERT INTO T VALUES
+ | (0, array(1.0f, 0.0f), array(0.0f, 1.0f)),
+ | (1, array(0.9f, 0.1f), array(0.1f, 0.9f)),
+ | (2, array(0.0f, 1.0f), array(1.0f, 0.0f))
+ |""".stripMargin)
+
+ spark
+ .sql(s"CALL sys.create_global_index(table => 'test.T', index_column =>
'title_vec', " +
+ s"index_type => '${TestVectorGlobalIndexerFactory.IDENTIFIER}')")
+ .collect()
+ spark
+ .sql(s"CALL sys.create_global_index(table => 'test.T', index_column =>
'body_vec', " +
+ s"index_type => '${TestVectorGlobalIndexerFactory.IDENTIFIER}')")
+ .collect()
+
+ val result = spark
+ .sql("""
+ |SELECT id, __paimon_vector_search_score
+ |FROM multi_vector_search(
+ | 'T',
+ | array(
+ | named_struct(
+ | 'vector_column', 'title_vec',
+ | 'query_vector', array(1.0f, 0.0f),
+ | 'limit', 2,
+ | 'weight', 2.0f,
+ | 'options', map('ivf.nprobe', '16')),
+ | named_struct(
+ | 'vector_column', 'body_vec',
+ | 'query_vector', array(0.0f, 1.0f),
+ | 'limit', 2,
+ | 'weight', 1.0f,
+ | 'options', map('ivf.nprobe', '16'))),
+ | 2,
+ | 'weighted_score')
+ |""".stripMargin)
+ .collect()
+
+ assert(result.length == 2)
+ assert(result.map(_.getInt(0)).contains(1))
+ assert(result.forall(row => !row.isNullAt(1)))
+ }
+ }
+}