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 4211ddad [core] Rerank primary-key vector candidates with exact
distances (#550)
4211ddad is described below
commit 4211ddad799665eede5933e9a39e7f12cc006a74
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Jul 20 18:51:17 2026 +0800
[core] Rerank primary-key vector candidates with exact distances (#550)
---
.../paimon/src/table/pk_vector_data_file_reader.rs | 37 +-
.../src/table/pk_vector_indexed_split_read.rs | 2 +-
crates/paimon/src/table/pk_vector_orchestrator.rs | 135 +++-
crates/paimon/src/table/vector_search_builder.rs | 855 ++++++++++++++++++++-
crates/paimon/src/vindex/pkvector/bucket.rs | 182 ++++-
crates/paimon/tests/pk_vector_baseline_test.rs | 484 ++++++++++++
6 files changed, 1637 insertions(+), 58 deletions(-)
diff --git a/crates/paimon/src/table/pk_vector_data_file_reader.rs
b/crates/paimon/src/table/pk_vector_data_file_reader.rs
index f2c3a6dd..33067b6a 100644
--- a/crates/paimon/src/table/pk_vector_data_file_reader.rs
+++ b/crates/paimon/src/table/pk_vector_data_file_reader.rs
@@ -145,7 +145,7 @@ impl DataFilePkVectorReaderFactory {
/// `None`). The column must be a `FixedSizeList`/`List` of `Float32`; every
/// non-null row's child slice must have exactly `dimension` elements. Mirrors
/// the layout handling in `vector_search_builder`.
-fn append_batch_vectors(
+pub(crate) fn append_batch_vectors(
batch: &arrow_array::RecordBatch,
field_name: &str,
dimension: usize,
@@ -206,6 +206,12 @@ fn append_batch_vectors(
}
let mut vector = Vec::with_capacity(dimension);
for i in start..end {
+ if values.is_null(i) {
+ return Err(data_invalid(format!(
+ "vector row {row} has a null element at index {}",
+ i - start
+ )));
+ }
vector.push(values.value(i));
}
out.push(Some(vector));
@@ -349,6 +355,35 @@ mod integration_tests {
}
}
+ /// A present (non-null) vector row whose child slice contains a NULL
+ /// element must fail loud rather than silently defaulting the element to
+ /// `0.0` and corrupting the distance.
+ #[test]
+ fn append_batch_vectors_fails_loud_on_null_element() {
+ let field = vector_field();
+ let read_fields = vec![field.clone()];
+ let arrow_schema = build_target_arrow_schema(&read_fields).unwrap();
+
+ // Row is present, but element index 1 in its child slice is NULL:
[1.0, null].
+ let mut builder = FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(Arc::new(
+ ArrowField::new("element", ArrowDataType::Float32, true),
+ ));
+ builder.values().append_value(1.0);
+ builder.values().append_null();
+ builder.append(true);
+ let vec_array = builder.finish();
+ let batch = RecordBatch::try_new(arrow_schema,
vec![Arc::new(vec_array)]).unwrap();
+
+ let mut out: Vec<Option<Vec<f32>>> = Vec::new();
+ let err = append_batch_vectors(&batch, field.name(), 2, &mut out)
+ .expect_err("null child element must fail loud");
+ let msg = err.to_string();
+ assert!(
+ msg.contains("null") && msg.contains("element"),
+ "got: {msg}"
+ );
+ }
+
/// Write a FixedSizeList<Float32, 2> vector column
/// (`[1,2]`, NULL, `[3,4]`) as a parquet data file, build the factory over
/// its split, preload via `create`, and assert the whole-file sequential
diff --git a/crates/paimon/src/table/pk_vector_indexed_split_read.rs
b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
index 2dcb6e7f..b6383c99 100644
--- a/crates/paimon/src/table/pk_vector_indexed_split_read.rs
+++ b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
@@ -61,7 +61,7 @@ pub(crate) struct PkVectorIndexedSplit {
/// positions, validating bounds and ordering. Ranges must be non-empty, each
/// within `[0, row_count)`, strictly ascending and non-overlapping (touching
/// ranges allowed). Expansion is inclusive `from..=to`.
-fn expand_ranges(ranges: &[RowRange], row_count: i64) ->
crate::Result<Vec<i64>> {
+pub(crate) fn expand_ranges(ranges: &[RowRange], row_count: i64) ->
crate::Result<Vec<i64>> {
if ranges.is_empty() {
return Err(data_invalid("indexed split must select at least one row"));
}
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index 0479781f..358211a5 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -112,6 +112,7 @@ pub(crate) struct PkVectorSearchSplit {
/// the cross-bucket merge dimensions a lone `PkVectorSearchResult` lacks;
/// `split_index` is the re-association handle back to
/// `splits[split_index].data_split`.
+#[derive(Clone)]
pub(crate) struct PkVectorCandidate {
pub split_index: usize,
pub partition: BinaryRow,
@@ -121,6 +122,14 @@ pub(crate) struct PkVectorCandidate {
pub distance: f32,
}
+/// Best-first indexed (approximate) and exact-fallback candidate lists, each
+/// already globally bounded. The indexed list may be over-fetched for a later
+/// exact rerank; the exact list is bounded to the caller's final limit.
+pub(crate) struct OrchestratorSearchResult {
+ pub(crate) indexed: Vec<PkVectorCandidate>,
+ pub(crate) exact: Vec<PkVectorCandidate>,
+}
+
/// 5-level BEST_FIRST (smallest = best) key. Level 1 orders distance with
/// `java_float_compare` so a NaN distance (e.g. from a non-finite stored
vector
/// under inner product) sorts last rather than winning Top-1. Level 2 uses the
@@ -145,6 +154,20 @@ fn global_top_k(mut candidates: Vec<PkVectorCandidate>,
limit: usize) -> Vec<PkV
candidates
}
+/// Merge already-bounded indexed and exact candidate lists into a single
+/// best-first list truncated to `limit`. The final global Top-K over the union
+/// is what guarantees the merged result is independent of how the two inputs
+/// were individually bounded.
+pub(crate) fn merge_candidates(
+ indexed: Vec<PkVectorCandidate>,
+ exact: Vec<PkVectorCandidate>,
+ limit: usize,
+) -> Vec<PkVectorCandidate> {
+ let mut all = indexed;
+ all.extend(exact);
+ global_top_k(all, limit)
+}
+
/// Group Top-K survivors by `(partition, bucket, data_file_name)`,
re-associate
/// each group's file to its real `DataFileMeta` + aligned deletion file in the
/// source bucket split, and build one `PkVectorIndexedSplit` per file. Groups
are
@@ -197,7 +220,15 @@ pub(crate) fn build_indexed_splits(
}
// Re-associate the file to its DataFileMeta + aligned deletion file.
- let source = &splits[split_index].data_split;
+ let source = &splits
+ .get(split_index)
+ .ok_or_else(|| {
+ data_invalid(format!(
+ "vector search hit references split index {split_index}
out of range (splits: {})",
+ splits.len()
+ ))
+ })?
+ .data_split;
let file_idx = source
.data_files()
.iter()
@@ -301,10 +332,13 @@ impl PkVectorOrchestrator {
}
/// Run the eager per-bucket search + cross-bucket global Top-K and return
the
- /// best-first survivors (through the full 5-level tie-break, raw distance
- /// preserved). The exact-reader factory is split-scoped: it receives the
- /// current split index and split so a caller can build a reader keyed to
the
- /// specific split/file. `skip_exact_fallback` forwards to `bucket_search`.
+ /// indexed (approximate) and exact-fallback survivors as two separate
+ /// best-first lists (each through the full 5-level tie-break, raw distance
+ /// preserved). The indexed list is bounded to `indexed_limit`
(over-fetched
+ /// for a later exact rerank); the exact list is bounded to `limit`. The
+ /// exact-reader factory is split-scoped: it receives the current split
index
+ /// and split so a caller can build a reader keyed to the specific
split/file.
+ /// `skip_exact_fallback` forwards to `bucket_search`.
///
/// `residual_by_split`, when present, carries one per-file allow-list of
/// physical row positions per split (indexed parallel to `splits`): only
@@ -320,6 +354,7 @@ impl PkVectorOrchestrator {
query: &[f32],
metric: VectorSearchMetric,
limit: usize,
+ indexed_limit: usize,
ann_searcher: Option<&dyn PkVectorAnnSearcher>,
exact_reader_factory: &mut (dyn for<'s, 'f> FnMut(
usize,
@@ -330,11 +365,14 @@ impl PkVectorOrchestrator {
search_options: &HashMap<String, String>,
skip_exact_fallback: bool,
residual_by_split: Option<&[HashMap<String, RoaringTreemap>]>,
- ) -> crate::Result<Vec<PkVectorCandidate>> {
+ ) -> crate::Result<OrchestratorSearchResult> {
// Eager input-shape validation (Java checkArgument parity).
if limit == 0 {
return Err(data_invalid("vector search limit must be positive"));
}
+ if indexed_limit == 0 {
+ return Err(data_invalid("vector indexed search limit must be
positive"));
+ }
if query.is_empty() {
return Err(data_invalid("vector search query must not be empty"));
}
@@ -346,8 +384,9 @@ impl PkVectorOrchestrator {
}
}
- // Eager per-bucket search -> tagged candidates.
- let mut candidates: Vec<PkVectorCandidate> = Vec::new();
+ // Eager per-bucket search -> tagged candidates, kept split by path.
+ let mut indexed_candidates: Vec<PkVectorCandidate> = Vec::new();
+ let mut exact_candidates: Vec<PkVectorCandidate> = Vec::new();
for (split_index, split) in splits.iter().enumerate() {
let dvs = build_bucket_dv_map(&self.reader, split).await?;
// Wrap the split-scoped factory into bucket_search's per-file
signature.
@@ -357,7 +396,7 @@ impl PkVectorOrchestrator {
exact_reader_factory(split_index, split, file)
});
let residual_ranges = residual_by_split.map(|per_split|
&per_split[split_index]);
- let results = bucket_search(
+ let result = bucket_search(
ann_searcher,
&split.ann_segments,
&split.active_files,
@@ -365,30 +404,33 @@ impl PkVectorOrchestrator {
&mut bucket_factory,
query,
metric,
+ indexed_limit,
limit,
search_options,
skip_exact_fallback,
residual_ranges,
)
.await?;
- for PkVectorSearchResult {
+ let tag = |PkVectorSearchResult {
+ data_file_name,
+ row_position,
+ distance,
+ }: PkVectorSearchResult| PkVectorCandidate {
+ split_index,
+ partition: split.data_split.partition().clone(),
+ bucket: split.data_split.bucket(),
data_file_name,
row_position,
distance,
- } in results
- {
- candidates.push(PkVectorCandidate {
- split_index,
- partition: split.data_split.partition().clone(),
- bucket: split.data_split.bucket(),
- data_file_name,
- row_position,
- distance,
- });
- }
+ };
+ indexed_candidates.extend(result.indexed.into_iter().map(&tag));
+ exact_candidates.extend(result.exact.into_iter().map(&tag));
}
- Ok(global_top_k(candidates, limit))
+ Ok(OrchestratorSearchResult {
+ indexed: global_top_k(indexed_candidates, indexed_limit),
+ exact: global_top_k(exact_candidates, limit),
+ })
}
}
@@ -547,6 +589,18 @@ mod tests {
assert!(survivors.is_empty());
}
+ #[test]
+ fn merge_candidates_takes_global_top_k_over_union() {
+ // indexed and exact each already bounded; the union's global Top-K
must be
+ // independent of how each side was bounded.
+ let indexed = vec![cand(0, 0, "f", 0, 0.1), cand(0, 0, "f", 1, 0.4)];
+ let exact = vec![cand(0, 0, "g", 0, 0.2)];
+ let out = merge_candidates(indexed, exact, 2);
+ assert_eq!(out.len(), 2);
+ assert_eq!(out[0].distance, 0.1);
+ assert_eq!(out[1].distance, 0.2); // 0.2 (exact) beats 0.4 (indexed)
+ }
+
#[test]
fn builds_two_splits_with_ascending_position_ordered_scores() {
// One bucket, two files. file-a hits at global order [pos=10, pos=2];
@@ -628,6 +682,18 @@ mod tests {
assert!(format!("{err:?}").contains("duplicate"), "got: {err:?}");
}
+ #[test]
+ fn build_indexed_splits_fails_loud_on_out_of_range_split_index() {
+ // A malformed candidate whose split_index is beyond the splits slice
must
+ // fail loud, not panic on a bare slice index.
+ let splits = vec![search_split(0, vec![data_file("f", 10)])];
+ let survivors = vec![cand(5, 0, "f", 0, 1.0)];
+ let err = build_indexed_splits(survivors, &splits,
VectorSearchMetric::L2)
+ .map(|_| ())
+ .expect_err("out-of-range split index must error");
+ assert!(format!("{err:?}").contains("out of range"), "got: {err:?}");
+ }
+
#[test]
fn rejects_same_group_key_from_different_splits() {
// Two buckets share (partition, bucket, file_name) but sit at
different
@@ -949,12 +1015,13 @@ mod e2e_tests {
// expects; the split index/split are unused here.
let mut wrapped =
as_split_factory(|_: usize, _: &PkVectorSearchSplit, f:
&BucketActiveFile| factory(f));
- let survivors = orch
+ let result = orch
.search_candidates(
splits,
query,
metric,
limit,
+ limit,
ann,
&mut wrapped,
opts,
@@ -962,6 +1029,9 @@ mod e2e_tests {
None,
)
.await?;
+ // Merge the two bounded lists into the best-first survivors the
+ // materialization path expects.
+ let survivors = merge_candidates(result.indexed, result.exact, limit);
let indexed_splits = build_indexed_splits(survivors, splits, metric)?;
let mut out = Vec::new();
for indexed in indexed_splits {
@@ -991,6 +1061,7 @@ mod e2e_tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
0,
+ 0,
None,
&mut factory,
&opts,
@@ -1020,6 +1091,7 @@ mod e2e_tests {
&[],
VectorSearchMetric::L2,
5,
+ 5,
None,
&mut factory,
&opts,
@@ -1360,12 +1432,13 @@ mod e2e_tests {
},
);
let opts = HashMap::new();
- let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
+ let result = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
.search_candidates(
&[split],
&[0.0, 0.0],
VectorSearchMetric::L2,
2,
+ 2,
None,
&mut factory,
&opts,
@@ -1374,6 +1447,8 @@ mod e2e_tests {
)
.await
.unwrap();
+ // Merge the two bounded lists into the best-first survivors.
+ let cands = merge_candidates(result.indexed, result.exact, 2);
// Best-first: pos1 (d=1), pos2 (d=4).
assert_eq!(
cands
@@ -1432,12 +1507,13 @@ mod e2e_tests {
allowed.insert(2);
let residual_by_split = vec![HashMap::from([("r.mosaic".to_string(),
allowed)])];
let opts = HashMap::new();
- let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
+ let result = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
.search_candidates(
&[split],
&[0.0, 0.0],
VectorSearchMetric::L2,
3,
+ 3,
None,
&mut factory,
&opts,
@@ -1446,6 +1522,8 @@ mod e2e_tests {
)
.await
.unwrap();
+ // Merge the two bounded lists into the best-first survivors.
+ let cands = merge_candidates(result.indexed, result.exact, 3);
// Best-first among allowed positions: pos2 (d=4) then pos0 (d=9).
assert_eq!(
cands
@@ -1493,6 +1571,7 @@ mod e2e_tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
3,
+ 3,
None,
&mut factory,
&opts,
@@ -1533,12 +1612,13 @@ mod e2e_tests {
},
);
let opts = HashMap::new();
- let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
+ let result = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
.search_candidates(
&[split],
&[0.0, 0.0],
VectorSearchMetric::L2,
2,
+ 2,
None,
&mut factory,
&opts,
@@ -1547,7 +1627,8 @@ mod e2e_tests {
)
.await
.unwrap();
- assert!(cands.is_empty());
+ assert!(result.indexed.is_empty());
+ assert!(result.exact.is_empty());
}
#[test]
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 992c4cd4..9f50ee46 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -29,13 +29,17 @@ use crate::table::global_index_scanner::{
deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
unindexed_ranges_for_global_index_entries, RowRangeIndex,
};
-use crate::table::pk_vector_data_file_reader::DataFilePkVectorReaderFactory;
-use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplitRead;
+use crate::table::pk_vector_data_file_reader::{
+ append_batch_vectors, DataFilePkVectorReaderFactory,
+};
+use crate::table::pk_vector_indexed_split_read::{expand_ranges,
PkVectorIndexedSplitRead};
use crate::table::pk_vector_orchestrator::{
- as_split_exact_reader_factory, build_indexed_splits, PkVectorCandidate,
PkVectorOrchestrator,
- PkVectorSearchSplit,
+ as_split_exact_reader_factory, build_indexed_splits, merge_candidates,
+ OrchestratorSearchResult, PkVectorCandidate, PkVectorOrchestrator,
PkVectorSearchSplit,
+};
+use crate::table::pk_vector_position_read::{
+ PkVectorPositionRead, PKEY_VECTOR_POSITION_COLUMN, SEARCH_SCORE_COLUMN,
};
-use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
use crate::table::pk_vector_scan::{PkVectorScan, PkVectorScanPlan};
use crate::table::read_builder::resolve_projected_fields;
use crate::table::source::DataSplit;
@@ -437,6 +441,23 @@ impl<'a> VectorSearchBuilder<'a> {
let search_mode = core.global_index_search_mode()?;
let skip_exact_fallback = search_mode == GlobalIndexSearchMode::Fast;
+ // Resolve the refine factor from the query options first, then fall
back to
+ // the table options; a positive factor over-fetches indexed
(approximate)
+ // candidates so the exact rerank below has a wider pool to reorder.
Factor 0
+ // (unset) leaves `indexed_limit == limit`, byte-identical to the
no-rerank
+ // path. The two option maps are kept distinct (query options passed
+ // separately from table options) so a broad query key cannot be
overridden
+ // by a more specific table key: query options take precedence as a
whole.
+ // Resolved before planning so an invalid factor (e.g. a non-numeric
value)
+ // fails loud regardless of whether the table currently has searchable
data.
+ let refine_factor = configured_refine_factor(
+ &self.options,
+ self.table.schema().options(),
+ pk_col,
+ &index_type,
+ )?;
+ let indexed_limit = indexed_search_limit(limit, refine_factor)?;
+
let plan = PkVectorScan::new(
self.table,
field_id,
@@ -583,12 +604,13 @@ impl<'a> VectorSearchBuilder<'a> {
},
);
- let candidates = PkVectorOrchestrator::new(reader)
+ let search: OrchestratorSearchResult =
PkVectorOrchestrator::new(reader)
.search_candidates(
&plan.splits,
query_vector,
metric,
limit,
+ indexed_limit,
Some(&ann_searcher),
&mut factory,
&search_options,
@@ -597,6 +619,38 @@ impl<'a> VectorSearchBuilder<'a> {
)
.await?;
+ // Exact rerank of the approximate candidates when a refine factor is
set;
+ // exact-fallback candidates are already exact and are not reranked.
With no
+ // refine factor this is a plain merge, byte-identical to the
no-rerank path.
+ let indexed = if refine_factor > 0 && !search.indexed.is_empty() {
+ // Vector-only reader (project just the vector field); the
position read
+ // appends _PKEY_VECTOR_POSITION itself and injects _ROW_ID
internally.
+ let rerank_reader = DataFileReader::new(
+ self.table.file_io().clone(),
+ self.table.schema_manager().clone(),
+ self.table.schema().id(),
+ self.table.schema().fields().to_vec(),
+ vec![vector_field.clone()],
+ Vec::new(),
+ );
+ rerank_indexed_positional(
+ &rerank_reader,
+ search.indexed,
+ &plan.splits,
+ query_vector,
+ metric,
+ limit,
+ &vector_field,
+ )
+ .await?
+ } else {
+ search.indexed
+ };
+ // Merge the (possibly reranked) indexed list with the exact-fallback
list
+ // back into one best-first list bounded to the caller's limit; the
+ // downstream materialization consumes a single ranked candidate list.
+ let candidates = merge_candidates(indexed, search.exact, limit);
+
Ok((candidates, plan, metric))
}
@@ -1279,6 +1333,168 @@ fn verify_pk_vector_segment_metrics(
Ok(())
}
+/// Rerank approximate (indexed) candidates by rereading ONLY their candidate
+/// positions and recomputing the exact distance, then keep the best `limit`.
+///
+/// Unlike a whole-column preload, this reuses [`PkVectorPositionRead`] to read
+/// just the selected physical rows of each hit file (positions -> row ranges
->
+/// local ranges), so a rerank over a large ANN-covered file touches only the
+/// candidate rows. Mirrors Java's IndexedSplit rerank.
+///
+/// Each returned row is matched back to its candidate by the
+/// `_PKEY_VECTOR_POSITION` column VALUE (never batch order). The recomputed
+/// distance is written into the ORIGINAL candidate so `split_index` /
+/// partition / bucket survive (`build_indexed_splits` does not carry
+/// `split_index`). A DV loaded exactly as [`PkVectorIndexedSplitRead::read`]
+/// does drops deleted positions, so a candidate at a deleted position returns
no
+/// row and trips the leftover guard — a deleted candidate reaching rerank is a
+/// real inconsistency (the search path already DV-filters), so fail loud.
+#[allow(clippy::too_many_arguments)]
+async fn rerank_indexed_positional(
+ rerank_reader: &DataFileReader,
+ indexed: Vec<PkVectorCandidate>,
+ plan_splits: &[PkVectorSearchSplit],
+ query_vector: &[f32],
+ metric: VectorSearchMetric,
+ limit: usize,
+ vector_field: &DataField,
+) -> crate::Result<Vec<PkVectorCandidate>> {
+ // Original per-position candidates keyed by (split_index, file, position);
+ // the recomputed distance is written back into these so split_index and
+ // partition/bucket survive (build_indexed_splits does not carry
split_index).
+ let mut by_key: HashMap<(usize, String, i64), PkVectorCandidate> =
HashMap::new();
+ for c in &indexed {
+ if by_key
+ .insert(
+ (c.split_index, c.data_file_name.clone(), c.row_position),
+ c.clone(),
+ )
+ .is_some()
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "duplicate primary-key vector candidate for
reranking".to_string(),
+ source: None,
+ });
+ }
+ }
+
+ // Rebuild the split_index lookup by (partition bytes, bucket, file): the
+ // indexed split exposes partition/bucket/file but not split_index.
+ let mut split_index_of: HashMap<(Vec<u8>, i32, String), usize> =
HashMap::new();
+ for (i, s) in plan_splits.iter().enumerate() {
+ let p = s.data_split.partition().to_serialized_bytes();
+ let b = s.data_split.bucket();
+ for f in s.data_split.data_files() {
+ split_index_of.insert((p.clone(), b, f.file_name.clone()), i);
+ }
+ }
+
+ // Every candidate must reference a (partition, bucket, file) that the plan
+ // actually carries. Checking up front — before build_indexed_splits, which
+ // indexes plan_splits by split_index — turns an absent file into a
fail-loud
+ // error rather than an out-of-range panic, and keeps the per-split lookup
+ // below a self-consistent backstop.
+ for c in &indexed {
+ let key = (
+ c.partition.to_serialized_bytes(),
+ c.bucket,
+ c.data_file_name.clone(),
+ );
+ if !split_index_of.contains_key(&key) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("rerank split for {} not found in plan",
c.data_file_name),
+ source: None,
+ });
+ }
+ }
+
+ // Group the candidates into per-file indexed splits (position ranges +
file
+ // meta), reusing the exact grouping/validation the materialization path
uses.
+ let indexed_splits = build_indexed_splits(indexed, plan_splits, metric)?;
+
+ let dimension = query_vector.len();
+ let mut reranked: Vec<PkVectorCandidate> = Vec::new();
+ for split in indexed_splits {
+ let data_split = split.split.clone();
+ let file_meta = data_split.data_files()[0].clone();
+ let file_name = file_meta.file_name.clone();
+ let partition_bytes = data_split.partition().to_serialized_bytes();
+ let bucket = data_split.bucket();
+ let split_index = *split_index_of
+ .get(&(partition_bytes, bucket, file_name.clone()))
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("rerank split for {file_name} not found in
plan"),
+ source: None,
+ })?;
+
+ // DV loaded exactly as PkVectorIndexedSplitRead::read does; skipping
it
+ // would score deleted rows.
+ let dv_factory =
rerank_reader.build_split_dv_factory(&data_split).await?;
+ let dv = DataFileReader::deletion_vector_for_file(dv_factory.as_ref(),
&file_name);
+ let data_fields = rerank_reader.derive_data_fields(&file_meta).await?;
+
+ // Positions from the split's row_ranges (ascending); read only those.
+ let positions = expand_ranges(&split.row_ranges, file_meta.row_count)?;
+ let mut stream = PkVectorPositionRead::new(rerank_reader).read(
+ &data_split,
+ file_meta,
+ data_fields,
+ dv,
+ positions,
+ None, // no scores; rerank recomputes distance
+ )?;
+
+ while let Some(batch) = stream.try_next().await? {
+ let pos_idx = batch
+ .schema()
+ .index_of(PKEY_VECTOR_POSITION_COLUMN)
+ .map_err(|_| crate::Error::DataInvalid {
+ message: format!("rerank batch missing
{PKEY_VECTOR_POSITION_COLUMN} column"),
+ source: None,
+ })?;
+ let pos_col = batch
+ .column(pos_idx)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("{PKEY_VECTOR_POSITION_COLUMN} column is
not Int64"),
+ source: None,
+ })?;
+ let mut vectors: Vec<Option<Vec<f32>>> = Vec::new();
+ append_batch_vectors(&batch, vector_field.name(), dimension, &mut
vectors)?;
+ for (row, vector) in vectors.iter().enumerate() {
+ let position = pos_col.value(row);
+ let mut candidate = by_key
+ .remove(&(split_index, file_name.clone(), position))
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("rerank read unexpected position
{file_name}@{position}"),
+ source: None,
+ })?;
+ let vector = vector.as_ref().ok_or_else(||
crate::Error::DataInvalid {
+ message: format!(
+ "primary-key vector candidate {file_name}@{position}
contains a null vector"
+ ),
+ source: None,
+ })?;
+ candidate.distance = metric.compute_distance(query_vector,
vector);
+ reranked.push(candidate);
+ }
+ }
+ }
+
+ if !by_key.is_empty() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "failed to read {} primary-key vector candidate(s) for
reranking",
+ by_key.len()
+ ),
+ source: None,
+ });
+ }
+
+ Ok(merge_candidates(reranked, Vec::new(), limit))
+}
+
/// One materialized row tagged with its best-first `rank` and its
`(batch_index,
/// row_index)` location in the retained materialization batches.
struct RankedRow {
@@ -2965,6 +3181,633 @@ mod tests {
}
}
+ fn pk_candidate(
+ split_index: usize,
+ bucket: i32,
+ file: &str,
+ pos: i64,
+ distance: f32,
+ ) -> PkVectorCandidate {
+ PkVectorCandidate {
+ split_index,
+ partition: BinaryRow::new(0),
+ bucket,
+ data_file_name: file.to_string(),
+ row_position: pos,
+ distance,
+ }
+ }
+
+ // Candidate with a fixed empty (arity-0) partition and bucket 0, keyed
only by
+ // (split_index, file, position) — the dimensions the rerank core groups
on.
+ fn cand_at(split_index: usize, file: &str, pos: i64, dist: f32) ->
PkVectorCandidate {
+ pk_candidate(split_index, 0, file, pos, dist)
+ }
+
+ /// The single data-file name every rerank fixture writes.
+ const RERANK_FILE: &str = "part-0.parquet";
+
+ /// Serialize a Paimon deletion-vector blob covering `deleted_rows` and
write it
+ /// at `path`, returning the matching `DeletionFile`. Byte layout mirrors
the
+ /// position-read tests: `[length][magic][roaring bitmap][0]`.
+ async fn write_deletion_blob(
+ file_io: &FileIO,
+ path: &str,
+ deleted_rows: &[u32],
+ ) -> crate::table::source::DeletionFile {
+ use roaring::RoaringBitmap;
+
+ const MAGIC_NUMBER: i32 = 1581511376;
+ let mut bitmap = RoaringBitmap::new();
+ for row in deleted_rows {
+ bitmap.insert(*row);
+ }
+ let mut bitmap_bytes = Vec::new();
+ bitmap.serialize_into(&mut bitmap_bytes).unwrap();
+ let bitmap_length = 4 + bitmap_bytes.len() as i32;
+ let mut blob = Vec::new();
+ blob.extend_from_slice(&bitmap_length.to_be_bytes());
+ blob.extend_from_slice(&MAGIC_NUMBER.to_be_bytes());
+ blob.extend_from_slice(&bitmap_bytes);
+ blob.extend_from_slice(&0i32.to_be_bytes());
+ file_io
+ .new_output(path)
+ .unwrap()
+ .write(bytes::Bytes::from(blob))
+ .await
+ .unwrap();
+ crate::table::source::DeletionFile::new(
+ path.to_string(),
+ 0,
+ bitmap_length as i64,
+ Some(deleted_rows.len() as i64),
+ )
+ }
+
+ /// Write a single-file vector data file (`FixedSizeList<Float32>` of width
+ /// `dim`) holding `rows` (a `None` entry is a NULL vector row) as
Parquet, and
+ /// return a vector-only `DataFileReader`, the enclosing
`PkVectorSearchSplit`,
+ /// and the vector `DataField`. When `deleted_rows` is non-empty a deletion
+ /// vector covering those physical positions is attached to the split, so
the
+ /// position read drops them exactly as `PkVectorIndexedSplitRead::read`
does.
+ ///
+ /// This is the position-only analogue of the old `ArrayReader`: rerank now
+ /// re-reads real stored rows through `PkVectorPositionRead`, so the
fixtures
+ /// exercise that path rather than an in-memory preloaded column.
+ async fn vector_rerank_fixture(
+ table_path: &str,
+ dim: u32,
+ rows: &[Option<Vec<f32>>],
+ deleted_rows: &[u32],
+ ) -> (DataFileReader, PkVectorSearchSplit, DataField) {
+ use crate::arrow::build_target_arrow_schema;
+ use crate::arrow::format::{FormatFileWriter, ParquetFormatWriter};
+ use crate::spec::VectorType;
+ use crate::table::schema_manager::SchemaManager;
+
+ let vector_type =
+ VectorType::try_new(true, dim,
DataType::Float(FloatType::new())).unwrap();
+ let vector_field =
+ DataField::new(0, "embedding".to_string(),
DataType::Vector(vector_type));
+ let read_fields = vec![vector_field.clone()];
+ let arrow_schema = build_target_arrow_schema(&read_fields).unwrap();
+
+ let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), dim
as i32).with_field(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
+ );
+ for row in rows {
+ match row {
+ Some(values) => {
+ for v in values {
+ builder.values().append_value(*v);
+ }
+ builder.append(true);
+ }
+ None => {
+ for _ in 0..dim {
+ builder.values().append_value(0.0);
+ }
+ builder.append(false);
+ }
+ }
+ }
+ let vec_array = builder.finish();
+ let batch =
+ arrow_array::RecordBatch::try_new(arrow_schema.clone(),
vec![Arc::new(vec_array)])
+ .unwrap();
+
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let bucket_path = format!("{table_path}/bucket-0");
+ let output = file_io
+ .new_output(&format!("{bucket_path}/{RERANK_FILE}"))
+ .unwrap();
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ ParquetFormatWriter::new(
+ &output,
+ arrow_schema.clone(),
+ "zstd",
+ 1,
+ None,
+ &HashMap::new(),
+ )
+ .await
+ .unwrap(),
+ );
+ writer.write(&batch).await.unwrap();
+ let file_size = writer.close().await.unwrap().file_size;
+
+ let schema_id = 1;
+ let file_meta = pk_data_file(RERANK_FILE, rows.len() as i64, Some(0));
+ let file_meta = DataFileMeta {
+ file_size: file_size as i64,
+ schema_id,
+ ..file_meta
+ };
+
+ let mut split_builder = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path)
+ .with_total_buckets(1)
+ .with_data_files(vec![file_meta]);
+ if !deleted_rows.is_empty() {
+ let df =
+ write_deletion_blob(&file_io,
&format!("{table_path}/index/dv-0"), deleted_rows)
+ .await;
+ split_builder =
split_builder.with_data_deletion_files(vec![Some(df)]);
+ }
+ let data_split = split_builder.build().unwrap();
+ let split = PkVectorSearchSplit {
+ data_split,
+ ann_segments: Vec::new(),
+ active_files: Vec::new(),
+ };
+
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ schema_id,
+ read_fields.clone(),
+ read_fields,
+ Vec::new(),
+ );
+ (reader, split, vector_field)
+ }
+
+ #[tokio::test]
+ async fn rerank_aligns_recomputed_distance_by_position_column() {
+ use crate::arrow::build_target_arrow_schema;
+ use crate::arrow::format::{FormatFileWriter, ParquetFormatWriter};
+ use crate::spec::VectorType;
+ use crate::table::schema_manager::SchemaManager;
+
+ // A vector data file with 4 physical rows: positions 0,1,3 hold
vectors
+ // and position 2 (a NON-candidate) holds a NULL vector. Candidates
sit at
+ // non-contiguous positions {1, 3}. The ANN-reported distances are
+ // deliberately reversed relative to the true stored vectors; after
rerank
+ // each candidate must carry compute_distance(query,
vec_at_its_position),
+ // proving alignment is by the _PKEY_VECTOR_POSITION column value, not
batch
+ // order. Position 2's NULL is never read (it is not a candidate), so
it
+ // cannot trip the null-vector guard.
+ let vector_type = VectorType::try_new(true, 2,
DataType::Float(FloatType::new())).unwrap();
+ let vector_field =
+ DataField::new(0, "embedding".to_string(),
DataType::Vector(vector_type));
+ let read_fields = vec![vector_field.clone()];
+ let arrow_schema = build_target_arrow_schema(&read_fields).unwrap();
+
+ // pos0=[7,0], pos1=[1,0], pos2=NULL, pos3=[4,0].
+ let mut builder = FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(Arc::new(
+ ArrowField::new("element", ArrowDataType::Float32, true),
+ ));
+ for row in [
+ Some([7.0f32, 0.0]),
+ Some([1.0, 0.0]),
+ None,
+ Some([4.0, 0.0]),
+ ] {
+ match row {
+ Some([a, b]) => {
+ builder.values().append_value(a);
+ builder.values().append_value(b);
+ builder.append(true);
+ }
+ None => {
+ builder.values().append_value(0.0);
+ builder.values().append_value(0.0);
+ builder.append(false);
+ }
+ }
+ }
+ let vec_array = builder.finish();
+ let batch =
+ arrow_array::RecordBatch::try_new(arrow_schema.clone(),
vec![Arc::new(vec_array)])
+ .unwrap();
+
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/rerank_positional";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.parquet";
+ let output = file_io
+ .new_output(&format!("{bucket_path}/{file_name}"))
+ .unwrap();
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ ParquetFormatWriter::new(
+ &output,
+ arrow_schema.clone(),
+ "zstd",
+ 1,
+ None,
+ &HashMap::new(),
+ )
+ .await
+ .unwrap(),
+ );
+ writer.write(&batch).await.unwrap();
+ let file_size = writer.close().await.unwrap().file_size;
+
+ let schema_id = 1;
+ let file_meta = pk_data_file(file_name, 4, Some(0));
+ let file_meta = DataFileMeta {
+ file_size: file_size as i64,
+ schema_id,
+ ..file_meta
+ };
+ let data_split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path)
+ .with_total_buckets(1)
+ .with_data_files(vec![file_meta])
+ .build()
+ .unwrap();
+ let split = PkVectorSearchSplit {
+ data_split,
+ ann_segments: Vec::new(),
+ active_files: Vec::new(),
+ };
+
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ schema_id,
+ read_fields.clone(),
+ read_fields.clone(),
+ Vec::new(),
+ );
+
+ let query = vec![1.0f32, 0.0];
+ // ANN-reported distances reversed vs. truth: pos1 reported worse
(0.9) than
+ // pos3 (0.1), but the true L2 distances are pos1=0 and pos3=9.
+ let indexed = vec![cand_at(0, file_name, 1, 0.9), cand_at(0,
file_name, 3, 0.1)];
+
+ let out = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 2,
+ &vector_field,
+ )
+ .await
+ .unwrap();
+
+ // Best-first after exact recompute: pos1 (d=0) then pos3 (d=9), each
+ // carrying the distance computed from its OWN position's stored
vector.
+ assert_eq!(out.len(), 2);
+ assert_eq!(out[0].row_position, 1);
+ assert_eq!(out[0].distance, 0.0);
+ assert_eq!(out[1].row_position, 3);
+ assert_eq!(out[1].distance, 9.0);
+ }
+
+ #[tokio::test]
+ async fn rerank_recomputes_distance_and_reorders() {
+ // pos0=[9,0], pos1=[1,0]; query=[1,0]. The ANN-reported distances are
+ // reversed relative to the truth (pos0 reported best at 0.1, pos1
worst at
+ // 0.9), so an implementation that trusted the ANN order would emit
pos0
+ // first. Exact L2 recompute yields pos0=64, pos1=0, so the output must
+ // reorder to pos1-then-pos0 with the recomputed distances.
+ let (reader, split, vector_field) = vector_rerank_fixture(
+ "memory:/rerank_reorder",
+ 2,
+ &[Some(vec![9.0, 0.0]), Some(vec![1.0, 0.0])],
+ &[],
+ )
+ .await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![
+ cand_at(0, RERANK_FILE, 0, 0.1),
+ cand_at(0, RERANK_FILE, 1, 0.9),
+ ];
+
+ let out = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 2,
+ &vector_field,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(out.len(), 2);
+ assert_eq!(out[0].row_position, 1);
+ assert_eq!(out[0].distance, 0.0);
+ assert_eq!(out[1].row_position, 0);
+ assert_eq!(out[1].distance, 64.0);
+ // Order genuinely changed vs. the ANN-reported best-first (which was
pos0).
+ assert!(out[0].distance < out[1].distance);
+ }
+
+ #[tokio::test]
+ async fn rerank_is_independent_of_fast_mode_reranks_indexed() {
+ // The rerank core takes only the indexed (fast-path) candidates and
always
+ // recomputes their true distance; there is no fast/exact switch that
can
+ // skip it. The single candidate carries a bogus ANN distance (0.42)
but its
+ // stored vector equals the query, so the recomputed L2 distance is
exactly
+ // 0.0 — proving the indexed candidate WAS reranked rather than passed
+ // through with its ANN distance.
+ let (reader, split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_indexed", 2,
&[Some(vec![1.0, 0.0])], &[]).await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![cand_at(0, RERANK_FILE, 0, 0.42)];
+
+ let out = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 1,
+ &vector_field,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(out.len(), 1);
+ assert_eq!(out[0].row_position, 0);
+ assert_ne!(out[0].distance, 0.42);
+ assert_eq!(out[0].distance, 0.0);
+ }
+
+ #[tokio::test]
+ async fn rerank_fails_loud_on_null_vector() {
+ // A NULL vector stored AT a candidate position must fail loud rather
than
+ // silently scoring it: the candidate genuinely has no vector to
rerank on.
+ let (reader, split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_null", 2, &[None],
&[]).await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![cand_at(0, RERANK_FILE, 0, 0.1)];
+
+ let err = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 1,
+ &vector_field,
+ )
+ .await
+ .err()
+ .expect("null vector at a candidate position must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("null vector")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn rerank_fails_loud_on_leftover_candidate() {
+ // pos1 is deleted by the deletion vector, so the position read
returns no
+ // row for it. The search path already DV-filters, so a deleted
candidate
+ // reaching rerank is a real inconsistency: the leftover guard must
fail
+ // loud rather than silently dropping the candidate.
+ let (reader, split, vector_field) = vector_rerank_fixture(
+ "memory:/rerank_leftover",
+ 2,
+ &[Some(vec![1.0, 0.0]), Some(vec![2.0, 0.0])],
+ &[1],
+ )
+ .await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![
+ cand_at(0, RERANK_FILE, 0, 0.1),
+ cand_at(0, RERANK_FILE, 1, 0.9),
+ ];
+
+ let err = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 2,
+ &vector_field,
+ )
+ .await
+ .err()
+ .expect("a candidate returning no row must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("failed to read")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn rerank_fails_loud_on_dimension_mismatch() {
+ // Stored vectors are 3-dimensional but the query is 2-dimensional. The
+ // vector extraction validates each stored row against the query
dimension
+ // and fails loud, so the recompute never runs against mismatched
vectors.
+ let (reader, split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_dim", 3, &[Some(vec![1.0,
0.0, 0.0])], &[]).await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![cand_at(0, RERANK_FILE, 0, 0.1)];
+
+ let err = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 1,
+ &vector_field,
+ )
+ .await
+ .err()
+ .expect("dimension mismatch must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("dimension")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn rerank_fails_loud_on_duplicate_candidate_position() {
+ // Two candidates addressing the same (split_index, file, position) is
a
+ // programming error upstream: the dedup guard fires before any read.
+ let (reader, split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_dup", 2, &[Some(vec![1.0,
0.0])], &[]).await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![
+ cand_at(0, RERANK_FILE, 0, 0.1),
+ cand_at(0, RERANK_FILE, 0, 0.9),
+ ];
+
+ let err = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 2,
+ &vector_field,
+ )
+ .await
+ .err()
+ .expect("duplicate candidate position must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("duplicate")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn rerank_fails_loud_on_unexpected_position() {
+ // Every position the read surfaces must resolve to a candidate keyed
by
+ // (split_index, file, position). Here the plan carries two splits for
the
+ // SAME (partition, bucket, file), so `split_index_of` resolves the
file to
+ // the LAST plan index (1). The single candidate is tagged with
split_index
+ // 0, so its by_key entry is (0, file, 0) while the read looks up
+ // (1, file, 0). The lookup misses and the unexpected-position guard
fires
+ // rather than silently dropping the surfaced row.
+ let (reader, split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_unexpected", 2,
&[Some(vec![1.0, 0.0])], &[])
+ .await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![cand_at(0, RERANK_FILE, 0, 0.1)];
+
+ // Two plan entries for the same file: split_index_of ends up mapping
the
+ // file to plan index 1, not the candidate's split_index 0.
+ let dup = PkVectorSearchSplit {
+ data_split: split.data_split.clone(),
+ ann_segments: Vec::new(),
+ active_files: Vec::new(),
+ };
+ let plan = vec![dup, split];
+
+ let err = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &plan,
+ &query,
+ VectorSearchMetric::L2,
+ 1,
+ &vector_field,
+ )
+ .await
+ .err()
+ .expect("a read position absent from the candidate map must fail
loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("unexpected position")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn rerank_fails_loud_on_file_not_in_plan() {
+ // A candidate references a (partition, bucket, file) that is absent
from
+ // plan_splits. build_indexed_splits groups it into an indexed split,
but the
+ // split_index_of lookup — built only from plan_splits — has no entry,
so the
+ // kernel fails loud rather than reading an unplanned file.
+ let (reader, _split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_noplan", 2, &[Some(vec![1.0,
0.0])], &[]).await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![cand_at(0, RERANK_FILE, 0, 0.1)];
+
+ // Empty plan: the candidate's file resolves in no plan split.
+ let err = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[],
+ &query,
+ VectorSearchMetric::L2,
+ 1,
+ &vector_field,
+ )
+ .await
+ .err()
+ .expect("a candidate file absent from the plan must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("not found in plan")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn rerank_reads_only_candidate_positions_not_whole_column() {
+ // A 6-row file where every NON-candidate position (0, 2, 4, 5) holds
a NULL
+ // vector "poison" and only the two candidate positions (1, 3) hold
real
+ // vectors. The rerank read is told to fetch only positions {1, 3};
every
+ // row it surfaces is looked up in the candidate map, and any position
not in
+ // the map trips the "unexpected position" guard (a surfaced NULL row
would
+ // additionally trip the null-vector guard). So if the read had
surfaced any
+ // of the poison rows, rerank would fail. It succeeds and returns
exactly the
+ // two candidates at positions {1, 3}, which proves the position
selection
+ // reaching the read contained only the candidate positions (not the
whole
+ // column).
+ let rows = &[
+ None, // pos0 poison (non-candidate)
+ Some(vec![1.0, 0.0]), // pos1 candidate
+ None, // pos2 poison (non-candidate)
+ Some(vec![3.0, 0.0]), // pos3 candidate
+ None, // pos4 poison (non-candidate)
+ None, // pos5 poison (non-candidate)
+ ];
+ let (reader, split, vector_field) =
+ vector_rerank_fixture("memory:/rerank_spy", 2, rows, &[]).await;
+ let query = vec![1.0f32, 0.0];
+ let indexed = vec![
+ cand_at(0, RERANK_FILE, 1, 0.9),
+ cand_at(0, RERANK_FILE, 3, 0.1),
+ ];
+
+ let out = rerank_indexed_positional(
+ &reader,
+ indexed,
+ &[split],
+ &query,
+ VectorSearchMetric::L2,
+ 2,
+ &vector_field,
+ )
+ .await
+ .unwrap_or_else(|e| {
+ panic!("only candidate positions are read, so the poison NULLs
never decode: {e:?}")
+ });
+
+ assert_eq!(out.len(), 2, "exactly the candidate count of rows was
read");
+ let mut positions: Vec<i64> = out.iter().map(|c|
c.row_position).collect();
+ positions.sort_unstable();
+ assert_eq!(
+ positions,
+ vec![1, 3],
+ "only candidate positions reached the read"
+ );
+ // Recomputed distances confirm each surviving row is its own
candidate's vector.
+ assert_eq!(out[0].row_position, 1);
+ assert_eq!(out[0].distance, 0.0);
+ assert_eq!(out[1].row_position, 3);
+ assert_eq!(out[1].distance, 4.0);
+ }
+
/// Build a real vindex IVF-flat segment trained with `metric`, returning
the
/// serialized bytes. `nlist = 1` keeps training trivial and
deterministic; the
/// only thing the metric check cares about is the persisted metadata
metric.
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs
b/crates/paimon/src/vindex/pkvector/bucket.rs
index 137d7fc0..f3cfc35f 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -142,6 +142,15 @@ pub(crate) fn covered_source_files(
covered
}
+/// Separately bounded approximate-index and exact-fallback candidates for one
+/// bucket. The approximate list may be over-fetched (for later exact
reranking)
+/// while the exact-fallback list stays bounded to the caller's final limit.
+#[derive(Debug)]
+pub(crate) struct BucketSearchResult {
+ pub(crate) indexed: Vec<PkVectorSearchResult>,
+ pub(crate) exact: Vec<PkVectorSearchResult>,
+}
+
/// ANN + exact data-file fallback search for one snapshot bucket. Mirrors Java
/// `org.apache.paimon.index.pkvector.PrimaryKeyVectorBucketSearch.search`.
///
@@ -164,12 +173,16 @@ pub(crate) async fn bucket_search(
+ Send),
query: &[f32],
metric: VectorSearchMetric,
- limit: usize,
+ indexed_limit: usize,
+ exact_limit: usize,
search_options: &HashMap<String, String>,
skip_exact_fallback: bool,
residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
-) -> crate::Result<Vec<PkVectorSearchResult>> {
- if limit == 0 {
+) -> crate::Result<BucketSearchResult> {
+ if indexed_limit == 0 {
+ return Err(data_invalid("vector search limit must be positive"));
+ }
+ if exact_limit == 0 {
return Err(data_invalid("vector search limit must be positive"));
}
@@ -219,7 +232,8 @@ pub(crate) async fn bucket_search(
}
}
- let mut heap: BinaryHeap<WorstFirst> = BinaryHeap::with_capacity(limit +
1);
+ let mut indexed_heap: BinaryHeap<WorstFirst> =
BinaryHeap::with_capacity(indexed_limit + 1);
+ let mut exact_heap: BinaryHeap<WorstFirst> =
BinaryHeap::with_capacity(exact_limit + 1);
let active_source_files: HashSet<String> =
files_by_name.keys().map(|name| name.to_string()).collect();
// Active files whose rows an ANN segment already covers; the exact
fallback
@@ -247,13 +261,13 @@ pub(crate) async fn bucket_search(
segment,
query,
metric,
- limit,
+ indexed_limit,
&active_source_files,
deletion_vectors,
search_options,
residual_ranges,
)? {
- add_candidate(&mut heap, result, limit);
+ add_candidate(&mut indexed_heap, result, indexed_limit);
}
}
@@ -299,17 +313,19 @@ pub(crate) async fn bucket_search(
reader.as_mut(),
query,
metric,
- limit,
+ exact_limit,
&is_excluded,
)? {
- add_candidate(&mut heap, result, limit);
+ add_candidate(&mut exact_heap, result, exact_limit);
}
}
}
- let mut results: Vec<PkVectorSearchResult> = heap.into_iter().map(|w|
w.0).collect();
- results.sort_by(best_first);
- Ok(results)
+ let mut indexed: Vec<PkVectorSearchResult> =
indexed_heap.into_iter().map(|w| w.0).collect();
+ indexed.sort_by(best_first);
+ let mut exact: Vec<PkVectorSearchResult> = exact_heap.into_iter().map(|w|
w.0).collect();
+ exact.sort_by(best_first);
+ Ok(BucketSearchResult { indexed, exact })
}
#[cfg(test)]
@@ -369,6 +385,75 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn dual_limit_bounds_indexed_and_exact_independently() {
+ // One ANN segment covering "ann.mosaic" (3 rows) and one uncovered
exact
+ // file "exact.mosaic" (3 rows). indexed_limit = 3 lets all ANN hits
through;
+ // exact_limit = 1 keeps only the single closest exact hit.
+ let segment = BucketAnnSegment::for_test(meta(&[("ann.mosaic", 3)]));
+ // Fake ANN searcher returns three hits at increasing distance.
+ let ann = FakeAnnSearcher {
+ result: vec![
+ PkVectorSearchResult {
+ data_file_name: "ann.mosaic".into(),
+ row_position: 0,
+ distance: 0.1,
+ },
+ PkVectorSearchResult {
+ data_file_name: "ann.mosaic".into(),
+ row_position: 1,
+ distance: 0.2,
+ },
+ PkVectorSearchResult {
+ data_file_name: "ann.mosaic".into(),
+ row_position: 2,
+ distance: 0.3,
+ },
+ ],
+ };
+ // Exact file has three rows; query nearest is position 0.
+ let mut factory = as_factory(|_f: &BucketActiveFile| ->
ExactReaderFuture<'_> {
+ let reader = ArrayReader::new(
+ 2,
+ vec![
+ Some(vec![1.0, 0.0]),
+ Some(vec![9.0, 0.0]),
+ Some(vec![8.0, 0.0]),
+ ],
+ );
+ Box::pin(async move { Ok(Box::new(reader) as Box<dyn
PkVectorReader>) })
+ });
+ let active_files = vec![active("ann.mosaic", 3),
active("exact.mosaic", 3)];
+ let dvs: HashMap<String, Arc<DeletionVector>> = HashMap::new();
+ let opts = HashMap::new();
+
+ let out = bucket_search(
+ Some(&ann),
+ &[segment],
+ &active_files,
+ &dvs,
+ &mut factory,
+ &[1.0, 0.0],
+ VectorSearchMetric::L2,
+ 3, // indexed_limit
+ 1, // exact_limit
+ &opts,
+ false,
+ None,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(
+ out.indexed.len(),
+ 3,
+ "indexed heap keeps indexed_limit hits"
+ );
+ assert_eq!(out.exact.len(), 1, "exact heap bounded to exact_limit");
+ assert_eq!(out.exact[0].data_file_name, "exact.mosaic");
+ assert_eq!(out.exact[0].row_position, 0);
+ }
+
#[tokio::test]
async fn test_rejects_non_positive_limit() {
let mut factory = as_factory(|_: &BucketActiveFile| ->
ExactReaderFuture<'_> {
@@ -383,6 +468,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
0,
+ 0,
&HashMap::new(),
false,
None,
@@ -417,7 +503,7 @@ mod tests {
let mut factory = as_factory(|_: &BucketActiveFile| ->
ExactReaderFuture<'_> {
Box::pin(async { unreachable!() })
});
- let results = bucket_search(
+ let out = bucket_search(
Some(&ann),
&[segment],
&[active("data-1", 3)],
@@ -426,12 +512,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
3,
+ 3,
&HashMap::new(),
false,
None,
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(3);
// Top-3 BEST_FIRST: (data-1,0), (data-1,1), (data-1,2) — the larger
// data_file_name "data-2" entries are evicted despite equal distance.
assert_eq!(
@@ -469,7 +560,7 @@ mod tests {
let mut factory = as_factory(|_: &BucketActiveFile| ->
ExactReaderFuture<'_> {
Box::pin(async { unreachable!() })
});
- let results = bucket_search(
+ let out = bucket_search(
Some(&ann),
&[segment],
&[active("data-1", 2)],
@@ -478,12 +569,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].row_position, 1);
assert_eq!(results[0].distance, -1.0);
@@ -512,7 +608,7 @@ mod tests {
)) as Box<dyn PkVectorReader>)
})
});
- let results = bucket_search(
+ let out = bucket_search(
Some(&ann),
&[segment],
&[active("data-1", 2), active("data-2", 2)],
@@ -521,12 +617,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
2,
+ 2,
&HashMap::new(),
false,
None,
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(2);
assert_eq!(
results,
vec![
@@ -565,7 +666,7 @@ mod tests {
bm.insert(0); // data-1 position 0 deleted
dvs.insert("data-1".into(), Arc::new(DeletionVector::from_bitmap(bm)));
- let results = bucket_search(
+ let out = bucket_search(
None,
&[],
&[active("data-1", 2), active("data-2", 2)],
@@ -574,6 +675,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
2,
+ 2,
&HashMap::new(),
false,
None,
@@ -582,6 +684,10 @@ mod tests {
.unwrap();
// Candidates: data-2 pos0 {1,0} dist 1.0; data-1 pos1 {2,0} dist 4.0.
// (data-1 pos0 deleted, data-2 pos1 null.)
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(2);
assert_eq!(
results,
vec![
@@ -613,6 +719,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
@@ -640,6 +747,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
@@ -671,7 +779,7 @@ mod tests {
calls.lock().unwrap().push(f.file_name.clone());
Box::pin(async { unreachable!("only data-1 is active and it is
ANN-covered") })
});
- let results = bucket_search(
+ let out = bucket_search(
Some(&ann),
&[segment],
&[active("data-1", 2)],
@@ -680,12 +788,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
2,
+ 2,
&HashMap::new(),
false,
None,
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(2);
assert_eq!(
results,
vec![PkVectorSearchResult {
@@ -713,6 +826,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
@@ -732,7 +846,7 @@ mod tests {
let mut factory = as_factory(|_: &BucketActiveFile| ->
ExactReaderFuture<'_> {
Box::pin(async { unreachable!() })
});
- let results = bucket_search(
+ let out = bucket_search(
None,
&[],
&[active("data-1", 2), active("data-2", 2)],
@@ -741,13 +855,15 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
2,
+ 2,
&HashMap::new(),
true, // skip_exact_fallback
None,
)
.await
.unwrap();
- assert!(results.is_empty());
+ assert!(out.indexed.is_empty());
+ assert!(out.exact.is_empty());
}
#[tokio::test]
@@ -777,6 +893,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
@@ -816,6 +933,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
@@ -844,6 +962,7 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
1,
+ 1,
&HashMap::new(),
false,
None,
@@ -909,7 +1028,7 @@ mod tests {
});
let mut residual: HashMap<String, roaring::RoaringTreemap> =
HashMap::new();
residual.insert("data-1".into(), treemap(&[0, 2]));
- let results = bucket_search(
+ let out = bucket_search(
None,
&[],
&[active("data-1", 3)],
@@ -918,12 +1037,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
5,
+ 5,
&HashMap::new(),
false,
Some(&residual),
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(5);
assert_eq!(
results,
vec![
@@ -957,7 +1081,7 @@ mod tests {
});
let mut residual: HashMap<String, roaring::RoaringTreemap> =
HashMap::new();
residual.insert("data-1".into(), treemap(&[0, 1]));
- let results = bucket_search(
+ let out = bucket_search(
None,
&[],
&[active("data-1", 2), active("data-2", 2)],
@@ -966,12 +1090,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
5,
+ 5,
&HashMap::new(),
false,
Some(&residual),
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(5);
// Only data-1 rows appear; data-2 was never read.
assert!(results.iter().all(|r| r.data_file_name == "data-1"));
assert_eq!(calls.lock().unwrap().as_slice(), &["data-1".to_string()]);
@@ -988,7 +1117,7 @@ mod tests {
});
let mut residual: HashMap<String, roaring::RoaringTreemap> =
HashMap::new();
residual.insert("data-1".into(), treemap(&[]));
- let results = bucket_search(
+ let out = bucket_search(
None,
&[],
&[active("data-1", 3)],
@@ -997,13 +1126,15 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
5,
+ 5,
&HashMap::new(),
false,
Some(&residual),
)
.await
.unwrap();
- assert!(results.is_empty());
+ assert!(out.indexed.is_empty());
+ assert!(out.exact.is_empty());
assert_eq!(*calls.lock().unwrap(), 0);
}
@@ -1029,7 +1160,7 @@ mod tests {
dvs.insert("data-1".into(), Arc::new(DeletionVector::from_bitmap(bm)));
let mut residual: HashMap<String, roaring::RoaringTreemap> =
HashMap::new();
residual.insert("data-1".into(), treemap(&[0, 1, 2]));
- let results = bucket_search(
+ let out = bucket_search(
None,
&[],
&[active("data-1", 3)],
@@ -1038,12 +1169,17 @@ mod tests {
&[0.0, 0.0],
VectorSearchMetric::L2,
5,
+ 5,
&HashMap::new(),
false,
Some(&residual),
)
.await
.unwrap();
+ let mut results = out.indexed.clone();
+ results.extend(out.exact.clone());
+ results.sort_by(best_first);
+ results.truncate(5);
assert_eq!(
results.iter().map(|r| r.row_position).collect::<Vec<_>>(),
vec![1, 2]
diff --git a/crates/paimon/tests/pk_vector_baseline_test.rs
b/crates/paimon/tests/pk_vector_baseline_test.rs
index 8ec2fa62..97d9e18c 100644
--- a/crates/paimon/tests/pk_vector_baseline_test.rs
+++ b/crates/paimon/tests/pk_vector_baseline_test.rs
@@ -1006,3 +1006,487 @@ async fn
pk_vector_residual_filter_excludes_non_matching_rows() {
);
}
}
+
+// --- Exact-rerank coverage --------------------------------------------------
+//
+// The refine factor over-fetches approximate (ANN) candidates and reorders
them
+// by exact distance re-read from the data file. The two tests below drive that
+// end-to-end through the public builder: one where the approximate order and
the
+// exact order genuinely differ (so the reorder is observable), and one where
the
+// search yields no indexed candidates (so the rerank must be skipped cleanly).
+
+/// The query-side option key that requests exact rerank for the `embedding`
+/// PK-vector column. `fields.<col>.ivf.refine-factor` is one of the aliases
the
+/// builder accepts for an `ivf-flat` index (the `ivf.` prefix, `refine-factor`
+/// suffix); it is set as a query option so it takes precedence over table
options.
+fn refine_factor_option(factor: usize) -> HashMap<String, String> {
+ HashMap::from([(
+ format!("fields.{VECTOR_COLUMN}.ivf.refine-factor"),
+ factor.to_string(),
+ )])
+}
+
+/// Primary-key schema `(id INT PRIMARY KEY, embedding VECTOR<FLOAT>)` with
extra
+/// table options merged on top of [`table_options`]. Used to pin table-level
+/// options (e.g. `global-index.search-mode`) that the query-side
`with_options`
+/// cannot influence.
+fn pk_vector_schema_with(extra: &[(&str, &str)]) -> TableSchema {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ VECTOR_COLUMN,
+ DataType::Vector(
+ VectorType::try_new(true, DIM as u32,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ )
+ .primary_key(["id"]);
+ for (k, v) in table_options() {
+ builder = builder.option(k, v);
+ }
+ for (k, v) in extra {
+ builder = builder.option(k.to_string(), v.to_string());
+ }
+ TableSchema::new(0, &builder.build().unwrap())
+}
+
+/// Persist `schema` and write one real data file over `data_vectors` in a
fresh
+/// temp dir, returning the temp dir, the opened table, the `FileIO`, the table
+/// location, the written file's meta rewritten to satisfy the two PK-vector
+/// constraints (compacted, non-level-0, `first_row_id = 0`), and its
+/// bucket / partition.
+async fn write_schema_and_data(
+ schema: &TableSchema,
+ data_vectors: &[[f32; DIM]],
+) -> (
+ tempfile::TempDir,
+ Table,
+ FileIO,
+ String,
+ DataFileMeta,
+ Vec<u8>,
+ i32,
+) {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let location = format!("file://{}", tmp.path().display());
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+
+ for dir in ["schema", "snapshot", "manifest", "index"] {
+ file_io.mkdirs(&format!("{location}/{dir}")).await.unwrap();
+ }
+ file_io
+ .new_output(&format!("{location}/schema/schema-{}", schema.id()))
+ .unwrap()
+ .write(Bytes::from(serde_json::to_vec(schema).unwrap()))
+ .await
+ .unwrap();
+
+ let table = open_table(&file_io, &location).await;
+
+ let write_builder = table.new_write_builder();
+ let mut writer = write_builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&data_batch(data_vectors))
+ .await
+ .unwrap();
+ let write_messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(
+ write_messages.len(),
+ 1,
+ "single bucket -> one write message"
+ );
+ let written = &write_messages[0];
+ assert_eq!(written.new_files.len(), 1, "single data file expected");
+ let base_meta = written.new_files[0].clone();
+
+ let bucket = written.bucket;
+ let partition = written.partition.clone();
+ let indexed_meta = DataFileMeta {
+ level: 1,
+ file_source: Some(1),
+ first_row_id: Some(0),
+ ..base_meta
+ };
+ (
+ tmp,
+ table,
+ file_io,
+ location,
+ indexed_meta,
+ partition,
+ bucket,
+ )
+}
+
+/// Read `execute_read()` into `(id, score)` tuples (best-first) plus the
batches,
+/// mirroring [`read_id_and_scores`] but with caller-supplied query options so
the
+/// refine factor can be requested.
+async fn read_id_and_scores_with_options(
+ table: &Table,
+ query: Vec<f32>,
+ limit: usize,
+ options: HashMap<String, String>,
+) -> (Vec<i32>, Vec<f32>, Vec<RecordBatch>) {
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(VECTOR_COLUMN)
+ .with_query_vector(query)
+ .with_limit(limit)
+ .with_options(options);
+ let batches = builder
+ .execute_read()
+ .await
+ .expect("primary-key vector rerank read failed")
+ .try_collect::<Vec<_>>()
+ .await
+ .expect("collecting rerank read batches failed");
+
+ let ids: Vec<i32> = batches
+ .iter()
+ .flat_map(|b| {
+ let idx = b.schema().index_of("id").unwrap();
+ b.column(idx)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ let scores: Vec<f32> = batches
+ .iter()
+ .flat_map(|b| {
+ let idx = b.schema().index_of("__paimon_search_score").unwrap();
+ b.column(idx)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ (ids, scores, batches)
+}
+
+/// Fixture where the ANN-approximate order and the exact-distance order are
+/// disjoint. The data file holds `data_vectors` (the truth the rerank
re-reads),
+/// while the ANN segment is built over `index_vectors` (a perturbed copy), so
the
+/// approximate recall order differs from the exact order over the true data.
+///
+/// query [10,0,0,0]
+/// position | index_vectors (ANN sees) | data_vectors (truth)
+/// ---------+----------------------------------+---------------------------
+/// 0 | [0,0,0,0] -> 100 | [9,0,0,0] -> 1 (exact
1st)
+/// 1 | [0,0,1,0] -> 101 | [8,0,0,0] -> 4 (exact
2nd)
+/// 2 | [0,0,2,0] -> 104 | [7,0,0,0] -> 9 (exact
3rd)
+/// 3 | [9,0,0,0] -> 1 (ANN 3rd) | [0,0,0,0] -> 100
+/// 4 | [9.5,0,0,0]-> 0.25 (ANN 2nd) | [0,5,0,0] -> 125
+/// 5 | [10,0,0,0] -> 0 (ANN 1st) | [0,0,6,0] -> 136
+///
+/// ANN top-3 = [5, 4, 3]; exact top-3 = [0, 1, 2]. The two are disjoint, so
if the
+/// rerank never ran (gate broken or factor unset) the output would be [5, 4,
3].
+fn fixture_rerank_divergent() -> ([f32; DIM], Vec<[f32; DIM]>, Vec<[f32;
DIM]>) {
+ let query = [10.0, 0.0, 0.0, 0.0];
+ let index_vectors = vec![
+ [0.0, 0.0, 0.0, 0.0], // pos 0
+ [0.0, 0.0, 1.0, 0.0], // pos 1
+ [0.0, 0.0, 2.0, 0.0], // pos 2
+ [9.0, 0.0, 0.0, 0.0], // pos 3
+ [9.5, 0.0, 0.0, 0.0], // pos 4
+ [10.0, 0.0, 0.0, 0.0], // pos 5
+ ];
+ let data_vectors = vec![
+ [9.0, 0.0, 0.0, 0.0], // pos 0
+ [8.0, 0.0, 0.0, 0.0], // pos 1
+ [7.0, 0.0, 0.0, 0.0], // pos 2
+ [0.0, 0.0, 0.0, 0.0], // pos 3
+ [0.0, 5.0, 0.0, 0.0], // pos 4
+ [0.0, 0.0, 6.0, 0.0], // pos 5
+ ];
+ (query, index_vectors, data_vectors)
+}
+
+/// refine_factor > 0 reorders the emitted rows to the exact-distance ground
truth:
+/// the ANN-approximate order (built over a perturbed copy of the vectors)
differs
+/// from the exact order over the true data, and the rerank corrects it.
+///
+/// The fixture builds the ANN segment over `index_vectors` while the data file
+/// holds `data_vectors`, so the approximate recall order [5, 4, 3] is disjoint
+/// from the exact order [0, 1, 2]. With `refine-factor = 2` the search
over-fetches
+/// `limit * 2 = 6` candidates (the whole bucket), then re-reads each
candidate's
+/// true vector and reorders by exact distance. The emitted rows must be the
exact
+/// top-3 [0, 1, 2] with scores computed from the true distances.
+// Gated off Windows for the same `file://` tempdir reason as the tests above.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn pk_vector_refine_factor_matches_exact_ground_truth() {
+ let (query, index_vectors, data_vectors) = fixture_rerank_divergent();
+ let k = 3;
+
+ // Ground truths: the ANN recall order (exact over the perturbed index
vectors,
+ // since nlist = 1 is exhaustive) and the exact order over the true data.
+ let ann_order: Vec<i32> = analytic_topk(&query, &index_vectors, k)
+ .iter()
+ .map(|(id, _)| *id as i32)
+ .collect();
+ let exact = analytic_topk(&query, &data_vectors, k);
+ let exact_ids: Vec<i32> = exact.iter().map(|(id, _)| *id as i32).collect();
+ let exact_scores: Vec<f32> = exact.iter().map(|(_, d)|
l2_score(*d)).collect();
+ assert_eq!(ann_order, vec![5, 4, 3], "fixture guard: ANN order");
+ assert_eq!(exact_ids, vec![0, 1, 2], "fixture guard: exact order");
+ assert_ne!(
+ ann_order, exact_ids,
+ "fixture is only discriminating if ANN order differs from exact order"
+ );
+
+ // Build a table whose ANN segment encodes the perturbed order while the
data
+ // file holds the true vectors, and confirm the segment really recalls the
+ // perturbed order before the read path runs.
+ let schema = pk_vector_schema();
+ let (_tmp, table, file_io, location, indexed_meta, partition, bucket) =
+ write_schema_and_data(&schema, &data_vectors).await;
+ let data_file_name = indexed_meta.file_name.clone();
+ let row_count = indexed_meta.row_count;
+
+ let index_file_name = "vector-ivf-flat-pkvector-rerank.index".to_string();
+ let index_file_size =
+ write_ann_segment(&file_io, &location, &index_file_name,
&index_vectors).await;
+ {
+ let bytes = file_io
+ .new_input(&format!("{location}/index/{index_file_name}"))
+ .unwrap()
+ .read()
+ .await
+ .unwrap();
+ // The segment recalls the perturbed order, not the true-data order.
+ assert_segment_reads_back(&bytes, &query, &analytic_topk(&query,
&index_vectors, k));
+ }
+
+ let vector_field_id = schema
+ .fields()
+ .iter()
+ .find(|f| f.name() == VECTOR_COLUMN)
+ .expect("vector field present")
+ .id();
+ let index_file = IndexFileMeta {
+ index_type: INDEX_TYPE.to_string(),
+ file_name: index_file_name,
+ file_size: i32::try_from(index_file_size).unwrap(),
+ row_count: i32::try_from(row_count).unwrap(),
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: 0,
+ row_range_end: row_count - 1,
+ index_field_id: vector_field_id,
+ extra_field_ids: None,
+ source_meta: Some(source_meta_bytes(
+ indexed_meta.level,
+ &[(&data_file_name, row_count)],
+ )),
+ index_meta: None,
+ }),
+ };
+ let mut message = CommitMessage::new(partition, bucket,
vec![indexed_meta]);
+ message.new_index_files = vec![index_file];
+ TableCommit::new(table.clone(), "pkvector-rerank".to_string())
+ .commit(vec![message])
+ .await
+ .unwrap();
+
+ // Sanity anchor: with NO refine factor the same fixture emits the raw ANN
+ // recall order [5, 4, 3] (the rerank never runs), so any reordering below
is
+ // attributable to the refine factor and not to the fixture happening to
agree.
+ let (unset_ids, _unset_scores, _unset_batches) =
+ read_id_and_scores_with_options(&table, query.to_vec(), k,
HashMap::new()).await;
+ assert_eq!(
+ unset_ids, ann_order,
+ "without a refine factor the rows must stay in raw ANN order [5, 4, 3]"
+ );
+
+ // With the refine factor set, the emitted rows are the EXACT top-3 [0, 1,
2],
+ // not the approximate order [5, 4, 3]: the rerank reordered them.
+ let (ids, scores, batches) =
+ read_id_and_scores_with_options(&table, query.to_vec(), k,
refine_factor_option(2)).await;
+ assert_eq!(
+ ids, exact_ids,
+ "refine factor must reorder rows to exact top-3 [0, 1, 2], not ANN
order [5, 4, 3]"
+ );
+
+ // Row content: each emitted row's vector equals the TRUE data vector at
that
+ // physical position (the rerank read the data file, not the ANN segment).
+ let got_vectors = collect_vectors(&batches);
+ assert_eq!(got_vectors.len(), k, "three rows expected");
+ for (row_idx, (id, _)) in exact.iter().enumerate() {
+ assert_eq!(
+ got_vectors[row_idx],
+ data_vectors[*id as usize].to_vec(),
+ "materialized vector for row id {id} diverges from true data"
+ );
+ }
+
+ // Scores are computed from the recomputed exact distances over the true
data.
+ assert_eq!(scores.len(), k);
+ for (got, want) in scores.iter().zip(&exact_scores) {
+ assert!(
+ (got - want).abs() < 1e-4,
+ "reranked score diverges: got {got}, want {want}"
+ );
+ }
+}
+
+/// Gating: with a positive refine factor set but a search that yields ZERO
+/// indexed (ANN) candidates, the exact rerank must be skipped — no error, no
+/// spurious position read — and the exact-fallback rows come through
unchanged.
+///
+/// The table carries a compacted, non-level-0 data file but NO ANN index
segment,
+/// and `global-index.search-mode = full` enables the exact data-file
fallback. The
+/// search therefore produces exact-fallback candidates while `search.indexed`
is
+/// empty, which is exactly the `refine_factor > 0 &&
!search.indexed.is_empty()`
+/// gate's short-circuit path. The emitted rows must be the exact top-3 the
fallback
+/// found, identical to what a refine-unset run would emit.
+// Gated off Windows for the same `file://` tempdir reason as the tests above.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn pk_vector_refine_factor_with_no_indexed_candidates_is_noop() {
+ // Reuse the discriminating fixture's true-data layout so best-first order
+ // [5, 1, 3] is distinct from ascending position, proving the exact
fallback
+ // ranked correctly rather than emitting rows in file order.
+ let (query, data_vectors) = fixture_discriminating();
+ let k = 3;
+ let exact = analytic_topk(&query, &data_vectors, k);
+ let exact_ids: Vec<i32> = exact.iter().map(|(id, _)| *id as i32).collect();
+ let exact_scores: Vec<f32> = exact.iter().map(|(_, d)|
l2_score(*d)).collect();
+ assert_eq!(exact_ids, vec![5, 1, 3], "fixture guard: exact order");
+
+ // FULL search mode enables the exact data-file fallback; no ANN segment is
+ // committed, so the ANN candidate list stays empty.
+ let schema = pk_vector_schema_with(&[("global-index.search-mode",
"full")]);
+ let (_tmp, table, _file_io, _location, indexed_meta, partition, bucket) =
+ write_schema_and_data(&schema, &data_vectors).await;
+
+ // Commit the data file WITHOUT any index segment: the plan has a
searchable
+ // bucket but zero ANN segments -> zero indexed candidates.
+ let message = CommitMessage::new(partition, bucket, vec![indexed_meta]);
+ TableCommit::new(table.clone(), "pkvector-rerank-noop".to_string())
+ .commit(vec![message])
+ .await
+ .unwrap();
+
+ // A positive refine factor is set, but with no indexed candidates the
rerank is
+ // gated off: execute_read must not error, and returns the exact-fallback
rows.
+ let (ids, scores, batches) =
+ read_id_and_scores_with_options(&table, query.to_vec(), k,
refine_factor_option(2)).await;
+ assert_eq!(
+ ids, exact_ids,
+ "with no indexed candidates the exact-fallback rows must pass through
unchanged"
+ );
+
+ let got_vectors = collect_vectors(&batches);
+ assert_eq!(got_vectors.len(), k, "three rows expected");
+ for (row_idx, (id, _)) in exact.iter().enumerate() {
+ assert_eq!(
+ got_vectors[row_idx],
+ data_vectors[*id as usize].to_vec(),
+ "materialized vector for row id {id} diverges from true data"
+ );
+ }
+ assert_eq!(scores.len(), k);
+ for (got, want) in scores.iter().zip(&exact_scores) {
+ assert!(
+ (got - want).abs() < 1e-4,
+ "exact-fallback score diverges: got {got}, want {want}"
+ );
+ }
+}
+
+/// An invalid refine factor must fail loud even when the table is empty: the
+/// factor is resolved before planning, so config validity does not depend on
+/// whether the table currently has searchable data. Regression for a
resolution
+/// that ran only after the empty-plan early return. Covers both invalid forms
+/// the reviewer named (`0` and a non-integer) and both option sources (query
and
+/// table), since the fix moved the resolution of both sources ahead of
planning.
+#[cfg(not(target_os = "windows"))]
+#[tokio::test]
+async fn pk_vector_invalid_refine_factor_fails_loud_on_empty_table() {
+ // Persist only the schema (optionally with extra table options): no
snapshot,
+ // so the PK-vector plan is empty.
+ async fn empty_table(file_io: &FileIO, location: &str, extra: &[(&str,
&str)]) -> Table {
+ file_io.mkdirs(&format!("{location}/schema")).await.unwrap();
+ let schema = pk_vector_schema_with(extra);
+ file_io
+ .new_output(&format!("{location}/schema/schema-{}", schema.id()))
+ .unwrap()
+ .write(Bytes::from(serde_json::to_vec(&schema).unwrap()))
+ .await
+ .unwrap();
+ open_table(file_io, location).await
+ }
+
+ let refine_key = format!("fields.{VECTOR_COLUMN}.ivf.refine-factor");
+
+ // Case 1: non-integer via a QUERY option -> "Invalid ... Must be an
integer".
+ {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let location = format!("file://{}", tmp.path().display());
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table = empty_table(&file_io, &location, &[]).await;
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(VECTOR_COLUMN)
+ .with_query_vector(vec![0.0f32; DIM])
+ .with_limit(3)
+ .with_options(HashMap::from([(refine_key.clone(),
"abc".to_string())]));
+ let err = match builder.execute_read().await {
+ Ok(_) => panic!("a non-integer refine factor must fail loud on an
empty table"),
+ Err(e) => e,
+ };
+ assert!(
+ err.to_string().contains("Invalid vector refine factor"),
+ "expected a non-integer refine-factor error, got: {err}"
+ );
+ }
+
+ // Case 2: explicit `0` via a QUERY option -> "must be positive" (0 means
+ // "omit"; it cannot be requested).
+ {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let location = format!("file://{}", tmp.path().display());
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table = empty_table(&file_io, &location, &[]).await;
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(VECTOR_COLUMN)
+ .with_query_vector(vec![0.0f32; DIM])
+ .with_limit(3)
+ .with_options(HashMap::from([(refine_key.clone(),
"0".to_string())]));
+ let err = match builder.execute_read().await {
+ Ok(_) => panic!("a zero refine factor must fail loud on an empty
table"),
+ Err(e) => e,
+ };
+ assert!(
+ err.to_string().contains("must be positive"),
+ "expected a positive-refine-factor error, got: {err}"
+ );
+ }
+
+ // Case 3: non-integer via a TABLE option (the fix moved table-option
+ // resolution ahead of planning too).
+ {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let location = format!("file://{}", tmp.path().display());
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ let table = empty_table(&file_io, &location, &[(refine_key.as_str(),
"abc")]).await;
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(VECTOR_COLUMN)
+ .with_query_vector(vec![0.0f32; DIM])
+ .with_limit(3);
+ let err = match builder.execute_read().await {
+ Ok(_) => panic!("a non-integer table refine factor must fail loud
on an empty table"),
+ Err(e) => e,
+ };
+ assert!(
+ err.to_string().contains("Invalid vector refine factor"),
+ "expected a non-integer refine-factor error, got: {err}"
+ );
+ }
+}