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 b09282e7e1 [spark] support get row id in vector search (#8475)
b09282e7e1 is described below
commit b09282e7e11379f8199630073577bbabb921c96c
Author: Stefanietry <[email protected]>
AuthorDate: Mon Jul 6 18:14:34 2026 +0800
[spark] support get row id in vector search (#8475)
Purpose: Support get _row_id when process vector search
Linked issue: https://github.com/apache/paimon/issues/8474
---
.../apache/paimon/reader/ScoreRecordIterator.java | 12 ++++
.../globalindex/IndexedSplitRecordReader.java | 14 ++++-
.../paimon/spark/PaimonRecordReaderIterator.scala | 17 ++++--
.../plans/logical/PaimonTableValuedFunctions.scala | 6 +-
.../paimon/spark/execution/PaimonStrategy.scala | 12 ++--
.../apache/paimon/spark/SparkMultimodalITCase.java | 69 +++++++++++++++++++++-
6 files changed, 109 insertions(+), 21 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordIterator.java
b/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordIterator.java
index 908f35404e..10e4548141 100644
---
a/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordIterator.java
+++
b/paimon-common/src/main/java/org/apache/paimon/reader/ScoreRecordIterator.java
@@ -30,6 +30,8 @@ public interface ScoreRecordIterator<T> extends
RecordReader.RecordIterator<T> {
float returnedScore();
+ long returnedRowId();
+
@Override
default <R> ScoreRecordIterator<R> transform(Function<T, R> function) {
ScoreRecordIterator<T> thisIterator = this;
@@ -39,6 +41,11 @@ public interface ScoreRecordIterator<T> extends
RecordReader.RecordIterator<T> {
return thisIterator.returnedScore();
}
+ @Override
+ public long returnedRowId() {
+ return thisIterator.returnedRowId();
+ }
+
@Nullable
@Override
public R next() throws IOException {
@@ -65,6 +72,11 @@ public interface ScoreRecordIterator<T> extends
RecordReader.RecordIterator<T> {
return thisIterator.returnedScore();
}
+ @Override
+ public long returnedRowId() {
+ return thisIterator.returnedRowId();
+ }
+
@Nullable
@Override
public T next() throws IOException {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
b/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
index b57e2c5403..3778f37cb9 100644
---
a/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
+++
b/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplitRecordReader.java
@@ -59,18 +59,26 @@ public class IndexedSplitRecordReader implements
RecordReader<InternalRow> {
return new ScoreRecordIterator<InternalRow>() {
private float score = Float.NaN;
+ private long rowId;
@Override
public float returnedScore() {
return score;
}
+ @Override
+ public long returnedRowId() {
+ return rowId;
+ }
+
@Override
public InternalRow next() throws IOException {
InternalRow row = iterator.next();
- if (row != null && rowIdToScore != null) {
- Long rowId = row.getLong(rowIdIndex);
- this.score = rowIdToScore.get(rowId);
+ if (row != null && rowIdIndex >= 0) {
+ this.rowId = row.getLong(rowIdIndex);
+ if (rowIdToScore != null) {
+ this.score = rowIdToScore.get(rowId);
+ }
if (projectedRow != null) {
projectedRow.replaceRow(row);
return projectedRow;
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
index ac06698fe9..706715f7ed 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
@@ -20,9 +20,10 @@ package org.apache.paimon.spark
import org.apache.paimon.data.{BinaryString, GenericRow, InternalRow =>
PaimonInternalRow, JoinedRow}
import org.apache.paimon.fs.Path
+import org.apache.paimon.globalindex.IndexedSplitRecordReader
import org.apache.paimon.reader.{FileRecordIterator, RecordReader,
ScoreRecordIterator}
import org.apache.paimon.spark.schema.PaimonMetadataColumn
-import
org.apache.paimon.spark.schema.PaimonMetadataColumn.{PARTITION_AND_BUCKET_META_COLUMNS,
PATH_AND_INDEX_META_COLUMNS, SEARCH_SCORE_COLUMN}
+import
org.apache.paimon.spark.schema.PaimonMetadataColumn.{PARTITION_AND_BUCKET_META_COLUMNS,
PATH_AND_INDEX_META_COLUMNS, ROW_ID_COLUMN, SEARCH_SCORE_COLUMN}
import org.apache.paimon.table.source.{DataSplit, Split}
import org.apache.paimon.utils.{CloseableIterator, Preconditions}
@@ -48,10 +49,12 @@ case class PaimonRecordReaderIterator(
private val needMetadata = metadataColumns.nonEmpty
private val needPathAndIndexMetadata =
metadataColumns.exists(c => PATH_AND_INDEX_META_COLUMNS.contains(c.name))
- private val needScoreMetadata = {
- metadataColumns.exists(_.name == SEARCH_SCORE_COLUMN)
- }
- Preconditions.checkArgument(!needScoreMetadata || metadataColumns.size == 1)
+ private val needVectorSearchMetadata =
reader.isInstanceOf[IndexedSplitRecordReader] &&
+ metadataColumns.exists(c => c.name == ROW_ID_COLUMN || c.name ==
SEARCH_SCORE_COLUMN)
+
+ Preconditions.checkArgument(
+ !needVectorSearchMetadata ||
+ metadataColumns.forall(c => c.name == ROW_ID_COLUMN || c.name ==
SEARCH_SCORE_COLUMN))
private val metadataRow: GenericRow =
GenericRow.of(Array.fill(metadataColumns.size)(null.asInstanceOf[AnyRef]):
_*)
@@ -126,7 +129,7 @@ case class PaimonRecordReaderIterator(
while (!stop) {
val dataRow = currentIterator.next()
if (dataRow != null) {
- if (needScoreMetadata) {
+ if (needVectorSearchMetadata) {
updateScoreMetadata(
currentIterator.asInstanceOf[ScoreRecordIterator[PaimonInternalRow]])
currentResult = joinedRow.replace(dataRow, metadataRow)
@@ -179,6 +182,8 @@ case class PaimonRecordReaderIterator(
metadataColumns.zipWithIndex.foreach {
case (metadataColumn, index) =>
metadataColumn.name match {
+ case PaimonMetadataColumn.ROW_ID_COLUMN =>
+ metadataRow.setField(index, fileRecordIterator.returnedRowId())
case PaimonMetadataColumn.SEARCH_SCORE_COLUMN =>
metadataRow.setField(index, fileRecordIterator.returnedScore())
}
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 7c24bdd5f0..d3d9194150 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
@@ -844,11 +844,11 @@ case class DynamicVectorSearchRelation(
relationOutput: Seq[Attribute])
extends LeafNode {
- private lazy val outputWithScore: Seq[Attribute] =
+ private lazy val outputWithMetaFields: Seq[Attribute] =
relationOutput ++
- Seq(PaimonMetadataColumn.SEARCH_SCORE.toAttribute)
+ Seq(PaimonMetadataColumn.ROW_ID.toAttribute,
PaimonMetadataColumn.SEARCH_SCORE.toAttribute)
- override def output: Seq[Attribute] = outputWithScore
+ override def output: Seq[Attribute] = outputWithMetaFields
}
case class LateralVectorSearch(
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
index a559fe4ada..43e2e11264 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
@@ -315,14 +315,12 @@ case class LateralVectorSearchExec(
readerTracker: LateralVectorSearchReaderTracker):
LateralVectorSearchContext = {
val rowType = innerTable.rowType()
val readFieldNames = vectorSearchOutput
- .filterNot(_.name == PaimonMetadataColumn.SEARCH_SCORE_COLUMN)
+ .filterNot(
+ attr =>
+ attr.name == PaimonMetadataColumn.SEARCH_SCORE_COLUMN ||
+ attr.name == SpecialFields.ROW_ID.name())
.map(_.name)
- val readFieldNamesWithRowId =
- if (readFieldNames.contains(SpecialFields.ROW_ID.name())) {
- readFieldNames
- } else {
- readFieldNames :+ SpecialFields.ROW_ID.name()
- }
+ val readFieldNamesWithRowId = readFieldNames :+ SpecialFields.ROW_ID.name()
val rowTypeWithRowId = SpecialFields.rowTypeWithRowId(rowType)
val readRowType = rowType.project(readFieldNames.asJava)
val readRowTypeWithRowId = SpecialFields.rowTypeWithRowId(readRowType)
diff --git
a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkMultimodalITCase.java
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkMultimodalITCase.java
index 3a4dd41b16..340122a12a 100644
---
a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkMultimodalITCase.java
+++
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkMultimodalITCase.java
@@ -32,6 +32,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -143,19 +144,68 @@ public class SparkMultimodalITCase {
"select gid, sid, embs from my_db1.vector_test
where date = '20260420' and embs is not null;")
.collectAsList();
assertThat(rows).hasSize(8);
+ Map<Long, Long> baseRowIds =
+ spark.sql("select gid, _row_id from my_db1.vector_test where
date = '20260420'")
+ .collectAsList().stream()
+ .collect(Collectors.toMap(row -> row.getLong(0), row
-> row.getLong(1)));
+ assertThat(baseRowIds).hasSize(8);
rows =
spark.sql(
"select gid, sid, embs from
vector_search('my_db1.vector_test', 'embs', array(1.0f, 2.0f, 3.0f, 4.0f), 5)
where date = '20260420'")
.collectAsList();
assertThat(rows).hasSize(5);
+
+ // **vector search with row id */
+ String vectorSearchWithRowIdSql =
+ "select gid, sid, embs, _row_id AS _row_id "
+ + "from vector_search('my_db1.vector_test', 'embs',
array(1.0f, 2.0f, 3.0f, 4.0f), 5) "
+ + "where date = '20260420'";
+ Dataset<Row> df = spark.sql(vectorSearchWithRowIdSql);
+ assertThat(df.columns()).hasSize(4);
+ assertThat(df.columns()).contains("_row_id");
+ rows = df.collectAsList();
+ assertThat(rows).hasSize(5);
+ assertThat(rows.stream().noneMatch(row -> row.isNullAt(3))).isTrue();
+ assertThat(
+ rows.stream()
+ .allMatch(
+ row ->
+ baseRowIds
+ .get(row.getLong(0))
+
.equals(row.getLong(3))))
+ .isTrue();
+
+ // **vector search with row id and score */
+ String vectorSearchWithRowIdAndScoreSql =
+ "select gid, sid, embs, _row_id AS _row_id,
__paimon_search_score "
+ + "from vector_search('my_db1.vector_test', 'embs',
array(1.0f, 2.0f, 3.0f, 4.0f), 5) "
+ + "where date = '20260420'";
+ df = spark.sql(vectorSearchWithRowIdAndScoreSql);
+ assertThat(df.columns()).hasSize(5);
+ assertThat(df.columns()).contains("_row_id", "__paimon_search_score");
+ rows = df.collectAsList();
+ assertThat(rows).hasSize(5);
+ assertThat(rows.stream().allMatch(row -> !row.isNullAt(3) &&
!row.isNullAt(4))).isTrue();
+ assertThat(
+ rows.stream()
+ .allMatch(
+ row ->
+ baseRowIds
+ .get(row.getLong(0))
+
.equals(row.getLong(3))))
+ .isTrue();
+
+ // **vector search with score */
String vectorSearchSql =
"select gid, sid, embs, __paimon_search_score "
+ "from vector_search('my_db1.vector_test', 'embs',
array(1.0f, 2.0f, 3.0f, 4.0f), 5) "
+ "where date = '20260420'";
- Dataset<Row> df = spark.sql(vectorSearchSql);
+ df = spark.sql(vectorSearchSql);
assertThat(df.columns()).hasSize(4);
rows = df.collectAsList();
assertThat(rows).hasSize(5);
+
+ // ** distribute vector search */
spark.sql("SET
`spark.paimon.vector-search.distribute.enabled`=`true`");
spark.sql("SET `spark.paimon.global-index.thread-num`=`1`");
List<Row> compareRows = spark.sql(vectorSearchSql).collectAsList();
@@ -170,13 +220,28 @@ public class SparkMultimodalITCase {
.collect(Collectors.toList()));
spark.close();
+ // ** lateral vector search */
spark = builder.getOrCreate();
spark.sql("SET
`spark.paimon.vector-search.distribute.enabled`=`false`");
rows =
spark.sql(
- "SELECT q.gid AS query_gid, q.embs AS
query_embs, r.gid AS result_gid FROM my_db1.vector_test AS q, LATERAL (SELECT
gid FROM vector_search('my_db1.vector_test', 'embs', q.embs, 5)) AS r WHERE
q.`date` = '20260420';")
+ "SELECT q.gid AS query_gid, q.embs AS
query_embs, "
+ + "r.gid AS result_gid, r._row_id AS
result_row_id "
+ + "FROM my_db1.vector_test AS q, "
+ + "LATERAL (SELECT gid, _row_id "
+ + "FROM
vector_search('my_db1.vector_test', 'embs', q.embs, 5)) AS r "
+ + "WHERE q.`date` = '20260420';")
.collectAsList();
assertThat(rows).hasSize(40);
+ assertThat(rows.stream().noneMatch(row -> row.isNullAt(3))).isTrue();
+ assertThat(
+ rows.stream()
+ .allMatch(
+ row ->
+ baseRowIds
+ .get(row.getLong(2))
+
.equals(row.getLong(3))))
+ .isTrue();
assertThat(
rows.stream()
.collect(