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 ffaebaec51 [spark] support lateral inner join for vector search (#8252)
ffaebaec51 is described below

commit ffaebaec51f12b4f5e2b2064512d834c2742cc43
Author: Stefanietry <[email protected]>
AuthorDate: Wed Jun 24 08:50:06 2026 +0800

    [spark] support lateral inner join for vector search (#8252)
---
 docs/generated/core_configuration.html             |  18 +-
 .../main/java/org/apache/paimon/CoreOptions.java   |  12 +
 .../PaimonIncompatibleResolutionRules.scala        |   9 +
 .../PushDownLateralVectorSearchFilter.scala        |  72 ++++
 .../plans/logical/PaimonTableValuedFunctions.scala | 262 +++++++++++++-
 .../paimon/spark/execution/PaimonStrategy.scala    | 387 ++++++++++++++++++++-
 .../extensions/PaimonSparkSessionExtensions.scala  |   3 +-
 .../apache/paimon/spark/SparkMultimodalITCase.java |  23 ++
 .../spark/sql/TableValuedFunctionsTest.scala       | 142 +++++++-
 9 files changed, 911 insertions(+), 17 deletions(-)

diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index f525a00b64..1e82e01041 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1668,6 +1668,12 @@ If the data size allocated for the sorting task is 
uneven,which may lead to perf
             <td>Boolean</td>
             <td>Whether to process distributed vector search.</td>
         </tr>
+        <tr>
+            <td><h5>vector-search.lateral-join.batch-size</h5></td>
+            <td style="word-wrap: break-word;">256</td>
+            <td>Integer</td>
+            <td>The batch size for lateral vector search. Each batch executes 
vector topK search and table lookup for multiple query vectors.</td>
+        </tr>
         <tr>
             <td><h5>vector.file.format</h5></td>
             <td style="word-wrap: break-word;">(none)</td>
@@ -1734,12 +1740,6 @@ If the data size allocated for the sorting task is 
uneven,which may lead to perf
             <td>Boolean</td>
             <td>If set to true, compactions and snapshot expiration will be 
skipped. This option is used along with dedicated compact jobs.</td>
         </tr>
-        <tr>
-            <td><h5>write.sequence-number-init-mode</h5></td>
-            <td style="word-wrap: break-word;">scan</td>
-            <td><p>Enum</p></td>
-            <td>Specify how to initialize the next sequence number for primary 
key table writers.<br /><br />Possible values:<ul><li>"scan": initialize by 
scanning existing file metadata.</li><li>"snapshot": initialize from the 
maximum sequence number recorded in snapshot properties, which can avoid 
scanning existing file metadata in write-only mode.</li></ul></td>
-        </tr>
         <tr>
             <td><h5>write.batch-memory</h5></td>
             <td style="word-wrap: break-word;">128 mb</td>
@@ -1752,6 +1752,12 @@ If the data size allocated for the sorting task is 
uneven,which may lead to perf
             <td>Integer</td>
             <td>Write batch size for any file format if it supports.</td>
         </tr>
+        <tr>
+            <td><h5>write.sequence-number-init-mode</h5></td>
+            <td style="word-wrap: break-word;">scan</td>
+            <td><p>Enum</p></td>
+            <td>Specify how to initialize the next sequence number for primary 
key table writers.<br /><br />Possible values:<ul><li>"scan": initialize by 
scanning existing file metadata.</li><li>"snapshot": initialize from the 
maximum sequence number recorded in snapshot properties, which can avoid 
scanning existing file metadata in write-only mode.</li></ul></td>
+        </tr>
         <tr>
             <td><h5>zorder.var-length-contribution</h5></td>
             <td style="word-wrap: break-word;">8</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 5eb86a56bc..8ca39caf66 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2658,6 +2658,14 @@ public class CoreOptions implements Serializable {
                     .defaultValue(false)
                     .withDescription("Whether to process distributed vector 
search.");
 
+    public static final ConfigOption<Integer> 
VECTOR_SEARCH_LATERAL_JOIN_BATCH_SIZE =
+            key("vector-search.lateral-join.batch-size")
+                    .intType()
+                    .defaultValue(256)
+                    .withDescription(
+                            "The batch size for lateral vector search. Each 
batch executes vector "
+                                    + "topK search and table lookup for 
multiple query vectors.");
+
     @Immutable
     public static final ConfigOption<Boolean> PK_CLUSTERING_OVERRIDE =
             key("pk-clustering-override")
@@ -4155,6 +4163,10 @@ public class CoreOptions implements Serializable {
         return options.get(VECTOR_SEARCH_DISTRIBUTE_ENABLED);
     }
 
+    public int vectorSearchLateralJoinBatchSize() {
+        return options.get(VECTOR_SEARCH_LATERAL_JOIN_BATCH_SIZE);
+    }
+
     /** Specifies the merge engine for table with primary key. */
     public enum MergeEngine implements DescribedEnum {
         DEDUPLICATE("deduplicate", "De-duplicate and keep the last row."),
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonIncompatibleResolutionRules.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonIncompatibleResolutionRules.scala
index 9824597c1e..a56116545a 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonIncompatibleResolutionRules.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonIncompatibleResolutionRules.scala
@@ -21,6 +21,8 @@ package org.apache.paimon.spark.catalyst.analysis
 import 
org.apache.paimon.spark.catalyst.plans.logical.{PaimonTableValuedFunctions, 
PaimonTableValueFunction}
 
 import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.expressions.LateralSubquery
+import org.apache.spark.sql.catalyst.plans.logical.LateralJoin
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.catalyst.rules.Rule
 
@@ -32,6 +34,13 @@ case class PaimonIncompatibleResolutionRules(session: 
SparkSession) extends Rule
     case func: PaimonTableValueFunction if func.args.forall(_.resolved) =>
       PaimonTableValuedFunctions.resolvePaimonTableValuedFunction(session, 
func)
 
+    case lateralJoin @ LateralJoin(left, lateralSubquery: LateralSubquery, 
joinType, condition)
+        if left.resolved && lateralSubquery.plan.resolved &&
+          
PaimonTableValuedFunctions.containsDynamicVectorSearch(lateralSubquery.plan) =>
+      PaimonTableValuedFunctions
+        .resolveLateralVectorSearch(left, lateralSubquery.plan, joinType, 
condition)
+        .getOrElse(lateralJoin)
+
   }
 
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownLateralVectorSearchFilter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownLateralVectorSearchFilter.scala
new file mode 100644
index 0000000000..de5baf0b35
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownLateralVectorSearchFilter.scala
@@ -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.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.catalyst.plans.logical.{LateralVectorSearch, 
PaimonTableValuedFunctions}
+
+import org.apache.spark.sql.catalyst.expressions.{And, PredicateHelper}
+import org.apache.spark.sql.catalyst.plans.logical.{Filter, LogicalPlan}
+import org.apache.spark.sql.catalyst.rules.Rule
+
+/** Pushes filters on the query side below lateral vector search. */
+object PushDownLateralVectorSearchFilter extends Rule[LogicalPlan] with 
PredicateHelper {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = plan.transform {
+    case filter @ Filter(condition, lvs: LateralVectorSearch) =>
+      val predicates = splitConjunctivePredicates(condition)
+      val (pushDownToLeft, otherPredicates) = predicates.partition {
+        predicate => predicate.deterministic && 
predicate.references.subsetOf(lvs.child.outputSet)
+      }
+      val (pushDownToSearch, stayUp) = otherPredicates.partition {
+        predicate =>
+          predicate.deterministic &&
+          predicate.references.nonEmpty &&
+          predicate.references.subsetOf(lvs.searchFilterOutputSet) &&
+          PaimonTableValuedFunctions
+            .convertLateralVectorSearchFilters(
+              lvs.innerTable,
+              lvs.vectorSearchOutput,
+              lvs.projectList,
+              lvs.projectOutput,
+              Seq(predicate))
+            .isDefined
+      }
+
+      if (pushDownToLeft.isEmpty && pushDownToSearch.isEmpty) {
+        filter
+      } else {
+        val lvsWithPushedLeft = if (pushDownToLeft.isEmpty) {
+          lvs
+        } else {
+          lvs.copy(left = Filter(buildBalancedPredicate(pushDownToLeft, And), 
lvs.child))
+        }
+        val newLateralVectorSearch = if (pushDownToSearch.isEmpty) {
+          lvsWithPushedLeft
+        } else {
+          lvsWithPushedLeft.copy(
+            searchFilters = lvsWithPushedLeft.searchFilters ++ 
pushDownToSearch)
+        }
+        if (stayUp.isEmpty) {
+          newLateralVectorSearch
+        } else {
+          Filter(buildBalancedPredicate(stayUp, And), newLateralVectorSearch)
+        }
+      }
+  }
+}
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 7d41d7f1b5..ad3675f369 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
@@ -20,19 +20,21 @@ package org.apache.paimon.spark.catalyst.plans.logical
 
 import org.apache.paimon.CoreOptions
 import org.apache.paimon.globalindex.HybridSearchRanker
-import org.apache.paimon.predicate.{FullTextQuery, FullTextSearch, 
HybridSearch, HybridSearchRoute, VectorSearch}
-import org.apache.paimon.spark.SparkTable
+import org.apache.paimon.predicate.{FullTextQuery, FullTextSearch, 
HybridSearch, HybridSearchRoute, Predicate, VectorSearch}
+import org.apache.paimon.spark.{SparkTable, SparkTypeUtils, 
SparkV2FilterConverter}
 import 
org.apache.paimon.spark.catalyst.plans.logical.PaimonTableValuedFunctions._
+import org.apache.paimon.spark.schema.PaimonMetadataColumn
 import org.apache.paimon.table.{DataTable, FullTextSearchTable, 
HybridSearchTable, InnerTable, VectorSearchTable}
 import 
org.apache.paimon.table.source.snapshot.TimeTravelUtil.InconsistentTagBucketException
 
-import org.apache.spark.sql.PaimonUtils.createDataset
+import org.apache.spark.sql.PaimonUtils.{createDataset, normalizeExprs, 
toAttributes, translateFilterV2}
 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, CreateNamedStruct, Expression, ExpressionInfo, Literal}
-import org.apache.spark.sql.catalyst.plans.logical.{LeafNode, LogicalPlan}
+import org.apache.spark.sql.catalyst.expressions.{Alias, And, Attribute, 
AttributeSet, CreateArray, CreateMap, CreateNamedStruct, Expression, 
ExpressionInfo, ExprId, Literal, OuterReference}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType}
+import org.apache.spark.sql.catalyst.plans.logical.{Filter, LeafNode, 
LogicalPlan, Project, SubqueryAlias, UnaryNode}
 import org.apache.spark.sql.catalyst.util.MapData
 import org.apache.spark.sql.connector.catalog.{Identifier, Table, TableCatalog}
 import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
@@ -40,9 +42,17 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.unsafe.types.UTF8String
 
 import scala.collection.JavaConverters._
+import scala.collection.mutable.ArrayBuffer
+import scala.util.control.NonFatal
 
 object PaimonTableValuedFunctions {
 
+  private case class DynamicVectorSearchExtraction(
+      relation: DynamicVectorSearchRelation,
+      projectList: Seq[Expression],
+      projectOutput: Seq[Attribute],
+      searchFilters: Seq[Expression])
+
   val INCREMENTAL_QUERY = "paimon_incremental_query"
   val INCREMENTAL_BETWEEN_TIMESTAMP = "paimon_incremental_between_timestamp"
   val INCREMENTAL_TO_AUTO_TAG = "paimon_incremental_to_auto_tag"
@@ -153,6 +163,9 @@ object PaimonTableValuedFunctions {
       argsWithoutTable: Seq[Expression]): LogicalPlan = {
     sparkTable match {
       case st @ SparkTable(innerTable: InnerTable) =>
+        if (vsq.hasOuterReference(argsWithoutTable)) {
+          return vsq.createDynamicVectorSearch(innerTable, argsWithoutTable)
+        }
         val vectorSearch = vsq.createVectorSearch(innerTable, argsWithoutTable)
         val vectorSearchTable = VectorSearchTable.create(innerTable, 
vectorSearch)
         DataSourceV2Relation.create(
@@ -189,6 +202,138 @@ object PaimonTableValuedFunctions {
     }
   }
 
+  def resolveLateralVectorSearch(
+      left: LogicalPlan,
+      right: LogicalPlan,
+      joinType: JoinType,
+      condition: Option[Expression]): Option[LogicalPlan] = {
+    extractDynamicVectorSearch(right) match {
+      case None if containsDynamicVectorSearch(right) =>
+        throw new UnsupportedOperationException(
+          "LATERAL vector_search only supports SELECT <columns> FROM 
vector_search(...) " +
+            "or SELECT <columns> FROM vector_search(...) WHERE <searched-table 
predicate>.")
+      case None =>
+        None
+      case Some(_) if joinType != Inner =>
+        throw new RuntimeException(
+          s"LATERAL vector_search only supports INNER join, but got: 
$joinType.")
+      case Some(
+            DynamicVectorSearchExtraction(relation, projectList, 
projectOutput, searchFilters)) =>
+        val vectorSearchOutput = vectorSearchOutputForProject(relation, 
projectList, searchFilters)
+        if (
+          searchFilters.nonEmpty && convertLateralVectorSearchFilters(
+            relation.innerTable,
+            vectorSearchOutput,
+            projectList,
+            projectOutput,
+            searchFilters).isEmpty
+        ) {
+          throw new UnsupportedOperationException(
+            "LATERAL vector_search only supports deterministic subquery 
predicates " +
+              "convertible to Paimon predicates on searched-table columns.")
+        }
+        val lateralVectorSearch =
+          LateralVectorSearch(
+            left,
+            relation.innerTable,
+            relation.columnName,
+            relation.queryVectorExpr,
+            relation.limit,
+            relation.options,
+            vectorSearchOutput,
+            projectList,
+            projectOutput,
+            searchFilters
+          )
+        Some(condition.map(Filter(_, 
lateralVectorSearch)).getOrElse(lateralVectorSearch))
+    }
+  }
+
+  def convertLateralVectorSearchFilters(
+      innerTable: InnerTable,
+      vectorSearchOutput: Seq[Attribute],
+      projectList: Seq[Expression],
+      projectOutput: Seq[Attribute],
+      filters: Seq[Expression]): Option[Seq[Predicate]] = {
+    val converter = SparkV2FilterConverter(innerTable.rowType())
+    val projectionByExprId = projectList
+      .zip(projectOutput)
+      .map { case (project, outputAttr) => outputAttr.exprId -> 
stripAlias(project) }
+      .toMap
+    try {
+      val predicates = ArrayBuffer[Predicate]()
+      normalizeExprs(filters.map(rewriteSearchFilter(_, projectionByExprId)), 
vectorSearchOutput)
+        .flatMap(splitConjunctivePredicatesForFilter)
+        .foreach {
+          filter =>
+            translateFilterV2(filter).flatMap(converter.convert(_)) match {
+              case Some(predicate) => predicates += predicate
+              case None => return None
+            }
+        }
+      Some(predicates.toSeq)
+    } catch {
+      case NonFatal(_) => None
+    }
+  }
+
+  private def rewriteSearchFilter(
+      filter: Expression,
+      projectionByExprId: Map[ExprId, Expression]): Expression = {
+    filter.transform { case attr: Attribute => 
projectionByExprId.getOrElse(attr.exprId, attr) }
+  }
+
+  private def stripAlias(expression: Expression): Expression = {
+    expression match {
+      case Alias(child, _) => child
+      case other => other
+    }
+  }
+
+  private def splitConjunctivePredicatesForFilter(condition: Expression): 
Seq[Expression] = {
+    condition match {
+      case And(left, right) =>
+        splitConjunctivePredicatesForFilter(left) ++ 
splitConjunctivePredicatesForFilter(right)
+      case other => other :: Nil
+    }
+  }
+
+  private def vectorSearchOutputForProject(
+      relation: DynamicVectorSearchRelation,
+      projectList: Seq[Expression],
+      searchFilters: Seq[Expression]): Seq[Attribute] = {
+    val projectReferences =
+      AttributeSet.fromAttributeSets((projectList ++ 
searchFilters).map(_.references))
+    relation.output.filter(projectReferences.contains)
+  }
+
+  private def extractDynamicVectorSearch(
+      plan: LogicalPlan): Option[DynamicVectorSearchExtraction] = {
+    plan match {
+      case SubqueryAlias(_, child) =>
+        extractDynamicVectorSearch(child).map {
+          extraction => extraction.copy(projectOutput = plan.output)
+        }
+      case Project(projectList, Filter(condition, relation: 
DynamicVectorSearchRelation))
+          if projectList.forall(_.resolved) && condition.resolved =>
+        Some(DynamicVectorSearchExtraction(relation, projectList, plan.output, 
Seq(condition)))
+      case Project(projectList, relation: DynamicVectorSearchRelation)
+          if projectList.forall(_.resolved) =>
+        Some(DynamicVectorSearchExtraction(relation, projectList, plan.output, 
Nil))
+      case Filter(condition, relation: DynamicVectorSearchRelation) if 
condition.resolved =>
+        Some(
+          DynamicVectorSearchExtraction(relation, relation.output, 
relation.output, Seq(condition)))
+      case relation: DynamicVectorSearchRelation =>
+        Some(DynamicVectorSearchExtraction(relation, relation.output, 
relation.output, Nil))
+      case _ => None
+    }
+  }
+
+  def containsDynamicVectorSearch(plan: LogicalPlan): Boolean = {
+    plan.isInstanceOf[DynamicVectorSearchRelation] || plan.children.exists(
+      containsDynamicVectorSearch)
+  }
+
   private def resolveFullTextSearchQuery(
       sparkTable: Table,
       sparkCatalog: TableCatalog,
@@ -447,6 +592,48 @@ case class VectorSearchQuery(override val args: 
Seq[Expression])
     }
     value.toString
   }
+
+  def hasOuterReference(argsWithoutTable: Seq[Expression]): Boolean = {
+    val queryVector = argsWithoutTable(1)
+    (argsWithoutTable.size == 3 || argsWithoutTable.size == 4) &&
+    (queryVector.references.nonEmpty || containsOuterReference(queryVector))
+  }
+
+  private def containsOuterReference(expr: Expression): Boolean = {
+    expr.isInstanceOf[OuterReference] || 
expr.children.exists(containsOuterReference)
+  }
+
+  def createDynamicVectorSearch(
+      innerTable: InnerTable,
+      argsWithoutTable: Seq[Expression]): DynamicVectorSearchRelation = {
+    if (argsWithoutTable.size != 3 && argsWithoutTable.size != 4) {
+      throw new RuntimeException(
+        s"$VECTOR_SEARCH needs three or four parameters after table_name: " +
+          s"column_name, query_vector, limit[, options]. " +
+          s"Got ${argsWithoutTable.size} parameters after table_name."
+      )
+    }
+    val columnName = argsWithoutTable.head.eval().toString
+    if (!innerTable.rowType().containsField(columnName)) {
+      throw new RuntimeException(
+        s"Column $columnName does not exist in table ${innerTable.name()}"
+      )
+    }
+    val limit = parsePositiveLimit(argsWithoutTable(2).eval())
+    val options: Map[String, String] =
+      if (argsWithoutTable.size == 4) {
+        extractOptions(argsWithoutTable(3))
+      } else {
+        Map.empty[String, String]
+      }
+    DynamicVectorSearchRelation(
+      innerTable,
+      columnName,
+      argsWithoutTable(1),
+      limit,
+      options,
+      toAttributes(SparkTypeUtils.fromPaimonRowType(innerTable.rowType())))
+  }
 }
 
 /**
@@ -637,6 +824,71 @@ case class HybridSearchQuery(override val args: 
Seq[Expression])
 
 }
 
+case class DynamicVectorSearchRelation(
+    innerTable: InnerTable,
+    columnName: String,
+    queryVectorExpr: Expression,
+    limit: Int,
+    options: Map[String, String],
+    relationOutput: Seq[Attribute])
+  extends LeafNode {
+
+  private lazy val outputWithScore: Seq[Attribute] =
+    relationOutput ++
+      Seq(PaimonMetadataColumn.SEARCH_SCORE.toAttribute)
+
+  override def output: Seq[Attribute] = outputWithScore
+}
+
+case class LateralVectorSearch(
+    left: LogicalPlan,
+    innerTable: InnerTable,
+    columnName: String,
+    queryVectorExpr: Expression,
+    limit: Int,
+    options: Map[String, String],
+    vectorSearchOutput: Seq[Attribute],
+    projectList: Seq[Expression],
+    projectOutput: Seq[Attribute],
+    searchFilters: Seq[Expression] = Nil)
+  extends UnaryNode {
+
+  override def child: LogicalPlan = left
+
+  override def output: Seq[Attribute] = left.output ++ projectOutput
+
+  lazy val searchFilterOutputSet: AttributeSet = {
+    val tableOutputSet = AttributeSet(
+      vectorSearchOutput.filterNot(_.name == 
PaimonMetadataColumn.SEARCH_SCORE_COLUMN))
+    AttributeSet(projectList.zip(projectOutput).collect {
+      case (expr, attr) if isSearchFilterAttribute(expr, tableOutputSet) =>
+        attr
+    })
+  }
+
+  private def isSearchFilterAttribute(
+      expression: Expression,
+      tableOutputSet: AttributeSet): Boolean = {
+    expression match {
+      case Alias(attr: Attribute, _) => tableOutputSet.contains(attr)
+      case attr: Attribute => tableOutputSet.contains(attr)
+      case _ => false
+    }
+  }
+
+  override lazy val producedAttributes: AttributeSet = {
+    AttributeSet(vectorSearchOutput ++ output.filterNot(attr => 
inputSet.contains(attr)))
+  }
+
+  override lazy val references: AttributeSet = {
+    AttributeSet.fromAttributeSets(expressions.map(_.references)) -- 
producedAttributes
+  }
+
+  override protected def withNewChildInternal(newChild: LogicalPlan): 
LogicalPlan = {
+    copy(left = newChild)
+  }
+}
+
 /**
  * Plan for the [[FULL_TEXT_SEARCH]] table-valued function.
  *
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 321e61f2cb..d6eeee11db 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
@@ -18,18 +18,30 @@
 
 package org.apache.paimon.spark.execution
 
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.globalindex.{GlobalIndexResult, 
ScoredGlobalIndexResult}
 import org.apache.paimon.partition.PartitionPredicate
-import org.apache.paimon.spark.{SparkCatalog, SparkGenericCatalog, SparkTable, 
SparkUtils}
+import 
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates
+import org.apache.paimon.predicate.{Predicate, PredicateBuilder}
+import org.apache.paimon.spark.{PaimonRecordReaderIterator, SparkCatalog, 
SparkGenericCatalog, SparkTable, SparkUtils}
 import org.apache.paimon.spark.catalog.{SparkBaseCatalog, SupportView}
 import org.apache.paimon.spark.catalyst.analysis.ResolvedPaimonView
-import 
org.apache.paimon.spark.catalyst.plans.logical.{CopyIntoLocationCommand, 
CopyIntoLocationSource, CopyIntoTableCommand, CreateOrReplaceTagCommand, 
CreatePaimonView, DeleteTagCommand, DropPaimonView, PaimonCallCommand, 
PaimonDropPartitions, RenameTagCommand, ResolvedIdentifier, ShowPaimonViews, 
ShowTagsCommand, TruncatePaimonTableWithFilter}
-import org.apache.paimon.table.Table
+import 
org.apache.paimon.spark.catalyst.plans.logical.{CopyIntoLocationCommand, 
CopyIntoLocationSource, CopyIntoTableCommand, CreateOrReplaceTagCommand, 
CreatePaimonView, DeleteTagCommand, DropPaimonView, LateralVectorSearch, 
PaimonCallCommand, PaimonDropPartitions, PaimonTableValuedFunctions, 
RenameTagCommand, ResolvedIdentifier, ShowPaimonViews, ShowTagsCommand, 
TruncatePaimonTableWithFilter}
+import org.apache.paimon.spark.data.SparkInternalRow
+import org.apache.paimon.spark.schema.PaimonMetadataColumn
+import org.apache.paimon.table.{InnerTable, SpecialFields, Table}
+import org.apache.paimon.table.source.{BatchVectorSearchBuilder, 
InnerTableScan, ReadBuilder, VectorScan}
+import org.apache.paimon.types.RowType
+import org.apache.paimon.utils.RoaringNavigableMap64
 
+import org.apache.spark.TaskContext
+import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.SparkSession
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.{ResolvedNamespace, 
ResolvedTable}
-import org.apache.spark.sql.catalyst.expressions.{Expression, 
GenericInternalRow, PredicateHelper}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, 
Expression, GenericInternalRow, JoinedRow, OuterReference, PredicateHelper, 
UnsafeProjection}
 import org.apache.spark.sql.catalyst.plans.logical.{CreateTableAsSelect, 
DescribeRelation, LogicalPlan, ReplaceTable, ReplaceTableAsSelect, 
ShowCreateTable}
+import org.apache.spark.sql.catalyst.util.ArrayData
 import org.apache.spark.sql.connector.catalog.{Identifier, 
PaimonLookupCatalog, TableCatalog}
 import org.apache.spark.sql.execution.{PaimonDescribeTableExec, SparkPlan, 
SparkStrategy}
 import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Implicits, 
DataSourceV2Relation}
@@ -37,6 +49,7 @@ import 
org.apache.spark.sql.execution.shim.{PaimonCreateTableAsSelectStrategy, P
 import org.apache.spark.sql.paimon.shims.SparkShimLoader
 
 import scala.collection.JavaConverters._
+import scala.collection.mutable.ArrayBuffer
 
 case class PaimonStrategy(spark: SparkSession)
   extends SparkStrategy
@@ -61,6 +74,20 @@ case class PaimonStrategy(spark: SparkSession)
       val input = buildInternalRow(args)
       PaimonCallExec(c.output, procedure, input) :: Nil
 
+    case lvs: LateralVectorSearch =>
+      LateralVectorSearchExec(
+        lvs.innerTable,
+        lvs.columnName,
+        lvs.queryVectorExpr,
+        lvs.limit,
+        lvs.options,
+        lvs.vectorSearchOutput,
+        lvs.projectList,
+        lvs.projectOutput,
+        lvs.searchFilters,
+        planLater(lvs.left)
+      ) :: Nil
+
     case t @ ShowTagsCommand(PaimonCatalogAndIdentifier(catalog, ident)) =>
       ShowTagsExec(catalog, ident, t.output) :: Nil
 
@@ -215,3 +242,355 @@ case class PaimonStrategy(spark: SparkSession)
     SparkShimLoader.shim.classicApi.recacheByPlan(spark, v2Relation)
   }
 }
+
+case class LateralVectorSearchExec(
+    innerTable: InnerTable,
+    columnName: String,
+    queryVectorExpr: Expression,
+    limit: Int,
+    options: Map[String, String],
+    vectorSearchOutput: Seq[Attribute],
+    projectList: Seq[Expression],
+    projectOutput: Seq[Attribute],
+    searchFilters: Seq[Expression],
+    child: SparkPlan)
+  extends SparkPlan
+  with PredicateHelper {
+
+  override def children: Seq[SparkPlan] = Seq(child)
+
+  override def output: Seq[Attribute] = child.output ++ projectOutput
+
+  @transient override lazy val producedAttributes: AttributeSet = {
+    AttributeSet(vectorSearchOutput ++ output.filterNot(attr => 
inputSet.contains(attr)))
+  }
+
+  @transient
+  override lazy val references: AttributeSet = {
+    AttributeSet.fromAttributeSets(expressions.map(_.references)) -- 
producedAttributes
+  }
+
+  override protected def withNewChildrenInternal(newChildren: 
IndexedSeq[SparkPlan]): SparkPlan = {
+    copy(child = newChildren.head)
+  }
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    child.execute().mapPartitions {
+      outerRows =>
+        val strippedQueryExpr = queryVectorExpr.transform {
+          case OuterReference(namedExpression) => namedExpression.toAttribute
+        }
+        val queryVectorProjection = 
UnsafeProjection.create(Seq(strippedQueryExpr), child.output)
+        val strippedProjectList = projectList.map {
+          project =>
+            project.transform {
+              case OuterReference(namedExpression) => 
namedExpression.toAttribute
+            }
+        }
+        val rightProjection =
+          UnsafeProjection.create(strippedProjectList, child.output ++ 
vectorSearchOutput)
+        val joinedRow = new JoinedRow
+        val readerTracker = new LateralVectorSearchReaderTracker
+        Option(TaskContext.get())
+          .foreach(_.addTaskCompletionListener[Unit](_ => 
readerTracker.closeCurrent()))
+        val searchContext = createSearchContext(rightProjection, readerTracker)
+        val batchSize = searchContext.batchSize
+
+        outerRows.map(_.copy()).grouped(batchSize).flatMap {
+          outerRowBatch =>
+            val searchBatch = ArrayBuffer[LateralVectorSearchQuery]()
+            outerRowBatch.foreach {
+              outerRow =>
+                toFloatArray(queryVectorProjection(outerRow).get(0, 
strippedQueryExpr.dataType))
+                  .foreach(
+                    queryVector => searchBatch += 
LateralVectorSearchQuery(outerRow, queryVector))
+            }
+
+            if (searchBatch.isEmpty) {
+              Iterator.empty
+            } else {
+              search(searchBatch.toVector, searchContext).map {
+                case (outerRow, rightRow) =>
+                  joinedRow(outerRow, rightRow)
+                  joinedRow.copy()
+              }
+            }
+        }
+    }
+  }
+
+  private def createSearchContext(
+      rightProjection: UnsafeProjection,
+      readerTracker: LateralVectorSearchReaderTracker): 
LateralVectorSearchContext = {
+    val rowType = innerTable.rowType()
+    val readFieldNames = vectorSearchOutput
+      .filterNot(_.name == PaimonMetadataColumn.SEARCH_SCORE_COLUMN)
+      .map(_.name)
+    val readFieldNamesWithRowId =
+      if (readFieldNames.contains(SpecialFields.ROW_ID.name())) {
+        readFieldNames
+      } else {
+        readFieldNames :+ SpecialFields.ROW_ID.name()
+      }
+    val rowTypeWithRowId = SpecialFields.rowTypeWithRowId(rowType)
+    val readRowType = rowType.project(readFieldNames.asJava)
+    val readRowTypeWithRowId = SpecialFields.rowTypeWithRowId(readRowType)
+    val readBuilder = innerTable
+      .newReadBuilder()
+      .withReadType(rowTypeWithRowId.project(readFieldNamesWithRowId.asJava))
+    val scoreMetadataColumns =
+      if (vectorSearchOutput.exists(_.name == 
PaimonMetadataColumn.SEARCH_SCORE_COLUMN)) {
+        Seq(PaimonMetadataColumn.SEARCH_SCORE)
+      } else {
+        Seq.empty
+      }
+    val resultRowType =
+      if (scoreMetadataColumns.isEmpty) {
+        readRowTypeWithRowId
+      } else {
+        new RowType(
+          (readRowTypeWithRowId.getFields.asScala ++ scoreMetadataColumns.map(
+            _.toPaimonDataField)).asJava)
+      }
+    val sparkRow = SparkInternalRow.create(resultRowType)
+    val vectorSearchBuilder = innerTable
+      .newBatchVectorSearchBuilder()
+      .withVectorColumn(columnName)
+      .withLimit(limit)
+      .withOptions(options.asJava)
+    pushSearchFilters(readBuilder, vectorSearchBuilder)
+
+    val vectorPlan = vectorSearchBuilder.newVectorScan().scan()
+    val batchSize =
+      Math.max(1, new 
CoreOptions(innerTable.options()).vectorSearchLateralJoinBatchSize())
+
+    LateralVectorSearchContext(
+      readBuilder,
+      vectorSearchBuilder,
+      vectorPlan,
+      scoreMetadataColumns,
+      sparkRow,
+      rowIdOrdinal = resultRowType.getFieldIndex(SpecialFields.ROW_ID.name()),
+      projectionInputOrdinals = vectorSearchOutput.map {
+        attr =>
+          if (attr.name == PaimonMetadataColumn.SEARCH_SCORE_COLUMN) {
+            -1
+          } else {
+            resultRowType.getFieldIndex(attr.name)
+          }
+      },
+      rightProjection,
+      batchSize,
+      readerTracker
+    )
+  }
+
+  private def pushSearchFilters(
+      readBuilder: ReadBuilder,
+      vectorSearchBuilder: BatchVectorSearchBuilder): Unit = {
+    val predicates = convertSearchFilters()
+    if (predicates.nonEmpty) {
+      val split = splitPartitionPredicatesAndDataPredicates(
+        predicates.asJava,
+        innerTable.rowType(),
+        innerTable.partitionKeys())
+      if (split.getLeft.isPresent) {
+        val partitionFilter = split.getLeft.get()
+        readBuilder.withPartitionFilter(partitionFilter)
+        vectorSearchBuilder.withPartitionFilter(partitionFilter)
+      }
+      if (!split.getRight.isEmpty) {
+        val dataFilter = PredicateBuilder.and(split.getRight)
+        readBuilder.withFilter(dataFilter)
+        vectorSearchBuilder.withFilter(dataFilter)
+      }
+    }
+  }
+
+  private def convertSearchFilters(): Seq[Predicate] = {
+    if (searchFilters.isEmpty) {
+      Seq.empty
+    } else {
+      PaimonTableValuedFunctions
+        .convertLateralVectorSearchFilters(
+          innerTable,
+          vectorSearchOutput,
+          projectList,
+          projectOutput,
+          searchFilters)
+        .getOrElse {
+          throw new UnsupportedOperationException(
+            s"Cannot convert searched-table predicates for LATERAL 
vector_search: $searchFilters")
+        }
+    }
+  }
+
+  private def search(
+      queries: Seq[LateralVectorSearchQuery],
+      context: LateralVectorSearchContext): Iterator[(InternalRow, 
InternalRow)] = {
+    val vectors = queries.map(_.queryVector).toArray
+    val globalIndexResults = context.vectorSearchBuilder
+      .withVectors(vectors)
+      .newBatchVectorRead()
+      .readBatch(context.vectorPlan)
+      .asScala
+      .toVector
+    // Batch vector search must return one result per input query vector and 
preserve the input
+    // order, because createRowIdToMatches pairs each result with its original 
outer row by index.
+    require(
+      globalIndexResults.size == queries.size,
+      s"Batch vector search returned ${globalIndexResults.size} results for 
${queries.size} " +
+        "query vectors. The result count must match the query count."
+    )
+    val rowIdToMatches = createRowIdToMatches(queries, globalIndexResults)
+    val batchGlobalIndexResult = 
createBatchGlobalIndexResult(globalIndexResults)
+    val scan = context.readBuilder
+      .newScan()
+      .withGlobalIndexResult(batchGlobalIndexResult)
+      .asInstanceOf[InnerTableScan]
+    val read = context.readBuilder.newRead()
+
+    scan.plan().splits().asScala.iterator.flatMap {
+      split =>
+        val reader =
+          PaimonRecordReaderIterator(read.createReader(split), 
context.scoreMetadataColumns, split)
+        val readerState = context.readerTracker.track(reader)
+        new Iterator[Iterator[(InternalRow, InternalRow)]] {
+          override def hasNext: Boolean = {
+            val hasNext = reader.hasNext
+            if (!hasNext) {
+              readerState.closeOnce()
+            }
+            hasNext
+          }
+
+          override def next(): Iterator[(InternalRow, InternalRow)] = {
+            val rightRow = context.sparkRow.replace(reader.next())
+            val rowId = rightRow.getLong(context.rowIdOrdinal)
+            rowIdToMatches.getOrElse(rowId, Seq.empty).iterator.map {
+              searchMatch =>
+                val projectedRow = projectRightRow(rightRow, searchMatch, 
context)
+                (searchMatch.outerRow, projectedRow)
+            }
+          }
+        }.flatMap(identity)
+    }
+  }
+
+  private def projectRightRow(
+      rightRow: InternalRow,
+      searchMatch: LateralVectorSearchMatch,
+      context: LateralVectorSearchContext): InternalRow = {
+    val values = new Array[Any](vectorSearchOutput.size)
+    vectorSearchOutput.zipWithIndex.foreach {
+      case (attr, index) =>
+        val ordinal = context.projectionInputOrdinals(index)
+        values(index) = if (ordinal < 0) {
+          searchMatch.score
+        } else {
+          rightRow.get(ordinal, attr.dataType)
+        }
+    }
+    context.rightProjection(new JoinedRow(searchMatch.outerRow, new 
GenericInternalRow(values)))
+  }
+
+  private def createRowIdToMatches(
+      queries: Seq[LateralVectorSearchQuery],
+      globalIndexResults: Seq[GlobalIndexResult]): Map[Long, 
Seq[LateralVectorSearchMatch]] = {
+    val rowIdToMatches =
+      scala.collection.mutable.LinkedHashMap[Long, 
ArrayBuffer[LateralVectorSearchMatch]]()
+    queries.zip(globalIndexResults).foreach {
+      case (query, result) =>
+        val scoreGetter = result match {
+          case scored: ScoredGlobalIndexResult => Some(scored.scoreGetter())
+          case _ => None
+        }
+        result.results().iterator().asScala.foreach {
+          rowId =>
+            rowIdToMatches.getOrElseUpdate(rowId, ArrayBuffer()) +=
+              LateralVectorSearchMatch(
+                query.outerRow,
+                scoreGetter.map(_.score(rowId)).getOrElse(Float.NaN))
+        }
+    }
+    rowIdToMatches.iterator.map { case (rowId, matches) => rowId -> 
matches.toSeq }.toMap
+  }
+
+  private def createBatchGlobalIndexResult(
+      globalIndexResults: Seq[GlobalIndexResult]): GlobalIndexResult = {
+    val rowIds = new RoaringNavigableMap64()
+    globalIndexResults.foreach(result => rowIds.or(result.results()))
+    GlobalIndexResult.create(rowIds)
+  }
+
+  private def toFloatArray(value: Any): Option[Array[Float]] = {
+    value match {
+      case null => None
+      case arrayData: ArrayData => Some(arrayData.toFloatArray())
+      case _ =>
+        throw new RuntimeException(s"Cannot extract query vector from 
expression value: $value")
+    }
+  }
+
+  private class LateralVectorSearchReaderTracker {
+    @volatile private var currentReader: LateralVectorSearchReaderState = _
+
+    def track(reader: PaimonRecordReaderIterator): 
LateralVectorSearchReaderState = {
+      val state = new LateralVectorSearchReaderState(reader, this)
+      this.synchronized {
+        currentReader = state
+      }
+      state
+    }
+
+    def clear(state: LateralVectorSearchReaderState): Unit = {
+      this.synchronized {
+        if (currentReader eq state) {
+          currentReader = null
+        }
+      }
+    }
+
+    def closeCurrent(): Unit = {
+      val reader = currentReader
+      if (reader != null) {
+        reader.closeOnce()
+      }
+    }
+  }
+
+  private class LateralVectorSearchReaderState(
+      reader: PaimonRecordReaderIterator,
+      tracker: LateralVectorSearchReaderTracker) {
+    private var closed = false
+
+    def closeOnce(): Unit = {
+      this.synchronized {
+        if (!closed) {
+          closed = true
+          try {
+            reader.close()
+          } finally {
+            tracker.clear(this)
+          }
+        }
+      }
+    }
+  }
+
+  private case class LateralVectorSearchContext(
+      readBuilder: ReadBuilder,
+      vectorSearchBuilder: BatchVectorSearchBuilder,
+      vectorPlan: VectorScan.Plan,
+      scoreMetadataColumns: Seq[PaimonMetadataColumn],
+      sparkRow: SparkInternalRow,
+      rowIdOrdinal: Int,
+      projectionInputOrdinals: Seq[Int],
+      rightProjection: UnsafeProjection,
+      batchSize: Int,
+      readerTracker: LateralVectorSearchReaderTracker)
+
+  private case class LateralVectorSearchQuery(outerRow: InternalRow, 
queryVector: Array[Float])
+
+  private case class LateralVectorSearchMatch(outerRow: InternalRow, score: 
Float)
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
index e433a5f7d4..61481e201c 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
@@ -19,7 +19,7 @@
 package org.apache.paimon.spark.extensions
 
 import org.apache.paimon.spark.catalyst.analysis.{PaimonAnalysis, 
PaimonDeleteTable, PaimonFunctionResolver, PaimonIncompatibleResolutionRules, 
PaimonMergeInto, PaimonPostHocResolutionRules, PaimonProcedureResolver, 
PaimonUpdateTable, PaimonViewResolver, ReplacePaimonFunctions, 
RewriteUpsertTable}
-import 
org.apache.paimon.spark.catalyst.optimizer.{MergePaimonScalarSubqueries, 
OptimizeMetadataOnlyDeleteFromPaimonTable}
+import 
org.apache.paimon.spark.catalyst.optimizer.{MergePaimonScalarSubqueries, 
OptimizeMetadataOnlyDeleteFromPaimonTable, PushDownLateralVectorSearchFilter}
 import 
org.apache.paimon.spark.catalyst.plans.logical.PaimonTableValuedFunctions
 import org.apache.paimon.spark.commands.BucketExpression
 import org.apache.paimon.spark.execution.{OldCompatibleStrategy, 
PaimonStrategy}
@@ -102,6 +102,7 @@ class PaimonSparkSessionExtensions extends 
(SparkSessionExtensions => Unit) {
     extensions.injectOptimizerRule(spark => ReplacePaimonFunctions(spark))
     extensions.injectOptimizerRule(_ => 
OptimizeMetadataOnlyDeleteFromPaimonTable)
     extensions.injectOptimizerRule(_ => MergePaimonScalarSubqueries)
+    extensions.injectOptimizerRule(_ => PushDownLateralVectorSearchFilter)
 
     // planner extensions
     extensions.injectPlannerStrategy(spark => PaimonStrategy(spark))
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 af3401ea2a..3a4dd41b16 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
@@ -170,6 +170,29 @@ public class SparkMultimodalITCase {
                                 .collect(Collectors.toList()));
         spark.close();
 
+        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';")
+                        .collectAsList();
+        assertThat(rows).hasSize(40);
+        assertThat(
+                        rows.stream()
+                                .collect(
+                                        Collectors.groupingBy(
+                                                row -> row.getLong(0), 
Collectors.counting())))
+                .hasSize(8)
+                .containsEntry(1L, 5L)
+                .containsEntry(2L, 5L)
+                .containsEntry(3L, 5L)
+                .containsEntry(4L, 5L)
+                .containsEntry(5L, 5L)
+                .containsEntry(6L, 5L)
+                .containsEntry(7L, 5L)
+                .containsEntry(8L, 5L);
+        spark.close();
+
         spark = builder.getOrCreate();
         spark.sql("DROP TABLE IF EXISTS `my_db1`.`vector_test`;");
         spark.close();
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/TableValuedFunctionsTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/TableValuedFunctionsTest.scala
index 8114766a99..94565a9ad2 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/TableValuedFunctionsTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/TableValuedFunctionsTest.scala
@@ -21,10 +21,11 @@ package org.apache.paimon.spark.sql
 import org.apache.paimon.data.{BinaryString, GenericRow, Timestamp}
 import org.apache.paimon.manifest.ManifestCommittable
 import org.apache.paimon.spark.PaimonHiveTestBase
-import 
org.apache.paimon.spark.catalyst.plans.logical.PaimonTableValuedFunctions
+import org.apache.paimon.spark.catalyst.plans.logical.{LateralVectorSearch, 
PaimonTableValuedFunctions}
 import org.apache.paimon.utils.DateTimeUtils
 
 import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.catalyst.plans.logical.Filter
 
 import java.time.LocalDateTime
 import java.util.Collections
@@ -41,6 +42,145 @@ class TableValuedFunctionsTest extends PaimonHiveTestBase {
     assert(error.getMessage.contains("Limit must be no greater than"))
   }
 
+  test("lateral vector search preserves subquery alias qualifiers") {
+    withTable("vector_search_source", "vector_search_result") {
+      spark.sql("""
+                  |CREATE TABLE vector_search_source (gid BIGINT, embs 
ARRAY<FLOAT>, dt STRING)
+                  |USING paimon
+                  |TBLPROPERTIES (
+                  |  'vector.file.format' = 'lance',
+                  |  'vector-field' = 'embs',
+                  |  'field.embs.vector-dim' = '3',
+                  |  'row-tracking.enabled' = 'true',
+                  |  'data-evolution.enabled' = 'true')
+                  |PARTITIONED BY (dt)
+                  |""".stripMargin)
+      spark.sql("""
+                  |CREATE TABLE vector_search_result (
+                  |  query_gid BIGINT,
+                  |  query_embs ARRAY<FLOAT>,
+                  |  result_gid BIGINT,
+                  |  result_embs ARRAY<FLOAT>,
+                  |  score FLOAT,
+                  |  dt STRING)
+                  |USING paimon
+                  |PARTITIONED BY (dt)
+                  |""".stripMargin)
+
+      val insertOptimizedPlan = spark
+        .sql("""
+               |SELECT q.gid AS query_gid, q.embs AS query_embs,
+               |       r.gid AS result_gid, r.embs AS result_embs,
+               |       r.__paimon_search_score AS score
+               |FROM vector_search_source AS q,
+               |LATERAL (
+               |  SELECT gid, embs, __paimon_search_score
+               |  FROM vector_search('vector_search_source', 'embs', q.embs, 5)
+               |) AS r
+               |WHERE q.dt = '20260608'
+               |""".stripMargin)
+        .queryExecution
+        .optimizedPlan
+      val lateralVectorSearches = insertOptimizedPlan.collect {
+        case lvs: LateralVectorSearch => lvs
+      }
+      assert(lateralVectorSearches.size == 1, insertOptimizedPlan.toString)
+
+      val optimizedPlanWithoutScore = spark
+        .sql("""
+               |SELECT q.gid AS query_gid, r.embs AS result_embs
+               |FROM vector_search_source AS q,
+               |LATERAL (
+               |  SELECT embs
+               |  FROM vector_search('vector_search_source', 'embs', q.embs, 5)
+               |) AS r
+               |""".stripMargin)
+        .queryExecution
+        .optimizedPlan
+      assert(
+        optimizedPlanWithoutScore.exists(_.isInstanceOf[LateralVectorSearch]),
+        optimizedPlanWithoutScore.toString)
+
+      val analyzedPlanWithJoinCondition = spark
+        .sql("""
+               |SELECT q.gid AS query_gid, r.result_gid, r.score
+               |FROM vector_search_source AS q,
+               |LATERAL (
+               |  SELECT gid AS result_gid, __paimon_search_score AS score
+               |  FROM vector_search('vector_search_source', 'embs', q.embs, 5)
+               |) AS r
+               |WHERE q.gid = r.result_gid AND r.score >= 0.0
+               |""".stripMargin)
+        .queryExecution
+        .analyzed
+      val lateralVectorSearchFilters = analyzedPlanWithJoinCondition.collect {
+        case filter @ Filter(_, _: LateralVectorSearch) => filter
+      }
+      assert(lateralVectorSearchFilters.size == 1, 
analyzedPlanWithJoinCondition.toString)
+      assert(
+        lateralVectorSearchFilters.head.condition.references
+          .subsetOf(lateralVectorSearchFilters.head.child.outputSet),
+        analyzedPlanWithJoinCondition.toString
+      )
+
+      val optimizedPlanWithSearchFilter = spark
+        .sql("""
+               |SELECT q.gid AS query_gid, r.result_gid, r.dt
+               |FROM vector_search_source AS q,
+               |LATERAL (
+               |  SELECT gid AS result_gid, dt
+               |  FROM vector_search('vector_search_source', 'embs', q.embs, 5)
+               |) AS r
+               |WHERE r.dt = '20260608'
+               |""".stripMargin)
+        .queryExecution
+        .optimizedPlan
+      val lateralVectorSearchesWithSearchFilter = 
optimizedPlanWithSearchFilter.collect {
+        case lvs: LateralVectorSearch => lvs
+      }
+      assert(
+        lateralVectorSearchesWithSearchFilter.size == 1,
+        optimizedPlanWithSearchFilter.toString)
+      assert(
+        lateralVectorSearchesWithSearchFilter.head.searchFilters.nonEmpty,
+        optimizedPlanWithSearchFilter.toString)
+
+      val optimizedPlanWithSubqueryFilter = spark
+        .sql("""
+               |SELECT q.gid AS query_gid, r.result_gid
+               |FROM vector_search_source AS q,
+               |LATERAL (
+               |  SELECT gid AS result_gid
+               |  FROM vector_search('vector_search_source', 'embs', q.embs, 5)
+               |  WHERE dt = '20260608'
+               |) AS r
+               |""".stripMargin)
+        .queryExecution
+        .optimizedPlan
+      val lateralVectorSearchesWithSubqueryFilter = 
optimizedPlanWithSubqueryFilter.collect {
+        case lvs: LateralVectorSearch => lvs
+      }
+      assert(
+        lateralVectorSearchesWithSubqueryFilter.size == 1,
+        optimizedPlanWithSubqueryFilter.toString)
+      assert(
+        lateralVectorSearchesWithSubqueryFilter.head.searchFilters.nonEmpty,
+        optimizedPlanWithSubqueryFilter.toString)
+
+      val constantVectorPlan = spark
+        .sql("""
+               |SELECT gid
+               |FROM vector_search(
+               |  'vector_search_source', 'embs', array(1.0f, 2.0f, 3.0f), 5)
+               |""".stripMargin)
+        .queryExecution
+        .optimizedPlan
+      assert(
+        !constantVectorPlan.exists(_.isInstanceOf[LateralVectorSearch]),
+        constantVectorPlan.toString)
+    }
+  }
+
   withPk.foreach {
     hasPk =>
       bucketModes.foreach {

Reply via email to