This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 1352504  feat(index): Support vector index rerank (#493)
1352504 is described below

commit 13525049ed3f4f88ae194435092c18d220cc8bf3
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 9 23:30:50 2026 +0800

    feat(index): Support vector index rerank (#493)
---
 crates/paimon/src/table/vector_search_builder.rs | 670 +++++++++++++++++++++--
 crates/paimon/src/vector_search.rs               |  90 ++-
 docs/src/sql.md                                  |  64 +++
 3 files changed, 755 insertions(+), 69 deletions(-)

diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 2384ce9..8d75c49 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -35,7 +35,9 @@ use arrow_array::{Array, FixedSizeListArray, Float32Array, 
Int64Array, ListArray
 use futures::TryStreamExt;
 use paimon_vindex_core::distance::MetricType;
 use paimon_vindex_core::index::VectorIndexReader as VIndexReader;
-use std::collections::{HashMap, HashSet};
+use roaring::RoaringTreemap;
+use std::cmp::Ordering;
+use std::collections::{BinaryHeap, HashMap, HashSet};
 use std::io::Cursor;
 
 const INDEX_DIR: &str = "index";
@@ -350,6 +352,22 @@ async fn evaluate_batch_vector_search(
         None
     };
 
+    let max_limit = vector_searches
+        .iter()
+        .map(|vector_search| vector_search.limit)
+        .max()
+        .unwrap_or(0);
+    let refine_factor = match vector_entries.first() {
+        Some(entry) => configured_refine_factor(
+            &search_options,
+            evaluation.table_options,
+            field_name,
+            &entry.index_file.index_type,
+        )?,
+        None => 0,
+    };
+    let index_search_limit = indexed_search_limit(max_limit, refine_factor)?;
+
     let mut merged = vec![SearchResult::empty(); vector_searches.len()];
     if !vector_entries.is_empty() {
         let futures: Vec<_> = vector_entries
@@ -364,17 +382,13 @@ async fn evaluate_batch_vector_search(
                 let index_meta_bytes = 
global_meta.index_meta.clone().unwrap_or_default();
                 let row_range_start = global_meta.row_range_start;
                 let row_range_end = global_meta.row_range_end;
-                let max_limit = vector_searches
-                    .iter()
-                    .map(|vector_search| vector_search.limit)
-                    .max()
-                    .unwrap_or(0);
                 let index_limit = search_limit_with_deleted_rows(
-                    max_limit,
+                    index_search_limit,
                     row_range_start,
                     row_range_end,
                     deleted_row_index.as_ref(),
-                );
+                )
+                .min(i32::MAX as usize);
                 let mut vector_searches = vector_searches.to_vec();
                 for vector_search in &mut vector_searches {
                     vector_search.limit = index_limit;
@@ -444,6 +458,19 @@ async fn evaluate_batch_vector_search(
         }
     }
 
+    if refine_factor != 0 {
+        merged = maybe_rerank_indexed_batch_results(
+            evaluation,
+            index_entries,
+            field_id,
+            field_name,
+            vector_searches,
+            merged,
+            index_search_limit,
+        )
+        .await?;
+    }
+
     if search_mode != GlobalIndexSearchMode::Fast {
         let detail_ranges = if search_mode == GlobalIndexSearchMode::Detail {
             let table = evaluation.table.ok_or_else(|| 
crate::Error::DataInvalid {
@@ -500,6 +527,110 @@ fn is_vector_global_index_file(index_file: 
&IndexFileMeta) -> bool {
     VectorIndexBackend::from_index_type(&index_file.index_type).is_some()
 }
 
+fn indexed_search_limit(limit: usize, refine_factor: usize) -> 
crate::Result<usize> {
+    if refine_factor == 0 {
+        return Ok(limit);
+    }
+    let search_limit =
+        limit
+            .checked_mul(refine_factor)
+            .ok_or_else(|| crate::Error::ConfigInvalid {
+                message: format!(
+                    "Vector search limit overflow: limit={limit}, refine 
factor={refine_factor}"
+                ),
+            })?;
+    if search_limit > i32::MAX as usize {
+        return Err(crate::Error::ConfigInvalid {
+            message: format!(
+                "Vector search limit overflow: limit={limit}, refine 
factor={refine_factor}"
+            ),
+        });
+    }
+    Ok(search_limit)
+}
+
+async fn maybe_rerank_indexed_batch_results(
+    evaluation: VectorSearchEvaluation<'_>,
+    index_entries: &[IndexManifestEntry],
+    field_id: i32,
+    field_name: &str,
+    vector_searches: &[VectorSearch],
+    results: Vec<SearchResult>,
+    index_search_limit: usize,
+) -> crate::Result<Vec<SearchResult>> {
+    let mut candidate_searches = Vec::with_capacity(vector_searches.len());
+    let mut candidate_results = Vec::with_capacity(vector_searches.len());
+    let mut union_candidates = RoaringTreemap::new();
+
+    for (result, vector_search) in results.into_iter().zip(vector_searches) {
+        let candidates = result.top_k(index_search_limit);
+        let mut include_row_ids = RoaringTreemap::new();
+        for &row_id in &candidates.row_ids {
+            include_row_ids.insert(row_id);
+            union_candidates.insert(row_id);
+        }
+
+        let mut candidate_search = vector_search.clone();
+        candidate_search.include_row_ids = Some(include_row_ids);
+        candidate_searches.push(candidate_search);
+        candidate_results.push(candidates);
+    }
+
+    if union_candidates.iter().next().is_none() {
+        return Ok(candidate_results);
+    }
+
+    let table = evaluation.table.ok_or_else(|| crate::Error::DataInvalid {
+        message: "Vector index rerank requires table context".to_string(),
+        source: None,
+    })?;
+    let raw_ranges = sorted_row_ids_to_row_ranges(union_candidates.iter())?;
+    let metric = resolve_raw_vector_metric(
+        evaluation.file_io,
+        evaluation.table_path.trim_end_matches('/'),
+        evaluation.table_options,
+        index_entries,
+        field_id,
+        field_name,
+    )
+    .await?;
+
+    read_raw_batch_vector_search(table, &candidate_searches, &raw_ranges, 
metric).await
+}
+
+fn sorted_row_ids_to_row_ranges(
+    row_ids: impl IntoIterator<Item = u64>,
+) -> crate::Result<Vec<RowRange>> {
+    let mut row_ids = row_ids.into_iter();
+    let Some(first) = row_ids.next() else {
+        return Ok(Vec::new());
+    };
+    let mut start = row_id_to_i64_for_range(first)?;
+    let mut end = start;
+    let mut ranges = Vec::new();
+    for row_id in row_ids {
+        let row_id = row_id_to_i64_for_range(row_id)?;
+        if end.checked_add(1) == Some(row_id) {
+            end = row_id;
+        } else {
+            ranges.push(RowRange::new(start, end));
+            start = row_id;
+            end = row_id;
+        }
+    }
+    ranges.push(RowRange::new(start, end));
+    Ok(ranges)
+}
+
+fn row_id_to_i64_for_range(row_id: u64) -> crate::Result<i64> {
+    i64::try_from(row_id).map_err(|_| crate::Error::DataInvalid {
+        message: format!(
+            "Vector search row id {row_id} exceeds i64::MAX and cannot be 
converted to RowRange"
+        ),
+        source: None,
+    })
+}
+
 async fn detail_data_ranges_for_table(table: &Table) -> 
crate::Result<Vec<RowRange>> {
     let plan = table
         .new_read_builder()
@@ -563,6 +694,80 @@ fn normalize_metric(metric: &str) -> String {
     metric.to_ascii_lowercase().replace('-', "_")
 }
 
+fn indexed_type_prefixes(field_name: &str, index_type: &str) -> Vec<String> {
+    let mut prefixes = Vec::new();
+    add_refine_prefixes(&mut prefixes, &format!("fields.{field_name}."), 
index_type);
+    add_refine_prefixes(&mut prefixes, "", index_type);
+    prefixes
+}
+
+fn add_refine_prefixes(prefixes: &mut Vec<String>, base: &str, index_type: 
&str) {
+    if !index_type.is_empty() {
+        prefixes.push(format!("{base}{index_type}."));
+        let normalized = normalize_metric(index_type);
+        if normalized != index_type {
+            prefixes.push(format!("{base}{normalized}."));
+        }
+        if normalized.starts_with("ivf") {
+            prefixes.push(format!("{base}ivf."));
+        }
+    }
+    prefixes.push(base.to_string());
+}
+
+fn configured_refine_factor(
+    search_options: &HashMap<String, String>,
+    table_options: &HashMap<String, String>,
+    field_name: &str,
+    index_type: &str,
+) -> crate::Result<usize> {
+    if let Some(value) =
+        configured_refine_factor_from_options(search_options, field_name, 
index_type)
+    {
+        return parse_refine_factor(&value);
+    }
+    if let Some(value) =
+        configured_refine_factor_from_options(table_options, field_name, 
index_type)
+    {
+        return parse_refine_factor(&value);
+    }
+    Ok(0)
+}
+
+fn configured_refine_factor_from_options(
+    options: &HashMap<String, String>,
+    field_name: &str,
+    index_type: &str,
+) -> Option<String> {
+    for prefix in indexed_type_prefixes(field_name, index_type) {
+        for suffix in [
+            "refine_factor",
+            "refine-factor",
+            "rerank_factor",
+            "rerank-factor",
+        ] {
+            if let Some(value) = options.get(&(prefix.clone() + suffix)) {
+                return Some(value.trim().to_string());
+            }
+        }
+    }
+    None
+}
+
+fn parse_refine_factor(value: &str) -> crate::Result<usize> {
+    let factor = value
+        .parse::<usize>()
+        .map_err(|_| crate::Error::ConfigInvalid {
+            message: format!("Invalid vector refine factor: {value}. Must be 
an integer."),
+        })?;
+    if factor == 0 {
+        return Err(crate::Error::ConfigInvalid {
+            message: format!("Vector refine factor must be positive, got: 
{value}"),
+        });
+    }
+    Ok(factor)
+}
+
 async fn resolve_raw_vector_metric(
     file_io: &FileIO,
     table_path: &str,
@@ -695,33 +900,155 @@ async fn read_raw_batch_vector_search(
     let read = read_builder.new_read()?;
     let mut stream = read.to_arrow(plan.splits())?;
 
-    let mut row_ids = vec![Vec::new(); vector_searches.len()];
-    let mut scores = vec![Vec::new(); vector_searches.len()];
+    let scoring_plan = RawScoringPlan::new(vector_searches, metric);
+    let mut top_k = vector_searches
+        .iter()
+        .map(|vector_search| RawScoreTopK::new(vector_search.limit))
+        .collect::<Vec<_>>();
     while let Some(batch) = stream.try_next().await? {
-        collect_raw_batch_vector_batch(&batch, vector_searches, metric, &mut 
row_ids, &mut scores)?;
+        collect_raw_batch_vector_batch(&batch, vector_searches, metric, 
&scoring_plan, &mut top_k)?;
     }
 
-    Ok(row_ids
+    Ok(top_k
         .into_iter()
-        .zip(scores)
-        .zip(vector_searches)
-        .map(|((row_ids, scores), vector_search)| {
-            SearchResult::new(row_ids, scores).top_k(vector_search.limit)
-        })
+        .map(RawScoreTopK::into_search_result)
         .collect())
 }
 
+struct RawScoringPlan {
+    all_query_indices: Vec<usize>,
+    candidate_query_indices: HashMap<u64, Vec<usize>>,
+    query_l2_norms: Vec<f32>,
+}
+
+impl RawScoringPlan {
+    fn new(vector_searches: &[VectorSearch], metric: RawVectorMetric) -> Self {
+        let mut all_query_indices = Vec::new();
+        let mut candidate_query_indices: HashMap<u64, Vec<usize>> = 
HashMap::new();
+        let query_l2_norms = vector_searches
+            .iter()
+            .map(|vector_search| match metric {
+                RawVectorMetric::Cosine => vector_search
+                    .vector
+                    .iter()
+                    .map(|value| value * value)
+                    .sum::<f32>()
+                    .sqrt(),
+                RawVectorMetric::L2 | RawVectorMetric::InnerProduct => 0.0,
+            })
+            .collect();
+
+        for (query_index, vector_search) in vector_searches.iter().enumerate() 
{
+            if let Some(include_row_ids) = &vector_search.include_row_ids {
+                for row_id in include_row_ids.iter() {
+                    candidate_query_indices
+                        .entry(row_id)
+                        .or_default()
+                        .push(query_index);
+                }
+            } else {
+                all_query_indices.push(query_index);
+            }
+        }
+
+        Self {
+            all_query_indices,
+            candidate_query_indices,
+            query_l2_norms,
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct RawScoredRow {
+    row_id: u64,
+    score: f32,
+}
+
+impl Eq for RawScoredRow {}
+
+impl PartialOrd for RawScoredRow {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for RawScoredRow {
+    fn cmp(&self, other: &Self) -> Ordering {
+        other
+            .score
+            .total_cmp(&self.score)
+            .then_with(|| self.row_id.cmp(&other.row_id))
+    }
+}
+
+impl RawScoredRow {
+    fn is_stronger_than(&self, other: &Self) -> bool {
+        self.score
+            .total_cmp(&other.score)
+            .then_with(|| other.row_id.cmp(&self.row_id))
+            == Ordering::Greater
+    }
+}
+
+struct RawScoreTopK {
+    limit: usize,
+    heap: BinaryHeap<RawScoredRow>,
+}
+
+impl RawScoreTopK {
+    fn new(limit: usize) -> Self {
+        Self {
+            limit,
+            heap: BinaryHeap::with_capacity(limit.min(1024).saturating_add(1)),
+        }
+    }
+
+    fn offer(&mut self, row_id: u64, score: f32) {
+        if self.limit == 0 {
+            return;
+        }
+        let entry = RawScoredRow { row_id, score };
+        if self.heap.len() < self.limit {
+            self.heap.push(entry);
+        } else if self
+            .heap
+            .peek()
+            .is_some_and(|weakest| entry.is_stronger_than(weakest))
+        {
+            self.heap.pop();
+            self.heap.push(entry);
+        }
+    }
+
+    fn into_search_result(self) -> SearchResult {
+        let mut rows = self.heap.into_vec();
+        rows.sort_by(|a, b| {
+            b.score
+                .total_cmp(&a.score)
+                .then_with(|| a.row_id.cmp(&b.row_id))
+        });
+        let mut row_ids = Vec::with_capacity(rows.len());
+        let mut scores = Vec::with_capacity(rows.len());
+        for row in rows {
+            row_ids.push(row.row_id);
+            scores.push(row.score);
+        }
+        SearchResult::new(row_ids, scores)
+    }
+}
+
 fn collect_raw_batch_vector_batch(
     batch: &RecordBatch,
     vector_searches: &[VectorSearch],
     metric: RawVectorMetric,
-    row_ids_out: &mut [Vec<u64>],
-    scores_out: &mut [Vec<f32>],
+    scoring_plan: &RawScoringPlan,
+    top_k_out: &mut [RawScoreTopK],
 ) -> crate::Result<()> {
     if vector_searches.is_empty() {
         return Ok(());
     }
-    if row_ids_out.len() != vector_searches.len() || scores_out.len() != 
vector_searches.len() {
+    if top_k_out.len() != vector_searches.len() {
         return Err(crate::Error::DataInvalid {
             message: "Raw batch vector search output buffers must match query 
vector count"
                 .to_string(),
@@ -823,48 +1150,141 @@ fn collect_raw_batch_vector_batch(
                 (row * len, (row + 1) * len)
             }
         };
+        ensure_raw_vector_values_not_null(values, start, end)?;
 
-        let mut stored = Vec::with_capacity(end - start);
-        for value_index in start..end {
-            if values.is_null(value_index) {
-                return Err(crate::Error::DataInvalid {
-                    message: "Vector raw search found null vector 
element".to_string(),
-                    source: None,
-                });
+        let raw_row = RawVectorRow {
+            row_id,
+            values,
+            start,
+            end,
+        };
+        for &query_index in &scoring_plan.all_query_indices {
+            offer_raw_vector_score(
+                raw_row,
+                query_index,
+                metric,
+                vector_searches,
+                scoring_plan,
+                top_k_out,
+            )?;
+        }
+        if let Some(query_indices) = 
scoring_plan.candidate_query_indices.get(&row_id) {
+            for &query_index in query_indices {
+                offer_raw_vector_score(
+                    raw_row,
+                    query_index,
+                    metric,
+                    vector_searches,
+                    scoring_plan,
+                    top_k_out,
+                )?;
             }
-            stored.push(values.value(value_index));
         }
+    }
 
-        for (query_index, vector_search) in vector_searches.iter().enumerate() 
{
-            if vector_search
-                .include_row_ids
-                .as_ref()
-                .is_some_and(|include_row_ids| 
!include_row_ids.contains(row_id))
-            {
-                continue;
-            }
-            if stored.len() != vector_search.vector.len() {
-                return Err(crate::Error::DataInvalid {
-                    message: format!(
-                        "Query vector dimension mismatch: raw row has {}, but 
query has {}",
-                        stored.len(),
-                        vector_search.vector.len()
-                    ),
-                    source: None,
-                });
-            }
-            row_ids_out[query_index].push(row_id);
-            scores_out[query_index].push(compute_raw_vector_score(
-                &vector_search.vector,
-                &stored,
-                metric,
-            ));
+    Ok(())
+}
+
+fn ensure_raw_vector_values_not_null(
+    values: &Float32Array,
+    start: usize,
+    end: usize,
+) -> crate::Result<()> {
+    for value_index in start..end {
+        if values.is_null(value_index) {
+            return Err(crate::Error::DataInvalid {
+                message: "Vector raw search found null vector 
element".to_string(),
+                source: None,
+            });
         }
     }
+    Ok(())
+}
+
+#[derive(Clone, Copy)]
+struct RawVectorRow<'a> {
+    row_id: u64,
+    values: &'a Float32Array,
+    start: usize,
+    end: usize,
+}
 
+fn offer_raw_vector_score(
+    row: RawVectorRow<'_>,
+    query_index: usize,
+    metric: RawVectorMetric,
+    vector_searches: &[VectorSearch],
+    scoring_plan: &RawScoringPlan,
+    top_k_out: &mut [RawScoreTopK],
+) -> crate::Result<()> {
+    let vector_search = &vector_searches[query_index];
+    let stored_len = row.end - row.start;
+    if stored_len != vector_search.vector.len() {
+        return Err(crate::Error::DataInvalid {
+            message: format!(
+                "Query vector dimension mismatch: raw row has {}, but query 
has {}",
+                stored_len,
+                vector_search.vector.len()
+            ),
+            source: None,
+        });
+    }
+    let score = compute_raw_vector_score_from_values(
+        &vector_search.vector,
+        scoring_plan.query_l2_norms[query_index],
+        row.values,
+        row.start,
+        row.end,
+        metric,
+    );
+    top_k_out[query_index].offer(row.row_id, score);
     Ok(())
 }
 
+fn compute_raw_vector_score_from_values(
+    query: &[f32],
+    query_l2_norm: f32,
+    values: &Float32Array,
+    start: usize,
+    end: usize,
+    metric: RawVectorMetric,
+) -> f32 {
+    debug_assert_eq!(query.len(), end - start);
+    match metric {
+        RawVectorMetric::L2 => {
+            let sum_sq = query
+                .iter()
+                .zip(start..end)
+                .map(|(q, value_index)| {
+                    let diff = q - values.value(value_index);
+                    diff * diff
+                })
+                .sum::<f32>();
+            1.0 / (1.0 + sum_sq)
+        }
+        RawVectorMetric::Cosine => {
+            let mut dot = 0.0;
+            let mut norm_b = 0.0;
+            for (q, value_index) in query.iter().zip(start..end) {
+                let stored = values.value(value_index);
+                dot += q * stored;
+                norm_b += stored * stored;
+            }
+            let denominator = query_l2_norm * norm_b.sqrt();
+            if denominator == 0.0 {
+                0.0
+            } else {
+                dot / denominator
+            }
+        }
+        RawVectorMetric::InnerProduct => query
+            .iter()
+            .zip(start..end)
+            .map(|(q, value_index)| q * values.value(value_index))
+            .sum(),
+    }
+}
+
 fn row_id_to_u64(row_id: i64) -> crate::Result<u64> {
     u64::try_from(row_id).map_err(|_| crate::Error::DataInvalid {
         message: format!("Negative _ROW_ID {row_id} cannot be used for global 
index search"),
@@ -872,6 +1292,7 @@ fn row_id_to_u64(row_id: i64) -> crate::Result<u64> {
     })
 }
 
+#[cfg(test)]
 fn compute_raw_vector_score(query: &[f32], stored: &[f32], metric: 
RawVectorMetric) -> f32 {
     match metric {
         RawVectorMetric::L2 => {
@@ -1004,6 +1425,76 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_configured_refine_factor_precedence_and_aliases() {
+        let table_options = HashMap::from([(
+            "fields.embedding.ivf.refine-factor".to_string(),
+            "3".to_string(),
+        )]);
+        let search_options = HashMap::from([(
+            "fields.embedding.ivf_flat.rerank_factor".to_string(),
+            "2".to_string(),
+        )]);
+        assert_eq!(
+            configured_refine_factor(
+                &search_options,
+                &table_options,
+                "embedding",
+                IVF_FLAT_IDENTIFIER,
+            )
+            .unwrap(),
+            2
+        );
+
+        assert_eq!(
+            configured_refine_factor(
+                &HashMap::new(),
+                &table_options,
+                "embedding",
+                IVF_FLAT_IDENTIFIER,
+            )
+            .unwrap(),
+            3
+        );
+
+        let global_options = HashMap::from([("rerank-factor".to_string(), 
"4".to_string())]);
+        assert_eq!(
+            configured_refine_factor(
+                &HashMap::new(),
+                &global_options,
+                "embedding",
+                LUMINA_IDENTIFIER,
+            )
+            .unwrap(),
+            4
+        );
+    }
+
+    #[test]
+    fn test_configured_refine_factor_rejects_invalid_values() {
+        let zero_options = HashMap::from([("refine_factor".to_string(), 
"0".to_string())]);
+        let err = configured_refine_factor(
+            &zero_options,
+            &HashMap::new(),
+            "embedding",
+            LUMINA_IDENTIFIER,
+        )
+        .unwrap_err();
+        assert!(err.to_string().contains("must be positive"));
+
+        let invalid_options = HashMap::from([("refine_factor".to_string(), 
"abc".to_string())]);
+        let err = configured_refine_factor(
+            &invalid_options,
+            &HashMap::new(),
+            "embedding",
+            LUMINA_IDENTIFIER,
+        )
+        .unwrap_err();
+        assert!(err.to_string().contains("Must be an integer"));
+
+        assert!(indexed_search_limit(i32::MAX as usize, 2).is_err());
+    }
+
     #[test]
     fn test_collect_raw_batch_vector_batch_preserves_query_order() {
         let element_field = Arc::new(ArrowField::new("element", 
ArrowDataType::Float32, true));
@@ -1037,27 +1528,88 @@ mod tests {
             VectorSearch::new(vec![1.0, 0.0], 1, 
"embedding".to_string()).unwrap(),
             VectorSearch::new(vec![0.0, 1.0], 1, 
"embedding".to_string()).unwrap(),
         ];
-        let mut row_ids = vec![Vec::new(); searches.len()];
-        let mut scores = vec![Vec::new(); searches.len()];
+        let scoring_plan = RawScoringPlan::new(&searches, RawVectorMetric::L2);
+        let mut top_k = searches
+            .iter()
+            .map(|search| RawScoreTopK::new(search.limit))
+            .collect::<Vec<_>>();
 
         collect_raw_batch_vector_batch(
             &batch,
             &searches,
             RawVectorMetric::L2,
-            &mut row_ids,
-            &mut scores,
+            &scoring_plan,
+            &mut top_k,
         )
         .unwrap();
-        let results = row_ids
+        let results = top_k
             .into_iter()
-            .zip(scores)
-            .map(|(row_ids, scores)| SearchResult::new(row_ids, 
scores).top_k(1))
+            .map(RawScoreTopK::into_search_result)
             .collect::<Vec<_>>();
 
         assert_eq!(results[0].row_ids, vec![10]);
         assert_eq!(results[1].row_ids, vec![11]);
     }
 
+    #[test]
+    fn test_collect_raw_batch_vector_batch_scores_only_include_row_ids() {
+        let element_field = Arc::new(ArrowField::new("element", 
ArrowDataType::Float32, true));
+        let mut builder =
+            FixedSizeListBuilder::new(Float32Builder::new(), 
2).with_field(element_field);
+        for vector in [[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]] {
+            builder.values().append_value(vector[0]);
+            builder.values().append_value(vector[1]);
+            builder.append(true);
+        }
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new(
+                "embedding",
+                ArrowDataType::FixedSizeList(
+                    Arc::new(ArrowField::new("element", 
ArrowDataType::Float32, true)),
+                    2,
+                ),
+                true,
+            ),
+            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(builder.finish()) as ArrayRef,
+                Arc::new(Int64Array::from(vec![Some(10), Some(11), Some(12)])) 
as ArrayRef,
+            ],
+        )
+        .unwrap();
+        let mut include_row_ids = RoaringTreemap::new();
+        include_row_ids.insert(12);
+        let searches = vec![
+            VectorSearch::new(vec![1.0, 0.0], 2, "embedding".to_string())
+                .unwrap()
+                .with_include_row_ids(include_row_ids),
+        ];
+        let scoring_plan = RawScoringPlan::new(&searches, RawVectorMetric::L2);
+        let mut top_k = searches
+            .iter()
+            .map(|search| RawScoreTopK::new(search.limit))
+            .collect::<Vec<_>>();
+
+        collect_raw_batch_vector_batch(
+            &batch,
+            &searches,
+            RawVectorMetric::L2,
+            &scoring_plan,
+            &mut top_k,
+        )
+        .unwrap();
+        let results = top_k
+            .into_iter()
+            .map(RawScoreTopK::into_search_result)
+            .collect::<Vec<_>>();
+
+        assert_eq!(results[0].row_ids, vec![12]);
+        assert_eq!(results[0].scores.len(), 1);
+    }
+
     #[tokio::test]
     async fn test_batch_vector_search_requires_vectors() {
         let table = vector_test_table();
diff --git a/crates/paimon/src/vector_search.rs 
b/crates/paimon/src/vector_search.rs
index c96edbf..2070d5e 100644
--- a/crates/paimon/src/vector_search.rs
+++ b/crates/paimon/src/vector_search.rs
@@ -15,7 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::collections::HashMap;
+use std::cmp::Ordering;
+use std::collections::{BinaryHeap, HashMap};
 
 #[derive(Clone)]
 pub struct VectorSearch {
@@ -98,6 +99,46 @@ pub struct SearchResult {
     pub scores: Vec<f32>,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ScoredRow {
+    row_id: u64,
+    score: f32,
+}
+
+impl Eq for ScoredRow {}
+
+impl PartialOrd for ScoredRow {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for ScoredRow {
+    fn cmp(&self, other: &Self) -> Ordering {
+        other
+            .score
+            .total_cmp(&self.score)
+            .then_with(|| self.row_id.cmp(&other.row_id))
+    }
+}
+
+impl ScoredRow {
+    fn is_stronger_than(&self, other: &Self) -> bool {
+        self.score
+            .total_cmp(&other.score)
+            .then_with(|| other.row_id.cmp(&self.row_id))
+            == Ordering::Greater
+    }
+}
+
+fn sort_scored_rows_by_rank(rows: &mut [ScoredRow]) {
+    rows.sort_by(|a, b| {
+        b.score
+            .total_cmp(&a.score)
+            .then_with(|| a.row_id.cmp(&b.row_id))
+    });
+}
+
 impl SearchResult {
     pub fn new(row_ids: Vec<u64>, scores: Vec<f32>) -> Self {
         assert_eq!(row_ids.len(), scores.len());
@@ -159,18 +200,39 @@ impl SearchResult {
     }
 
     pub fn top_k(&self, k: usize) -> Self {
+        if k == 0 {
+            return Self::empty();
+        }
         if self.row_ids.len() <= k {
             return self.clone();
         }
-        let mut indices: Vec<usize> = (0..self.row_ids.len()).collect();
-        indices.sort_by(|&a, &b| {
-            self.scores[b]
-                .partial_cmp(&self.scores[a])
-                .unwrap_or(std::cmp::Ordering::Equal)
-        });
-        indices.truncate(k);
-        let row_ids = indices.iter().map(|&i| self.row_ids[i]).collect();
-        let scores = indices.iter().map(|&i| self.scores[i]).collect();
+
+        let mut heap = BinaryHeap::with_capacity(k + 1);
+        for (&row_id, &score) in self.row_ids.iter().zip(&self.scores) {
+            let entry = ScoredRow { row_id, score };
+            if heap.len() < k {
+                heap.push(entry);
+            } else if heap
+                .peek()
+                .is_some_and(|weakest| entry.is_stronger_than(weakest))
+            {
+                heap.pop();
+                heap.push(entry);
+            }
+        }
+
+        let mut rows = heap.into_vec();
+        sort_scored_rows_by_rank(&mut rows);
+        Self::from_scored_rows(rows)
+    }
+
+    fn from_scored_rows(rows: Vec<ScoredRow>) -> Self {
+        let mut row_ids = Vec::with_capacity(rows.len());
+        let mut scores = Vec::with_capacity(rows.len());
+        for row in rows {
+            row_ids.push(row.row_id);
+            scores.push(row.score);
+        }
         Self { row_ids, scores }
     }
 
@@ -277,6 +339,14 @@ mod tests {
         assert!(top.row_ids.contains(&4));
     }
 
+    #[test]
+    fn test_search_result_top_k_tie_breaks_by_smaller_row_id() {
+        let result = SearchResult::new(vec![30, 10, 20], vec![0.9, 0.9, 0.9]);
+        let top = result.top_k(2);
+        assert_eq!(top.row_ids, vec![10, 20]);
+        assert_eq!(top.scores, vec![0.9, 0.9]);
+    }
+
     #[test]
     fn test_search_result_filters_deleted_row_ranges() {
         let result = SearchResult::new(vec![1, 2, 3, 4], vec![0.1, 0.9, 0.8, 
0.2]);
diff --git a/docs/src/sql.md b/docs/src/sql.md
index d3b2520..827e045 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1027,6 +1027,70 @@ The function performs ANN search across all matching 
vector index files for the
 target column, merges results, and returns the top-k rows ordered by relevance
 score. If no matching index is found, an empty result is returned.
 
+### Refine / Rerank
+
+Vector index search can optionally refine ANN results by reading the raw 
vectors
+for a larger candidate set, recomputing exact scores, and reranking the final
+top-k results. This is useful for quantized indexes, such as IVF-PQ, where ANN
+scores are approximate.
+
+Refine is disabled by default. Configure a positive refine factor as a table
+property. The search first requests `limit * refine_factor` candidates from the
+index, then reranks those candidates by exact raw-vector scores and keeps the
+requested `limit` rows. A factor of `1` still performs exact reranking over the
+original `limit` candidates, but does not over-fetch additional candidates.
+Omit the option to keep the default ANN-only behavior.
+
+Set the option when creating the table:
+
+```sql
+CREATE TABLE paimon.my_db.items (
+  id INT,
+  embedding ARRAY<FLOAT>
+) WITH (
+  'bucket' = '1',
+  'row-tracking.enabled' = 'true',
+  'data-evolution.enabled' = 'true',
+  'global-index.enabled' = 'true',
+  'fields.embedding.ivf.refine-factor' = '3'
+);
+```
+
+Or enable it on an existing table:
+
+```sql
+ALTER TABLE paimon.my_db.items SET TBLPROPERTIES(
+  'fields.embedding.ivf.refine-factor' = '3'
+);
+```
+
+Then run `vector_search` as usual:
+
+```sql
+SELECT *
+FROM vector_search('paimon.my_db.items', 'embedding', '[1.0, 0.0, 0.0, 0.0]', 
10);
+```
+
+Supported refine option names are `refine_factor`, `refine-factor`,
+`rerank_factor`, and `rerank-factor`. Option lookup checks field-specific and
+index-specific keys before global keys. For example, for an `ivf-flat` index on
+column `embedding`, these keys are accepted, from more specific to more 
general:
+
+| Example Key | Scope |
+|---|---|
+| `fields.embedding.ivf-flat.refine-factor` | This column and index type |
+| `fields.embedding.ivf_flat.refine-factor` | This column and normalized index 
type |
+| `fields.embedding.ivf.refine-factor` | This column and all IVF vector 
indexes |
+| `fields.embedding.refine-factor` | This column |
+| `ivf-flat.refine-factor` | This index type |
+| `ivf_flat.refine-factor` | This normalized index type |
+| `ivf.refine-factor` | All IVF vector indexes |
+| `refine-factor` | All vector searches on the table |
+
+Larger refine factors may improve recall and ordering quality, but they also
+increase index result merging, raw-vector reads, and exact scoring work. Use 
the
+smallest factor that provides the desired recall.
+
 ### Lateral Joins
 
 Use `CROSS JOIN LATERAL` when query vectors come from another relation. In 
this mode, the third `vector_search` argument is a column reference from the 
left side of the join instead of a JSON literal:

Reply via email to