chrevanthreddy commented on code in PR #20107:
URL: https://github.com/apache/hudi/pull/20107#discussion_r4384292504


##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/HoodieVectorSearchTableValuedFunction.scala:
##########
@@ -91,49 +91,35 @@ object HoodieVectorSearchTableValuedFunction {
     else DistanceMetric.COSINE
     val algorithm = if (exprs.size >= 6) 
SearchAlgorithm.fromString(requireStringLiteral(exprs(5), "algorithm"))
     else SearchAlgorithm.BRUTE_FORCE
-    val filter = if (exprs.size >= 7) parseOptionalString(FUNC_NAME, exprs(6), 
"filter") else None
-    val maxDistance = if (exprs.size >= 8) parseOptionalDouble(FUNC_NAME, 
exprs(7), "max_distance") else None
-    ParsedArgs(table, embeddingCol, queryVectorExpr, k, metric, algorithm, 
filter, maxDistance)
-  }
-
-  private[logical] def parseK(funcName: String, expr: Expression): Int = {
-    val rawValue = expr.eval()
-    if (rawValue == null) {
-      throw new HoodieAnalysisException(
-        s"Function '$funcName': k must be a positive integer, got null")
+    val runtimeOptions = algorithm match {
+      case SearchAlgorithm.BRUTE_FORCE =>
+        val filter = if (exprs.size >= 7) parseOptionalString(FUNC_NAME, 
exprs(6), "filter") else None
+        val maxDistance = if (exprs.size >= 8) parseOptionalDouble(FUNC_NAME, 
exprs(7), "max_distance") else None
+        filter.map(BRUTE_FORCE_FILTER_OPT -> _).toMap ++
+          maxDistance.map(value => BRUTE_FORCE_MAX_DISTANCE_OPT -> 
value.toString).toMap
+      case SearchAlgorithm.IVF_RABITQ_MDT =>
+        if (exprs.size >= 8) {
+          throw new HoodieAnalysisException(
+            s"Function '$FUNC_NAME': max_distance is supported only by 
brute_force")
+        }
+        if (exprs.size >= 7) parseKvOptions(requireStringLiteral(exprs(6), 
"options"))

Review Comment:
   **Fixed** `3cde5283ea`. NULL is accepted for the `ivf_rabitq_mdt` options 
argument in both TVFs (`TestVectorSearchTvfArguments`).



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSparkBaseAnalysis.scala:
##########
@@ -345,12 +343,19 @@ case class ResolveReferences(spark: SparkSession) extends 
Rule[LogicalPlan]
    * Resolves a table reference to a DataFrame. Accepts either a table 
identifier
    * (including multi-part identifiers like catalog.db.table) or a file path.
    */
-  private def resolveTableToDf(table: String, funcName: String): DataFrame = {
+  private def resolveTableToDf(table: String, funcName: String): 
VectorSearchTable = {
     try {
       if (table.contains(StoragePath.SEPARATOR)) {
-        spark.read.format("hudi").load(table)
+        VectorSearchTable(spark.read.format("hudi").load(table), Some(table))
       } else {
-        spark.table(table)
+        val tableDf = spark.table(table)
+        val basePathOpt = try {
+          val tableId = 
spark.sessionState.sqlParser.parseTableIdentifier(table)

Review Comment:
   **Fixed** `3cde5283ea`. The index base path is resolved from the resolved 
Hudi catalog table (`SparkAdapter.resolveHoodieTable`); a catalog-qualified 
test was added.



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieVectorSearchPlanBuilder.scala:
##########
@@ -17,365 +17,473 @@
 
 package org.apache.spark.sql.hudi.analysis
 
+import org.apache.hudi.SparkAdapterSupport
+import org.apache.hudi.client.common.HoodieSparkEngineContext
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.index.vector.{ScoredVectorPostingMatch, 
VectorDistanceMetric, VectorIndexArbiter, VectorIndexMdtSearchUtils, 
VectorIndexMetadataCache, VectorIndexOptions, VectorPostingMatch, 
VectorStalePolicy}
+import org.apache.hudi.common.model.{FileSlice, HoodieIndexDefinition, 
HoodieRecord}
 import org.apache.hudi.common.schema.HoodieSchema
-
-import org.apache.spark.sql.{AnalysisException, DataFrame, SparkSession}
+import org.apache.hudi.common.table.{HoodieTableMetaClient, 
TableSchemaResolver}
+import org.apache.hudi.common.table.timeline.{HoodieTimeline, 
InstantComparison}
+import 
org.apache.hudi.common.table.timeline.InstantComparison.compareTimestamps
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView
+import org.apache.hudi.data.HoodieJavaRDD
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+import org.apache.hudi.metadata.{HoodieTableMetadata, HoodieTableMetadataUtil}
+
+import org.apache.spark.api.java.JavaSparkContext
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SparkSession}
+import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.parser.ParseException
+import 
org.apache.spark.sql.catalyst.plans.logical.HoodieVectorSearchTableValuedFunction
 import 
org.apache.spark.sql.catalyst.plans.logical.HoodieVectorSearchTableValuedFunction.{DistanceMetric,
 SearchAlgorithm}
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.expressions.Window
 import org.apache.spark.sql.functions.{broadcast, col, 
monotonically_increasing_id, row_number}
 import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
-import org.apache.spark.sql.types.{ArrayType, ByteType, DataType, DoubleType, 
FloatType}
+import org.apache.spark.sql.types.{ArrayType, ByteType, DataType, DoubleType, 
FloatType, Metadata, StructField, StructType}
+import org.apache.spark.util.LongAccumulator
+import org.slf4j.LoggerFactory
 
+import scala.collection.JavaConverters._
+import scala.collection.mutable.ArrayBuffer
 import scala.util.{Failure, Success, Try}
 
 /**
- * Extension point for vector search algorithms. Each implementation provides
- * the Spark logical plan for single-query and batch-query KNN search.
- *
- * To add a new algorithm (e.g. RowMatrix, HNSW):
- *  1. Create an object extending this trait
- *  2. Add a value to [[SearchAlgorithm]]
- *  3. Register the mapping in 
[[HoodieVectorSearchPlanBuilder.resolveAlgorithm]]
+ * IVF + RaBitQ vector search backed by native metadata-table posting prefix 
lookups.
  *
- * Implementations can use the shared validation helpers on
- * [[HoodieVectorSearchPlanBuilder]] (validateEmbeddingColumn, 
validateBatchDimensions, etc.)
- * and the raw distance functions on [[VectorDistanceUtils]].
- *
- * The output schema contract:
- *  - Single-query: all corpus columns (minus the embedding column) + 
`_hudi_distance: Double`
- *  - Batch-query: all corpus columns (minus the embedding column) + clashing 
query columns
- *    (prefixed with `_hudi_query_`) + `_hudi_distance: Double` + 
`_hudi_query_index: Long`
- *  - Results are ordered by `_hudi_distance` ascending (lower = more similar)
- *  - `_hudi_query_index` is an opaque grouping identifier (not a sequential 
index). Values may be
- *    large non-contiguous numbers because they are generated by 
`monotonically_increasing_id()`.
+ * The algorithm keeps Spark SQL out of the posting scan: it probes centroids, 
converts
+ * the selected clusters to MDT posting prefixes, reads posting rows with 
HFile-friendly
+ * prefix lookups, reduces with RaBitQ approximate scores before 
record-location lookup,
+ * then exact-reranks the fetched Hudi rows.
  */
-trait VectorSearchAlgorithm {
+object IvfRaBitQMdtSearchAlgorithm extends VectorSearchAlgorithm with 
SparkAdapterSupport {
+  private val APPROXIMATE_CANDIDATE_FLOOR = 32
 
-  /** Human-readable name for error messages and logging. */
-  def name: String
+  private[analysis] def approximateCandidateHeapSize(k: Int): Int =
+    math.max(k, APPROXIMATE_CANDIDATE_FLOOR)
 
-  /**
-   * Build a plan that finds the k nearest corpus rows to a single query 
vector.
-   *
-   * @param spark          active SparkSession
-   * @param corpusDf       resolved corpus DataFrame (may be Hudi, Parquet, or 
temp view)
-   * @param embeddingCol   name of the array-typed embedding column in corpusDf
-   * @param queryVector    the query vector, normalized to Array[Double]
-   * @param k              number of nearest neighbors to return
-   * @param metric         distance metric (COSINE, L2, DOT_PRODUCT)
-   * @param filter         optional SQL predicate applied to the corpus before 
distance computation,
-   *                       String literals inside the predicate must use 
double quotes (e.g. {@code label = "z-axis"})
-   *                       because the filter is passed as a single-quoted TVF 
argument and
-   *                       Spark's lexer does not support {@code ''} as an 
escape sequence
-   *                       inside string literals.
-   * @param maxDistance    optional distance threshold; results exceeding this 
value are excluded
-   * @return an analyzed LogicalPlan whose output matches the single-query 
schema contract
-   */
-  def buildSingleQueryPlan(
-      spark: SparkSession,
-      corpusDf: DataFrame,
-      embeddingCol: String,
-      queryVector: Array[Double],
-      k: Int,
-      metric: DistanceMetric.Value,
-      filter: Option[String] = None,
-      maxDistance: Option[Double] = None): LogicalPlan
-
-  /**
-   * Build a plan that finds the k nearest corpus rows for each row in the 
query table.
-   *
-   * @param spark              active SparkSession
-   * @param corpusDf           resolved corpus DataFrame
-   * @param corpusEmbeddingCol name of the embedding column in corpusDf
-   * @param queryDf            resolved query DataFrame
-   * @param queryEmbeddingCol  name of the embedding column in queryDf
-   * @param k                  number of nearest neighbors per query
-   * @param metric             distance metric (COSINE, L2, DOT_PRODUCT)
-   * @param filter             optional SQL predicate applied to the corpus 
before distance
-   *                           computation, and shrinking cross-join 
cardinality. Applied to the
-   *                           corpus only — to restrict the query side, apply 
a projection or
-   *                           filter on the query table before invoking the 
TVF. See
-   *                           [[buildSingleQueryPlan]] for quoting 
requirements.
-   * @param maxDistance        optional distance threshold; results exceeding 
this value are
-   *                           excluded before per-query top-K selection, 
reducing shuffle volume.
-   * @return an analyzed LogicalPlan whose output matches the batch-query 
schema contract
-   * @note Batch mode broadcasts the query table to all executors via a 
cross-join.
-   *       This is designed for small-to-medium query sets (tens to low 
hundreds of rows).
-   *       For large query tables, memory pressure on executors may occur.
-   */
-  def buildBatchQueryPlan(
-      spark: SparkSession,
-      corpusDf: DataFrame,
-      corpusEmbeddingCol: String,
-      queryDf: DataFrame,
-      queryEmbeddingCol: String,
-      k: Int,
-      metric: DistanceMetric.Value,
-      filter: Option[String] = None,
-      maxDistance: Option[Double] = None): LogicalPlan
-}
+  import HoodieVectorSearchPlanBuilder._
 
-/**
- * Resolves [[SearchAlgorithm]] values to [[VectorSearchAlgorithm]] 
implementations
- * and provides shared validation helpers used across algorithms.
- */
-object HoodieVectorSearchPlanBuilder {
-
-  val DISTANCE_COL = "_hudi_distance"
-  private[analysis] val QUERY_ID_COL = "_hudi_query_index"
-  private[analysis] val QUERY_EMB_ALIAS = "_hudi_query_emb"
-  private[analysis] val RANK_COL = "_hudi_rank"
-  private[analysis] val QUERY_COL_PREFIX = "_hudi_query_"
-
-  /** Resolve a [[SearchAlgorithm]] enum value to its implementation. */
-  def resolveAlgorithm(algorithm: SearchAlgorithm.Value): 
VectorSearchAlgorithm = algorithm match {
-    case SearchAlgorithm.BRUTE_FORCE => BruteForceSearchAlgorithm
-    case other => throw new HoodieAnalysisException(
-      s"Unsupported search algorithm: $other")
+  private[analysis] def isLogResidentCandidate(
+      candidate: ScoredVectorPostingMatch,
+      currentBaseInstant: String,
+      sliceHasLogs: Boolean): Boolean = {
+    // A negative position is the authoritative log-resident signal: base-file 
records always carry a
+    // non-negative row position, so a candidate with position < 0 in a slice 
that still has log files
+    // lives in a log block and cannot be materialized from the base Parquet. 
The candidate's instant
+    // is NOT a reliable discriminator here -- a log delta can share the 
slice's base instant time.
+    val location = candidate.getLocation
+    sliceHasLogs && location != null && location.getPosition < 0
   }
 
-  private[analysis] def validateEmbeddingColumn(df: DataFrame, colName: 
String): Unit = {
-    val fieldOpt = df.schema.fields.find(_.name.equalsIgnoreCase(colName))
-    val field = fieldOpt.getOrElse(
-      throw new HoodieAnalysisException(
-        s"Embedding column '$colName' not found in table schema. " +
-          s"Available columns: ${df.schema.fieldNames.mkString(", ")}"))
-    field.dataType match {
-      case ArrayType(FloatType, _) | ArrayType(DoubleType, _) | 
ArrayType(ByteType, _) => // valid
-      case other =>
-        throw new HoodieAnalysisException(
-          s"Embedding column '$colName' has type $other, " +
-            "expected array<float>, array<double>, or array<byte>")
+  private[analysis] def approximateArbitratedCandidate(
+      candidate: ScoredVectorPostingMatch): Option[ScoredVectorPostingMatch] =
+    candidate.getArbiterDecision match {
+      case VectorIndexArbiter.Decision.SERVE => Some(candidate)
+      // A delta carries the updated vector code. Refreshing only its locator 
is safe and preserves
+      // the new score; a stale packed-block row carries old code and must 
never be resurrected.
+      case VectorIndexArbiter.Decision.STALE if candidate.isDelta => 
Some(candidate)
+      case _ => None
     }
+
+  private[analysis] case class FreshnessLag(firstUnmarkedInstant: String, 
lagCount: Int)
+
+  private[analysis] def completedSourceWriteTimeline(timeline: 
HoodieTimeline): HoodieTimeline =
+    timeline.getWriteTimeline.filterCompletedInstants()
+
+  private[analysis] def freshnessLag(
+      completedWriteInstants: Seq[String],
+      coveredInstant: Option[String]): Option[FreshnessLag] = {
+    val uncovered = completedWriteInstants.filter(instant => 
coveredInstant.forall(covered =>
+      compareTimestamps(covered, InstantComparison.LESSER_THAN, instant)))
+    uncovered.headOption.map(first => FreshnessLag(first, uncovered.size))
   }
 
-  /**
-   * Validates that the query vector dimension matches the corpus embedding 
dimension
-   * when the corpus column has VECTOR(dim) metadata.
-   */
-  private[analysis] def validateQueryVectorDimension(
-      df: DataFrame, embeddingCol: String, queryDim: Int): Unit = {
-    extractVectorDimension(df, embeddingCol).foreach { corpusDim =>
-      if (corpusDim != queryDim) {
-        throw new HoodieAnalysisException(
-          s"Query vector dimension ($queryDim) does not match " +
-            s"corpus embedding dimension ($corpusDim) for column 
'$embeddingCol'")
+  override val name: String = "ivf_rabitq_mdt"
+
+  private[analysis] case class VectorSearchPlanContext(
+      basePath: String,
+      metaClient: HoodieTableMetaClient,
+      timeline: HoodieTimeline,
+      latestInstantTime: String,
+      indexPartition: String,
+      indexDefinition: HoodieIndexDefinition,
+      metadataTable: HoodieTableMetadata,
+      cache: VectorIndexMetadataCache,
+      tuningOptions: Map[String, String],
+      metric: DistanceMetric.Value)
+
+  private[analysis] def isPartitionedRecordIndex(ctx: 
VectorSearchPlanContext): Boolean =
+    false
+
+  private val STRUCTURAL_OPTION_KEYS: Set[String] = Set(
+    VectorIndexOptions.METRIC,
+    VectorIndexOptions.QUANTIZER,
+    VectorIndexOptions.RABITQ_BITS,
+    VectorIndexOptions.RABITQ_SEED,
+    VectorIndexOptions.RABITQ_ASSUME_NORMALIZED)
+
+  private[analysis] def resolvedTuningOptions(
+      indexOptions: Map[String, String],
+      runtimeOptions: Map[String, String]): Map[String, String] = {
+    val inherited = indexOptions
+      .filterNot { case (key, _) => STRUCTURAL_OPTION_KEYS.contains(key) }
+      // An explicit runtime mode change gets that mode's freshness default 
unless the caller also
+      // overrides freshness. Otherwise a persisted exact-mode default (FAIL) 
would leak into an
+      // approximate query whose contract default is WARN.
+      .filterNot { case (key, _) =>
+        key == VectorIndexOptions.FRESHNESS_POLICY &&
+          runtimeOptions.contains(VectorIndexOptions.QUERY_MODE) &&
+          !runtimeOptions.contains(VectorIndexOptions.FRESHNESS_POLICY)
       }
-    }
+    inherited ++ runtimeOptions
   }
 
-  /**
-   * Validates that corpus and query embedding columns have the same element 
type.
-   *
-   * This is a deliberate design choice: we require strict type matching 
rather than
-   * silently upcasting (e.g. float to double). While some vector search 
systems
-   * auto-convert, strict matching avoids subtle precision differences and 
makes
-   * type mismatches between pipelines explicit. This can be relaxed to support
-   * automatic widening (byte -> float -> double) in a future iteration if 
needed.
-   */
-  private[analysis] def validateElementTypeCompatibility(
-      corpusDf: DataFrame, corpusCol: String,
-      queryDf: DataFrame, queryCol: String): Unit = {
-    val corpusElemType = getElementType(corpusDf, corpusCol)
-    val queryElemType = getElementType(queryDf, queryCol)
-    if (corpusElemType != queryElemType) {
+  private def resolvePlanContext(
+      spark: SparkSession,
+      basePath: String,
+      embeddingCol: String,
+      vectorSchema: HoodieSchema.Vector,
+      callerMetric: DistanceMetric.Value,
+      runtimeOptions: Map[String, String]): VectorSearchPlanContext = {
+    val illegal = runtimeOptions.keySet.intersect(STRUCTURAL_OPTION_KEYS)
+    if (illegal.nonEmpty) {
       throw new HoodieAnalysisException(
-        s"Corpus embedding column '$corpusCol' has element type 
$corpusElemType " +
-          s"but query embedding column '$queryCol' has element type 
$queryElemType. " +
-          "Both must use the same element type (e.g. array<float>).")
+        s"Options ${illegal.toSeq.sorted.mkString(", ")} describe vector index 
structure and cannot be set at query time; " +
+          "they are read from the active index generation.")
     }
-  }
 
-  /**
-   * Validates that corpus and query embedding dimensions match when both have
-   * VECTOR(dim) metadata.
-   */
-  private[analysis] def validateBatchDimensions(
-      corpusDf: DataFrame, corpusCol: String,
-      queryDf: DataFrame, queryCol: String): Unit = {
-    (extractVectorDimension(corpusDf, corpusCol), 
extractVectorDimension(queryDf, queryCol)) match {
-      case (Some(corpusDim), Some(queryDim)) if corpusDim != queryDim =>
-        throw new HoodieAnalysisException(
-          s"Corpus embedding dimension ($corpusDim) does not match " +
-            s"query embedding dimension ($queryDim)")
-      case _ => // dimensions match or metadata not available
+    val storageConf = 
HadoopFSUtils.getStorageConf(spark.sessionState.newHadoopConf())
+    val metaClient = HoodieTableMetaClient.builder()
+      .setConf(storageConf.newInstance())
+      .setBasePath(basePath)
+      .build()
+    // Freshness markers are emitted for completed source writes only. Keep 
the query comparand on
+    // exactly that action family: clean, rollback, savepoint, restore, and 
indexing instants must
+    // never make an otherwise-covered vector generation look stale.
+    val timeline = completedSourceWriteTimeline(metaClient.getActiveTimeline)
+    val latestInstantTime = timeline.lastInstant().orElseThrow(() =>
+      new HoodieAnalysisException(s"No completed commits found for table 
'$basePath'.")).requestedTime()
+    val (indexPartition, indexDefinition) = 
resolveVectorIndexDefinition(metaClient, embeddingCol)
+    // Build the metadata-reader config from the active session properties 
instead of bare
+    // defaults. Prior to this, newBuilder().enable(true).build() silently 
dropped every
+    // hoodie.* metadata/hfile read-side conf set on the session (e.g. the 
HFile block-cache
+    // sizing), so the vector TVF always read on defaults. fromProperties 
restores operator
+    // control; enable(true) still forces MDT on for the search path 
regardless of session.
+    val metadataProps = 
TypedProperties.fromMap(spark.sessionState.conf.getAllConfs.asJava)
+    val metadataConfig = HoodieMetadataConfig.newBuilder()
+      .fromProperties(metadataProps)
+      .enable(true)
+      .build()
+    val engineContext = new HoodieSparkEngineContext(new 
JavaSparkContext(spark.sparkContext), spark.sqlContext)
+    val metadataTable = metaClient.getTableFormat.getMetadataFactory
+      .create(engineContext, metaClient.getStorage, metadataConfig, basePath)
+    val cache = getOrLoadMetadataCache(metaClient, metadataTable, 
indexPartition, vectorSchema, latestInstantTime)
+    if (cache == null || cache.getGenerationId < 0) {
+      throw new HoodieAnalysisException(
+        s"Vector index '$indexPartition' does not contain valid generation 
metadata.")
     }
+
+    val indexOptions = 
Option(indexDefinition.getIndexOptions).map(_.asScala.toMap).getOrElse(Map.empty)
+    val indexMetric = 
toSqlMetric(VectorIndexOptions.getMetric(indexOptions.asJava))
+    val authoritativeMetric = validateMetricAuthority(callerMetric, 
indexMetric, cache)
+    val tuningOptions = resolvedTuningOptions(indexOptions, runtimeOptions)
+    VectorSearchPlanContext(
+      basePath, metaClient, timeline, latestInstantTime, indexPartition, 
indexDefinition,
+      metadataTable, cache, tuningOptions, authoritativeMetric)
   }
 
-  private[analysis] def getElementType(df: DataFrame, colName: String): 
DataType = {
-    val field = 
df.schema.fields.find(_.name.equalsIgnoreCase(colName)).getOrElse(
-      throw new HoodieAnalysisException(
-        s"Embedding column '$colName' not found in table schema. " +
-          s"Available columns: ${df.schema.fieldNames.mkString(", ")}"))
-    field.dataType match {
-      case ArrayType(elemType, _) => elemType
-      case other =>
+  private def validateMetricAuthority(
+      callerMetric: DistanceMetric.Value,
+      indexMetric: DistanceMetric.Value,
+      cache: VectorIndexMetadataCache): DistanceMetric.Value = {
+    if (callerMetric == indexMetric) {
+      callerMetric
+    } else {
+      val normalizedEquivalent = cache.isAssumeNormalized &&
+        Set(callerMetric, indexMetric) == Set(DistanceMetric.COSINE, 
DistanceMetric.DOT_PRODUCT)
+      if (normalizedEquivalent) {
+        callerMetric
+      } else {
         throw new HoodieAnalysisException(
-          s"Embedding column '$colName' has type $other, expected an array 
type")
+          s"Query requests metric '$callerMetric' but the vector index 
generation was built for " +
+            s"'$indexMetric' (assumeNormalized=${cache.isAssumeNormalized}).")
+      }
     }
   }
 
-  /** Extracts VECTOR(dim) dimension from column metadata, if present. */
-  private def extractVectorDimension(df: DataFrame, colName: String): 
Option[Int] = {
-    df.schema.fields.find(_.name.equalsIgnoreCase(colName)).flatMap { field =>
-      val meta = field.metadata
-      if (meta.contains(HoodieSchema.TYPE_METADATA_FIELD)) {
-        val typeDesc = meta.getString(HoodieSchema.TYPE_METADATA_FIELD)
-        Try(HoodieSchema.parseTypeDescriptor(typeDesc)) match {
-          case Success(v: HoodieSchema.Vector) => Some(v.getDimension)
-          case Success(_) => throw new HoodieAnalysisException(
-            s"Column '$colName' has type '$typeDesc' which is not a VECTOR 
type. " +
-              "Only VECTOR columns are supported for vector search.")
-          case Failure(e) => throw new HoodieAnalysisException(
-            s"Column '$colName' has malformed type metadata '$typeDesc': 
${e.getMessage}")
-        }
-      } else None
-    }
+  private def toSqlMetric(metric: VectorDistanceMetric): DistanceMetric.Value 
= metric match {
+    case VectorDistanceMetric.COSINE => DistanceMetric.COSINE
+    case VectorDistanceMetric.L2 => DistanceMetric.L2
+    case VectorDistanceMetric.DOT_PRODUCT => DistanceMetric.DOT_PRODUCT
   }
-}
 
-/**
- * Brute-force KNN vector search: computes distance between every corpus row 
and
- * every query vector, then selects the top-K closest.
- *
- * <p>Complexity: O(|corpus| * |queries| * dimensions) — linear scan with no 
index.
- *
- * <p><b>Single-query mode:</b> applies a distance UDF per corpus row, then
- * {@code orderBy + limit(k)} (Spark optimizes this to a partial sort via 
TakeOrderedAndProject).
- *
- * <p><b>Batch-query mode:</b> broadcast cross-joins the (small) query table 
with
- * the corpus, computes pairwise distances, then uses a window function to rank
- * and select top-K per query. The cross-join produces O(|corpus| * |queries|)
- * intermediate rows, so this is suitable for small-to-medium query sets
- * (tens to low hundreds of queries) against moderate corpora.
- *
- * <p>Both modes support an optional {@code filter} predicate (applied to the 
corpus before
- * distance computation), and an optional {@code maxDistance} threshold 
(results beyond this
- * distance are excluded before top-K selection, reducing shuffle and sort 
volume).
- */
-object BruteForceSearchAlgorithm extends VectorSearchAlgorithm {
-
-  import HoodieVectorSearchPlanBuilder._
-
-  override val name: String = "brute_force"
-
-  /**
-   * Applies a user-supplied SQL predicate to the corpus DataFrame, wrapping
-   * [[ParseException]] (predicate syntax error) and [[AnalysisException]]
-   * (unknown column, type mismatch, etc.) in a [[HoodieAnalysisException]] 
that
-   * echoes the offending expression. Other exception types are allowed to
-   * propagate untouched so they aren't misreported as a filter problem.
-   */
-  private def applyFilter(df: DataFrame, filterExpr: String): DataFrame = {
-    try {
-      df.filter(filterExpr)
-    } catch {
-      case e @ (_: ParseException | _: AnalysisException) =>
-        throw new HoodieAnalysisException(
-          s"Invalid pre-filter expression '$filterExpr': ${e.getMessage}")
-    }
+  private[analysis] def toVectorMetric(metric: DistanceMetric.Value): 
VectorDistanceMetric = metric match {
+    case DistanceMetric.COSINE => VectorDistanceMetric.COSINE
+    case DistanceMetric.L2 => VectorDistanceMetric.L2
+    case DistanceMetric.DOT_PRODUCT => VectorDistanceMetric.DOT_PRODUCT
   }
 
   override def buildSingleQueryPlan(
       spark: SparkSession,
-      corpusDf: DataFrame,
+      corpusTable: VectorSearchTable,
       embeddingCol: String,
       queryVector: Array[Double],
       k: Int,
       metric: DistanceMetric.Value,
-      filter: Option[String],
-      maxDistance: Option[Double]): LogicalPlan = {
+      runtimeOptions: Map[String, String] = Map.empty): LogicalPlan = {
+    val corpusDf = corpusTable.df
     validateEmbeddingColumn(corpusDf, embeddingCol)
     validateQueryVectorDimension(corpusDf, embeddingCol, queryVector.length)
 
-    val elemType = getElementType(corpusDf, embeddingCol)
-    // Apply pre-filter before distance computation to enable reducing the 
number of rows that need distance computation.
-    var filteredDf = corpusDf.filter(col(embeddingCol).isNotNull)
-    filter.foreach(f => filteredDf = applyFilter(filteredDf, f))
-
-    // Validate byte corpus query vector values before creating the UDF.
-    if (elemType == ByteType) {
-      queryVector.foreach { v =>
-        if (v < Byte.MinValue || v > Byte.MaxValue)
-          throw new HoodieAnalysisException(
-            s"Query vector value $v is out of range for byte corpus 
[${Byte.MinValue}, ${Byte.MaxValue}]")
-        if (v != v.toByte.toDouble)
-          throw new HoodieAnalysisException(
-            s"Query vector value $v is not a whole number. " +
-              "Byte corpus embeddings require integer-valued query vectors.")
-      }
+    val basePath = corpusTable.basePath.getOrElse {
+      throw new HoodieAnalysisException(
+        s"Search algorithm '$name' requires a Hudi table identifier or table 
path, not a temporary view.")
+    }
+    if (!corpusDf.columns.contains(HoodieRecord.RECORD_KEY_METADATA_FIELD)) {
+      throw new HoodieAnalysisException(
+        s"Search algorithm '$name' requires Hudi meta fields, but 
'${HoodieRecord.RECORD_KEY_METADATA_FIELD}' is missing.")
     }
 
-    // The single-query UDF closes over the pre-computed query DenseVector,
-    // so only the corpus column is passed per row.
-    val distanceUdf = VectorDistanceUtils.createSingleQueryDistanceUdf(metric, 
elemType, queryVector)
-
-    var scored = filteredDf
-      .withColumn(DISTANCE_COL, distanceUdf(col(embeddingCol)))
-      .drop(embeddingCol)
-    // Apply max-distance threshold before ordering to shrink the sort input.
-    maxDistance.foreach(d => scored = scored.filter(col(DISTANCE_COL) <= d))
-    val result = scored.orderBy(col(DISTANCE_COL).asc).limit(k)
-
-    result.queryExecution.analyzed
+    val vectorSchema = extractVectorSchema(corpusDf, embeddingCol).getOrElse {
+      throw new HoodieAnalysisException(
+        s"Search algorithm '$name' requires '$embeddingCol' to carry VECTOR 
metadata.")
+    }
+    val queryFloat = queryVector.map(_.toFloat)
+
+    val contextStartNs = System.nanoTime()
+    val ctx = resolvePlanContext(spark, basePath, embeddingCol, vectorSchema, 
metric, runtimeOptions)
+    LOG.info(
+      s"[vector_search][plan_context] basePath=$basePath 
latestInstant=${ctx.latestInstantTime} " +
+        s"elapsedMs=${elapsedMs(contextStartNs)}")
+    val coveredInstant = ctx.cache.getLastContiguousSourceInstant
+    val lag = freshnessLag(
+      
ctx.timeline.getInstantsAsStream.iterator.asScala.map(_.requestedTime()).toSeq,
+      Option(coveredInstant))
+    val staleFallbackPlan = lag.map { freshnessLag =>
+      val freshnessPolicy = 
VectorIndexOptions.getFreshnessPolicy(ctx.tuningOptions.asJava)
+      val firstUnmarkedInstant = freshnessLag.firstUnmarkedInstant
+      val lagCount = freshnessLag.lagCount
+      val staleMessage =
+        s"Vector index generation ${ctx.cache.getGenerationId} covers source 
writes through " +
+          s"${Option(coveredInstant).getOrElse("<none>")} but the latest 
source data-write instant is ${ctx.latestInstantTime}; " +
+          s"first unmarked instant=$firstUnmarkedInstant, lag count=$lagCount. 
" +
+          s"Run vector-index catch-up/replay (or rebuild the index until 
replay is available) to repair the marker gap"
+      freshnessPolicy match {
+        case VectorStalePolicy.FAIL =>
+          throw new HoodieAnalysisException(staleMessage)
+        case VectorStalePolicy.WARN =>
+          LOG.warn(s"$staleMessage; continuing because 
vector.freshness.policy=WARN")
+          None
+        case VectorStalePolicy.FALLBACK =>
+          LOG.warn(s"$staleMessage; using brute-force exact fallback")
+          Some(BruteForceSearchAlgorithm.buildSingleQueryPlan(
+            spark, corpusTable, embeddingCol, queryVector, k, metric, 
runtimeOptions))
+      }
+    }.flatten
+    staleFallbackPlan.getOrElse {
+      if (ctx.cache.getDimension != vectorSchema.getDimension) {
+        throw new HoodieAnalysisException(
+          s"Vector index dimension (${ctx.cache.getDimension}) does not match 
column '$embeddingCol' dimension (${vectorSchema.getDimension}).")
+      }
+      val approxMode = 
VectorIndexOptions.isApproximateSearchMode(ctx.tuningOptions.asJava)
+
+      if (approxMode) {
+        buildApproximatePlan(spark, ctx, queryFloat, k)
+      } else {
+        val exactOutputSchema = StructType(
+          
corpusDf.schema.fields.filterNot(_.name.equalsIgnoreCase(embeddingCol)) :+
+            StructField(DISTANCE_COL, DoubleType, nullable = false))
+        val candidateDf = VectorExactSearchPlanner.buildRaBitQExactCandidateDf(
+          spark, ctx, exactOutputSchema, embeddingCol, vectorSchema, 
queryFloat, queryVector, k)
+
+        candidateDf
+          .orderBy(col(DISTANCE_COL).asc)
+          .limit(k)
+          .queryExecution.analyzed
+      }
+    }
   }
 
   override def buildBatchQueryPlan(
       spark: SparkSession,
-      corpusDf: DataFrame,
+      corpusTable: VectorSearchTable,
       corpusEmbeddingCol: String,
       queryDf: DataFrame,
       queryEmbeddingCol: String,
       k: Int,
       metric: DistanceMetric.Value,
-      filter: Option[String],
-      maxDistance: Option[Double]): LogicalPlan = {
-    validateEmbeddingColumn(corpusDf, corpusEmbeddingCol)
-    validateEmbeddingColumn(queryDf, queryEmbeddingCol)
-    validateElementTypeCompatibility(corpusDf, corpusEmbeddingCol, queryDf, 
queryEmbeddingCol)
-    validateBatchDimensions(corpusDf, corpusEmbeddingCol, queryDf, 
queryEmbeddingCol)
-
-    val corpusElemType = getElementType(corpusDf, corpusEmbeddingCol)
-    val distanceUdf = VectorDistanceUtils.createDistanceUdf(metric, 
corpusElemType)
-    // Apply pre-filter before the cross-join to enable Hudi partition pruning 
and
-    // data skipping, reducing the cross-join cardinality.
-    var filteredCorpus = corpusDf.filter(col(corpusEmbeddingCol).isNotNull)
-    filter.foreach(f => filteredCorpus = applyFilter(filteredCorpus, f))
-
-    // Prefix clashing query columns with "_hudi_query_" to avoid cross-join 
ambiguity when
-    // corpus and query share column names (e.g. both have "id" or 
"embedding").
-    val corpusCols = filteredCorpus.columns.toSet
-    val queryWithId = queryDf.filter(col(queryEmbeddingCol).isNotNull)
-      .withColumnRenamed(queryEmbeddingCol, QUERY_EMB_ALIAS)
-      .withColumn(QUERY_ID_COL, monotonically_increasing_id())
-
-    val renamedQuery = queryWithId.select(queryWithId.columns.map { qCol =>
-      if (qCol != QUERY_ID_COL && qCol != QUERY_EMB_ALIAS && 
corpusCols.contains(qCol))
-        col(qCol).as(s"$QUERY_COL_PREFIX$qCol")
-      else
-        col(qCol)
-    }: _*)
-
-    // Cross join corpus with broadcast queries, compute distance, then rank
-    var scored = filteredCorpus.crossJoin(broadcast(renamedQuery))
-      .withColumn(DISTANCE_COL,
-        distanceUdf(col(corpusEmbeddingCol), col(QUERY_EMB_ALIAS)))
-      .drop(corpusEmbeddingCol)
-      .drop(QUERY_EMB_ALIAS)
-
-    // Apply max-distance threshold before windowing to reduce shuffle volume.
-    maxDistance.foreach(d => scored = scored.filter(col(DISTANCE_COL) <= d))
-
-    val window = 
Window.partitionBy(QUERY_ID_COL).orderBy(col(DISTANCE_COL).asc)
-    val result = scored
-      .withColumn(RANK_COL, row_number().over(window))
-      .filter(col(RANK_COL) <= k)
-      .drop(RANK_COL)
-      .orderBy(col(QUERY_ID_COL), col(DISTANCE_COL))
-
-    result.queryExecution.analyzed
+      runtimeOptions: Map[String, String] = Map.empty): LogicalPlan = {
+    throw new HoodieAnalysisException(
+      s"Search algorithm '$name' currently supports single-query 
hudi_vector_search only.")
+  }
+
+  /**
+   * Approximate-only path: scores postings via RaBitQ and returns
+   * (record_key, partition_path, file_group_id, approx_distance) without
+   * reading the base table at all.
+   */
+  private def buildApproximatePlan(
+      spark: SparkSession,
+      ctx: VectorSearchPlanContext,
+      queryVector: Array[Float],
+      k: Int): LogicalPlan = {
+    import spark.implicits._
+    val planStartNs = System.nanoTime()
+    val cache = ctx.cache
+    val mergedOptions = ctx.tuningOptions
+    val metric = toVectorMetric(ctx.metric)
+    val residualEncoding = cache.isResidualEncoding
+    val numProbes = math.min(math.max(1, 
VectorIndexOptions.getNumProbes(mergedOptions.asJava)), cache.numClusters())
+    val centroidProbeStartNs = System.nanoTime()
+    val topClusters = cache.findTopClusters(queryVector, numProbes, metric)
+    val centroidProbeMs = elapsedMs(centroidProbeStartNs)
+    val shardLookupStartNs = System.nanoTime()
+    val shardCounts = cache.getShardCounts(topClusters)
+    val shardLookupMs = elapsedMs(shardLookupStartNs)
+    // Approximate-only search does not exact-rerank, so applying 
refine_factor here only bloats
+    // every task-local heap and both global reductions. Keep a small floor 
because local pruning
+    // precedes record-key overlay and tombstone removal; retaining exactly k 
could underfill results
+    // when duplicate versions occupy the local winners.
+    val candidateHeapSize = approximateCandidateHeapSize(k)
+    val postingPlanStartNs = System.nanoTime()
+    val topCandidates = VectorIndexMdtSearchUtils.scanPostingCandidates(
+      ctx.metadataTable, ctx.indexPartition, cache.getGenerationId, 
shardCounts,
+      queryVector, cache.getDimension, cache.getQuantizerSeed, 
cache.getRaBitQBits, cache.isAssumeNormalized,
+      metric, 
VectorIndexOptions.isRaBitQAsymmetricScoring(mergedOptions.asJava),
+      residualEncoding, if (residualEncoding) cache.getCentroids else null, 
candidateHeapSize)
+    val postingPlanMs = elapsedMs(postingPlanStartNs)
+    val resultRddPlanStartNs = System.nanoTime()
+    val resultSchema = new StructType(Array(
+      StructField(HoodieRecord.RECORD_KEY_METADATA_FIELD, 
org.apache.spark.sql.types.DataTypes.StringType, nullable = false, 
Metadata.empty),
+      StructField(HoodieRecord.PARTITION_PATH_METADATA_FIELD, 
org.apache.spark.sql.types.DataTypes.StringType, nullable = true, 
Metadata.empty),
+      StructField(FILE_GROUP_ID_COL, 
org.apache.spark.sql.types.DataTypes.StringType, nullable = true, 
Metadata.empty),
+      StructField(DISTANCE_COL, 
org.apache.spark.sql.types.DataTypes.DoubleType, nullable = false, 
Metadata.empty)
+    ))
+    // RFC-109 RLI finalist arbiter (freshness gate). Default-off: dormant 
until the commit-time
+    // delta writer lands. When enabled, exclude STALE (rewritten/moved) and 
DELETED finalists so
+    // approx candidate-gen never serves a posting that no longer faithfully 
represents a live row.
+    val arbiterEnabled = 
VectorIndexOptions.isFinalistArbiterEnabled(mergedOptions.asJava)
+    val candidateRdd: RDD[ScoredVectorPostingMatch] =
+      if (arbiterEnabled) {
+        val arbitrated = VectorIndexMdtSearchUtils.arbitrateFinalists(
+          ctx.metadataTable, topCandidates, isPartitionedRecordIndex(ctx))
+        val staleAcc: LongAccumulator = 
spark.sparkContext.longAccumulator("vector_arbiter_stale")
+        val deletedAcc: LongAccumulator = 
spark.sparkContext.longAccumulator("vector_arbiter_deleted")
+        HoodieJavaRDD.getJavaRDD(arbitrated).rdd.mapPartitions { it =>
+          var stale = 0L
+          var deleted = 0L
+          val kept = ArrayBuffer.empty[ScoredVectorPostingMatch]
+          it.foreach { c =>
+            approximateArbitratedCandidate(c) match {
+              case Some(candidate) => kept += candidate
+              case None if c.getArbiterDecision == 
VectorIndexArbiter.Decision.STALE => stale += 1L
+              case None => deleted += 1L
+            }
+          }
+          staleAcc.add(stale)
+          deletedAcc.add(deleted)
+          if (stale > 0L || deleted > 0L) {
+            LOG.info(s"[vector_search][approximate][arbiter] 
arbiterExclusions{stale=$stale, deleted=$deleted} kept=${kept.size}")
+          }
+          kept.iterator
+        }
+      } else {
+        HoodieJavaRDD.getJavaRDD(topCandidates).rdd
+      }
+    val resultRows = candidateRdd.map { c =>
+      val liveLocation = Option(c.getLocation)
+      Row(
+        c.getRecordKey,
+        
liveLocation.map(_.getPartitionPath).orElse(Option(c.getPartitionPath)).getOrElse(""),
+        
liveLocation.map(_.getFileId).orElse(Option(c.getFileGroupId)).getOrElse(""),
+        c.getApproxDistance.toDouble
+      )
+    }
+    val resultRddPlanMs = elapsedMs(resultRddPlanStartNs)
+    val sparkAnalysisStartNs = System.nanoTime()
+    val analyzedPlan = spark.createDataFrame(resultRows, resultSchema)
+      .orderBy(col(DISTANCE_COL).asc)
+      .limit(k)
+      .queryExecution.analyzed
+    val sparkAnalysisMs = elapsedMs(sparkAnalysisStartNs)
+    LOG.info(
+      s"[vector_search][approximate] basePath=${ctx.basePath} 
probedClusters=${topClusters.length} " +
+        s"candidateHeapSize=$candidateHeapSize 
residualEncoding=$residualEncoding arbiter=$arbiterEnabled " +
+        s"centroidProbeMs=$centroidProbeMs shardLookupMs=$shardLookupMs 
postingPlanMs=$postingPlanMs " +
+        s"resultRddPlanMs=$resultRddPlanMs sparkAnalysisMs=$sparkAnalysisMs 
planMs=${elapsedMs(planStartNs)}")
+    analyzedPlan
+  }
+
+  private def resolveVectorIndexDefinition(
+      metaClient: HoodieTableMetaClient,
+      embeddingCol: String): (String, HoodieIndexDefinition) = {
+    if (!metaClient.getIndexMetadata.isPresent) {
+      throw new HoodieAnalysisException("No Hudi index metadata is available 
for the table.")
+    }
+    val matches = metaClient.getIndexMetadata.get.getIndexDefinitions.asScala
+      .filter { case (_, definition) =>
+        definition.getIndexType == 
HoodieTableMetadataUtil.PARTITION_NAME_VECTOR_INDEX
+      }
+      .filter { case (_, definition) =>
+        
Option(definition.getSourceFields).exists(_.asScala.exists(_.equalsIgnoreCase(embeddingCol)))
+      }
+      .toSeq
+      .sortBy(_._1)
+    matches match {
+      case Seq() =>
+        throw new HoodieAnalysisException(
+          s"No vector index found for embedding column '$embeddingCol'.")
+      case Seq(single) =>
+        single
+      case multiple =>
+        throw new HoodieAnalysisException(
+          s"Multiple vector indexes found for embedding column 
'$embeddingCol': ${multiple.map(_._1).mkString(", ")}. " +
+            "Specify the index explicitly or drop the stale index.")
+    }
+  }
+
+  private case class MetadataCacheKey(basePath: String, indexPartition: String)
+
+  @volatile private var metadataCaches: Map[MetadataCacheKey, 
VectorIndexMetadataCache] = Map.empty
+
+  private[analysis] def resetMetadataCaches(): Unit = {
+    metadataCaches = Map.empty
   }
 
+  private[analysis] def metadataCacheSize: Int = metadataCaches.size
+
+  private def getOrLoadMetadataCache(
+      metaClient: HoodieTableMetaClient,
+      metadataTable: HoodieTableMetadata,
+      indexPartition: String,
+      vectorSchema: HoodieSchema.Vector,
+      currentInstant: String): VectorIndexMetadataCache = {
+    val cacheKey = MetadataCacheKey(metaClient.getBasePath.toString, 
indexPartition)
+
+    metadataCaches.synchronized {

Review Comment:
   **Fixed** `3cde5283ea`. Loads/replacement go through a dedicated 
`metadataCachesLock`; the map is immutable and published via `@volatile`.



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieVectorSearchPlanBuilder.scala:
##########
@@ -17,365 +17,473 @@
 
 package org.apache.spark.sql.hudi.analysis
 
+import org.apache.hudi.SparkAdapterSupport
+import org.apache.hudi.client.common.HoodieSparkEngineContext
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.index.vector.{ScoredVectorPostingMatch, 
VectorDistanceMetric, VectorIndexArbiter, VectorIndexMdtSearchUtils, 
VectorIndexMetadataCache, VectorIndexOptions, VectorPostingMatch, 
VectorStalePolicy}
+import org.apache.hudi.common.model.{FileSlice, HoodieIndexDefinition, 
HoodieRecord}
 import org.apache.hudi.common.schema.HoodieSchema
-
-import org.apache.spark.sql.{AnalysisException, DataFrame, SparkSession}
+import org.apache.hudi.common.table.{HoodieTableMetaClient, 
TableSchemaResolver}
+import org.apache.hudi.common.table.timeline.{HoodieTimeline, 
InstantComparison}
+import 
org.apache.hudi.common.table.timeline.InstantComparison.compareTimestamps
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView
+import org.apache.hudi.data.HoodieJavaRDD
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+import org.apache.hudi.metadata.{HoodieTableMetadata, HoodieTableMetadataUtil}
+
+import org.apache.spark.api.java.JavaSparkContext
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SparkSession}
+import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.parser.ParseException
+import 
org.apache.spark.sql.catalyst.plans.logical.HoodieVectorSearchTableValuedFunction
 import 
org.apache.spark.sql.catalyst.plans.logical.HoodieVectorSearchTableValuedFunction.{DistanceMetric,
 SearchAlgorithm}
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.expressions.Window
 import org.apache.spark.sql.functions.{broadcast, col, 
monotonically_increasing_id, row_number}
 import org.apache.spark.sql.hudi.command.exception.HoodieAnalysisException
-import org.apache.spark.sql.types.{ArrayType, ByteType, DataType, DoubleType, 
FloatType}
+import org.apache.spark.sql.types.{ArrayType, ByteType, DataType, DoubleType, 
FloatType, Metadata, StructField, StructType}
+import org.apache.spark.util.LongAccumulator
+import org.slf4j.LoggerFactory
 
+import scala.collection.JavaConverters._
+import scala.collection.mutable.ArrayBuffer
 import scala.util.{Failure, Success, Try}
 
 /**
- * Extension point for vector search algorithms. Each implementation provides
- * the Spark logical plan for single-query and batch-query KNN search.
- *
- * To add a new algorithm (e.g. RowMatrix, HNSW):
- *  1. Create an object extending this trait
- *  2. Add a value to [[SearchAlgorithm]]
- *  3. Register the mapping in 
[[HoodieVectorSearchPlanBuilder.resolveAlgorithm]]
+ * IVF + RaBitQ vector search backed by native metadata-table posting prefix 
lookups.
  *
- * Implementations can use the shared validation helpers on
- * [[HoodieVectorSearchPlanBuilder]] (validateEmbeddingColumn, 
validateBatchDimensions, etc.)
- * and the raw distance functions on [[VectorDistanceUtils]].
- *
- * The output schema contract:
- *  - Single-query: all corpus columns (minus the embedding column) + 
`_hudi_distance: Double`
- *  - Batch-query: all corpus columns (minus the embedding column) + clashing 
query columns
- *    (prefixed with `_hudi_query_`) + `_hudi_distance: Double` + 
`_hudi_query_index: Long`
- *  - Results are ordered by `_hudi_distance` ascending (lower = more similar)
- *  - `_hudi_query_index` is an opaque grouping identifier (not a sequential 
index). Values may be
- *    large non-contiguous numbers because they are generated by 
`monotonically_increasing_id()`.
+ * The algorithm keeps Spark SQL out of the posting scan: it probes centroids, 
converts
+ * the selected clusters to MDT posting prefixes, reads posting rows with 
HFile-friendly
+ * prefix lookups, reduces with RaBitQ approximate scores before 
record-location lookup,
+ * then exact-reranks the fetched Hudi rows.
  */
-trait VectorSearchAlgorithm {
+object IvfRaBitQMdtSearchAlgorithm extends VectorSearchAlgorithm with 
SparkAdapterSupport {
+  private val APPROXIMATE_CANDIDATE_FLOOR = 32
 
-  /** Human-readable name for error messages and logging. */
-  def name: String
+  private[analysis] def approximateCandidateHeapSize(k: Int): Int =
+    math.max(k, APPROXIMATE_CANDIDATE_FLOOR)
 
-  /**
-   * Build a plan that finds the k nearest corpus rows to a single query 
vector.
-   *
-   * @param spark          active SparkSession
-   * @param corpusDf       resolved corpus DataFrame (may be Hudi, Parquet, or 
temp view)
-   * @param embeddingCol   name of the array-typed embedding column in corpusDf
-   * @param queryVector    the query vector, normalized to Array[Double]
-   * @param k              number of nearest neighbors to return
-   * @param metric         distance metric (COSINE, L2, DOT_PRODUCT)
-   * @param filter         optional SQL predicate applied to the corpus before 
distance computation,
-   *                       String literals inside the predicate must use 
double quotes (e.g. {@code label = "z-axis"})
-   *                       because the filter is passed as a single-quoted TVF 
argument and
-   *                       Spark's lexer does not support {@code ''} as an 
escape sequence
-   *                       inside string literals.
-   * @param maxDistance    optional distance threshold; results exceeding this 
value are excluded
-   * @return an analyzed LogicalPlan whose output matches the single-query 
schema contract
-   */
-  def buildSingleQueryPlan(
-      spark: SparkSession,
-      corpusDf: DataFrame,
-      embeddingCol: String,
-      queryVector: Array[Double],
-      k: Int,
-      metric: DistanceMetric.Value,
-      filter: Option[String] = None,
-      maxDistance: Option[Double] = None): LogicalPlan
-
-  /**
-   * Build a plan that finds the k nearest corpus rows for each row in the 
query table.
-   *
-   * @param spark              active SparkSession
-   * @param corpusDf           resolved corpus DataFrame
-   * @param corpusEmbeddingCol name of the embedding column in corpusDf
-   * @param queryDf            resolved query DataFrame
-   * @param queryEmbeddingCol  name of the embedding column in queryDf
-   * @param k                  number of nearest neighbors per query
-   * @param metric             distance metric (COSINE, L2, DOT_PRODUCT)
-   * @param filter             optional SQL predicate applied to the corpus 
before distance
-   *                           computation, and shrinking cross-join 
cardinality. Applied to the
-   *                           corpus only — to restrict the query side, apply 
a projection or
-   *                           filter on the query table before invoking the 
TVF. See
-   *                           [[buildSingleQueryPlan]] for quoting 
requirements.
-   * @param maxDistance        optional distance threshold; results exceeding 
this value are
-   *                           excluded before per-query top-K selection, 
reducing shuffle volume.
-   * @return an analyzed LogicalPlan whose output matches the batch-query 
schema contract
-   * @note Batch mode broadcasts the query table to all executors via a 
cross-join.
-   *       This is designed for small-to-medium query sets (tens to low 
hundreds of rows).
-   *       For large query tables, memory pressure on executors may occur.
-   */
-  def buildBatchQueryPlan(
-      spark: SparkSession,
-      corpusDf: DataFrame,
-      corpusEmbeddingCol: String,
-      queryDf: DataFrame,
-      queryEmbeddingCol: String,
-      k: Int,
-      metric: DistanceMetric.Value,
-      filter: Option[String] = None,
-      maxDistance: Option[Double] = None): LogicalPlan
-}
+  import HoodieVectorSearchPlanBuilder._
 
-/**
- * Resolves [[SearchAlgorithm]] values to [[VectorSearchAlgorithm]] 
implementations
- * and provides shared validation helpers used across algorithms.
- */
-object HoodieVectorSearchPlanBuilder {
-
-  val DISTANCE_COL = "_hudi_distance"
-  private[analysis] val QUERY_ID_COL = "_hudi_query_index"
-  private[analysis] val QUERY_EMB_ALIAS = "_hudi_query_emb"
-  private[analysis] val RANK_COL = "_hudi_rank"
-  private[analysis] val QUERY_COL_PREFIX = "_hudi_query_"
-
-  /** Resolve a [[SearchAlgorithm]] enum value to its implementation. */
-  def resolveAlgorithm(algorithm: SearchAlgorithm.Value): 
VectorSearchAlgorithm = algorithm match {
-    case SearchAlgorithm.BRUTE_FORCE => BruteForceSearchAlgorithm
-    case other => throw new HoodieAnalysisException(
-      s"Unsupported search algorithm: $other")
+  private[analysis] def isLogResidentCandidate(
+      candidate: ScoredVectorPostingMatch,
+      currentBaseInstant: String,
+      sliceHasLogs: Boolean): Boolean = {
+    // A negative position is the authoritative log-resident signal: base-file 
records always carry a
+    // non-negative row position, so a candidate with position < 0 in a slice 
that still has log files
+    // lives in a log block and cannot be materialized from the base Parquet. 
The candidate's instant
+    // is NOT a reliable discriminator here -- a log delta can share the 
slice's base instant time.
+    val location = candidate.getLocation
+    sliceHasLogs && location != null && location.getPosition < 0
   }
 
-  private[analysis] def validateEmbeddingColumn(df: DataFrame, colName: 
String): Unit = {
-    val fieldOpt = df.schema.fields.find(_.name.equalsIgnoreCase(colName))
-    val field = fieldOpt.getOrElse(
-      throw new HoodieAnalysisException(
-        s"Embedding column '$colName' not found in table schema. " +
-          s"Available columns: ${df.schema.fieldNames.mkString(", ")}"))
-    field.dataType match {
-      case ArrayType(FloatType, _) | ArrayType(DoubleType, _) | 
ArrayType(ByteType, _) => // valid
-      case other =>
-        throw new HoodieAnalysisException(
-          s"Embedding column '$colName' has type $other, " +
-            "expected array<float>, array<double>, or array<byte>")
+  private[analysis] def approximateArbitratedCandidate(
+      candidate: ScoredVectorPostingMatch): Option[ScoredVectorPostingMatch] =
+    candidate.getArbiterDecision match {
+      case VectorIndexArbiter.Decision.SERVE => Some(candidate)
+      // A delta carries the updated vector code. Refreshing only its locator 
is safe and preserves
+      // the new score; a stale packed-block row carries old code and must 
never be resurrected.
+      case VectorIndexArbiter.Decision.STALE if candidate.isDelta => 
Some(candidate)
+      case _ => None
     }
+
+  private[analysis] case class FreshnessLag(firstUnmarkedInstant: String, 
lagCount: Int)
+
+  private[analysis] def completedSourceWriteTimeline(timeline: 
HoodieTimeline): HoodieTimeline =
+    timeline.getWriteTimeline.filterCompletedInstants()
+
+  private[analysis] def freshnessLag(
+      completedWriteInstants: Seq[String],
+      coveredInstant: Option[String]): Option[FreshnessLag] = {
+    val uncovered = completedWriteInstants.filter(instant => 
coveredInstant.forall(covered =>
+      compareTimestamps(covered, InstantComparison.LESSER_THAN, instant)))
+    uncovered.headOption.map(first => FreshnessLag(first, uncovered.size))
   }
 
-  /**
-   * Validates that the query vector dimension matches the corpus embedding 
dimension
-   * when the corpus column has VECTOR(dim) metadata.
-   */
-  private[analysis] def validateQueryVectorDimension(
-      df: DataFrame, embeddingCol: String, queryDim: Int): Unit = {
-    extractVectorDimension(df, embeddingCol).foreach { corpusDim =>
-      if (corpusDim != queryDim) {
-        throw new HoodieAnalysisException(
-          s"Query vector dimension ($queryDim) does not match " +
-            s"corpus embedding dimension ($corpusDim) for column 
'$embeddingCol'")
+  override val name: String = "ivf_rabitq_mdt"
+
+  private[analysis] case class VectorSearchPlanContext(
+      basePath: String,
+      metaClient: HoodieTableMetaClient,
+      timeline: HoodieTimeline,
+      latestInstantTime: String,
+      indexPartition: String,
+      indexDefinition: HoodieIndexDefinition,
+      metadataTable: HoodieTableMetadata,
+      cache: VectorIndexMetadataCache,
+      tuningOptions: Map[String, String],
+      metric: DistanceMetric.Value)
+
+  private[analysis] def isPartitionedRecordIndex(ctx: 
VectorSearchPlanContext): Boolean =
+    false
+
+  private val STRUCTURAL_OPTION_KEYS: Set[String] = Set(
+    VectorIndexOptions.METRIC,
+    VectorIndexOptions.QUANTIZER,
+    VectorIndexOptions.RABITQ_BITS,
+    VectorIndexOptions.RABITQ_SEED,
+    VectorIndexOptions.RABITQ_ASSUME_NORMALIZED)
+
+  private[analysis] def resolvedTuningOptions(
+      indexOptions: Map[String, String],
+      runtimeOptions: Map[String, String]): Map[String, String] = {
+    val inherited = indexOptions
+      .filterNot { case (key, _) => STRUCTURAL_OPTION_KEYS.contains(key) }
+      // An explicit runtime mode change gets that mode's freshness default 
unless the caller also
+      // overrides freshness. Otherwise a persisted exact-mode default (FAIL) 
would leak into an
+      // approximate query whose contract default is WARN.
+      .filterNot { case (key, _) =>
+        key == VectorIndexOptions.FRESHNESS_POLICY &&
+          runtimeOptions.contains(VectorIndexOptions.QUERY_MODE) &&
+          !runtimeOptions.contains(VectorIndexOptions.FRESHNESS_POLICY)
       }
-    }
+    inherited ++ runtimeOptions
   }
 
-  /**
-   * Validates that corpus and query embedding columns have the same element 
type.
-   *
-   * This is a deliberate design choice: we require strict type matching 
rather than
-   * silently upcasting (e.g. float to double). While some vector search 
systems
-   * auto-convert, strict matching avoids subtle precision differences and 
makes
-   * type mismatches between pipelines explicit. This can be relaxed to support
-   * automatic widening (byte -> float -> double) in a future iteration if 
needed.
-   */
-  private[analysis] def validateElementTypeCompatibility(
-      corpusDf: DataFrame, corpusCol: String,
-      queryDf: DataFrame, queryCol: String): Unit = {
-    val corpusElemType = getElementType(corpusDf, corpusCol)
-    val queryElemType = getElementType(queryDf, queryCol)
-    if (corpusElemType != queryElemType) {
+  private def resolvePlanContext(
+      spark: SparkSession,
+      basePath: String,
+      embeddingCol: String,
+      vectorSchema: HoodieSchema.Vector,
+      callerMetric: DistanceMetric.Value,
+      runtimeOptions: Map[String, String]): VectorSearchPlanContext = {
+    val illegal = runtimeOptions.keySet.intersect(STRUCTURAL_OPTION_KEYS)

Review Comment:
   **Fixed** `3cde5283ea`. Unknown query-time keys are rejected; the allow-list 
is shared with CREATE INDEX validation (`VectorIndexOptions.QUERY_OPTIONS`).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to