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 8e44700 [core] Add primary-key vector bucket search kernel (#516)
8e44700 is described below
commit 8e44700eb38f2267f6f0cf7746e622bfc7495099
Author: Junrui Lee <[email protected]>
AuthorDate: Tue Jul 14 11:24:04 2026 +0800
[core] Add primary-key vector bucket search kernel (#516)
---
crates/paimon/src/deletion_vector/core.rs | 20 +
crates/paimon/src/spec/pk_vector_source.rs | 4 +-
crates/paimon/src/vindex/mod.rs | 2 +
crates/paimon/src/vindex/pkvector/ann.rs | 459 +++++++++++++++++++++++
crates/paimon/src/vindex/pkvector/bucket.rs | 554 ++++++++++++++++++++++++++++
crates/paimon/src/vindex/pkvector/exact.rs | 289 +++++++++++++++
crates/paimon/src/vindex/pkvector/metric.rs | 211 +++++++++++
crates/paimon/src/vindex/pkvector/mod.rs | 42 +++
crates/paimon/src/vindex/pkvector/reader.rs | 80 ++++
crates/paimon/src/vindex/pkvector/result.rs | 27 ++
10 files changed, 1686 insertions(+), 2 deletions(-)
diff --git a/crates/paimon/src/deletion_vector/core.rs
b/crates/paimon/src/deletion_vector/core.rs
index 11c2d06..4164da6 100644
--- a/crates/paimon/src/deletion_vector/core.rs
+++ b/crates/paimon/src/deletion_vector/core.rs
@@ -59,6 +59,15 @@ impl DeletionVector {
self.bitmap.len()
}
+ /// Returns true if `position` is deleted. Positions above `u32::MAX`
cannot be
+ /// present in a roaring32 bitmap and are therefore reported as not
deleted.
+ /// Mirrors Java `BitmapDeletionVector#isDeleted` / the searchers'
`LongPredicate`.
+ pub fn is_deleted(&self, position: u64) -> bool {
+ u32::try_from(position)
+ .ok()
+ .is_some_and(|p| self.bitmap.contains(p))
+ }
+
/// Returns an iterator over deleted positions that supports
[DeletionVectorIterator::advance_to].
/// Required for efficient row selection building when skipping row groups
(avoid re-scanning
/// deletes in skipped ranges).
@@ -249,4 +258,15 @@ mod tests {
let expected_bitmap = RoaringBitmap::from_iter([1u32, 2u32]);
assert_eq!(dv.bitmap(), &expected_bitmap, "bitmap should be [1, 2]");
}
+
+ #[test]
+ fn test_is_deleted_reports_membership_and_guards_u32_overflow() {
+ let mut bitmap = RoaringBitmap::new();
+ bitmap.insert(2);
+ let dv = DeletionVector::from_bitmap(bitmap);
+ assert!(dv.is_deleted(2), "position 2 was deleted");
+ assert!(!dv.is_deleted(0), "position 0 was not deleted");
+ // Positions above u32::MAX cannot exist in a roaring32 bitmap -> not
deleted.
+ assert!(!dv.is_deleted(u64::from(u32::MAX) + 1));
+ }
}
diff --git a/crates/paimon/src/spec/pk_vector_source.rs
b/crates/paimon/src/spec/pk_vector_source.rs
index 2685f45..2c859e0 100644
--- a/crates/paimon/src/spec/pk_vector_source.rs
+++ b/crates/paimon/src/spec/pk_vector_source.rs
@@ -157,8 +157,8 @@ impl PkVectorSourceMeta {
}
/// Minimal big-endian reader mirroring the Java `DataInput` primitives the
-/// `_SOURCE_META` frame uses. Module-private by design (see the PR1 spec: no
-/// shared `common/` abstraction until a second consumer exists).
+/// `_SOURCE_META` frame uses. Module-private by design: no shared `common/`
+/// abstraction until a second consumer exists.
struct DataInputCursor<'a> {
bytes: &'a [u8],
position: usize,
diff --git a/crates/paimon/src/vindex/mod.rs b/crates/paimon/src/vindex/mod.rs
index c0d566a..77457ef 100644
--- a/crates/paimon/src/vindex/mod.rs
+++ b/crates/paimon/src/vindex/mod.rs
@@ -17,6 +17,8 @@
pub mod reader;
+pub mod pkvector;
+
use crate::spec::{DataField, DataType};
use paimon_vindex_core::index::VectorIndexConfig;
use std::collections::HashMap;
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs
b/crates/paimon/src/vindex/pkvector/ann.rs
new file mode 100644
index 0000000..f87e7f9
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -0,0 +1,459 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+use super::bucket::BucketAnnSegment;
+use super::data_invalid;
+use super::metric::VectorSearchMetric;
+use super::result::PkVectorSearchResult;
+use crate::deletion_vector::DeletionVector;
+use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
+use crate::vector_search::VectorSearch;
+
+/// Build the live-row-id mask for the ANN reader's `include_row_ids` filter,
in
+/// segment-ordinal space (source files concatenated in order). Mirrors Java
+/// `PkVectorAnnSegmentSearcher.liveRowPositions`.
+///
+/// Only source files present in `active_source_files` contribute live
ordinals;
+/// inactive sources' ordinal ranges are masked out entirely (their rows are no
+/// longer readable in this snapshot). Deletion vectors are applied only to
active
+/// sources.
+///
+/// Returns `None` only when every source file is active AND no deletion
vector is
+/// relevant — nothing to mask. Otherwise returns the masked live ids.
+pub(crate) fn build_live_row_ids(
+ source_files: &[PkVectorSourceFile],
+ active_source_files: &HashSet<String>,
+ deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+) -> crate::Result<Option<roaring::RoaringTreemap>> {
+ let all_active = source_files
+ .iter()
+ .all(|f| active_source_files.contains(f.file_name()));
+ let has_relevant_dv = source_files
+ .iter()
+ .any(|f| deletion_vectors.contains_key(f.file_name()));
+ if all_active && !has_relevant_dv {
+ return Ok(None);
+ }
+
+ let mut live = roaring::RoaringTreemap::new();
+ let mut deleted = roaring::RoaringTreemap::new();
+ let mut file_offset: u64 = 0;
+ for source_file in source_files {
+ let row_count = u64::try_from(source_file.row_count())
+ .map_err(|_| data_invalid("vector source row count must not be
negative"))?;
+ let end = file_offset
+ .checked_add(row_count)
+ .ok_or_else(|| data_invalid("vector source row counts overflow
u64"))?;
+ let active = active_source_files.contains(source_file.file_name());
+ if active && row_count > 0 {
+ live.insert_range(file_offset..end);
+ }
+ if active {
+ if let Some(dv) = deletion_vectors.get(source_file.file_name()) {
+ for position in dv.iter() {
+ let global =
file_offset.checked_add(position).ok_or_else(|| {
+ data_invalid("vector source deleted position overflows
u64")
+ })?;
+ deleted.insert(global);
+ }
+ }
+ }
+ file_offset = end;
+ }
+ live -= deleted;
+ Ok(Some(live))
+}
+
+/// Map ANN `(ordinal, score)` pairs to physical `(data file, position)`
results,
+/// validating ordinals against source metadata, rejecting hits that resolve
to an
+/// inactive source file, and rejecting hits on snapshot-deleted rows. Mirrors
the
+/// post-processing loop of Java `PkVectorAnnSegmentSearcher.search`. Results
are
+/// sorted BEST_FIRST.
+pub(crate) fn map_ann_results(
+ scored: &[(u64, f32)],
+ source_meta: &PkVectorSourceMeta,
+ active_source_files: &HashSet<String>,
+ deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+ metric: VectorSearchMetric,
+) -> crate::Result<Vec<PkVectorSearchResult>> {
+ let mut results = Vec::with_capacity(scored.len());
+ for &(ordinal, score) in scored {
+ let ordinal_i64 = i64::try_from(ordinal)
+ .map_err(|_| data_invalid(format!("ANN ordinal {ordinal} exceeds
i64::MAX")))?;
+ let (data_file_name, row_position) = source_meta.resolve(ordinal_i64)?;
+ if !active_source_files.contains(&data_file_name) {
+ return Err(data_invalid(format!(
+ "ANN segment returned inactive source {data_file_name}"
+ )));
+ }
+ if let Some(dv) = deletion_vectors.get(&data_file_name) {
+ let pos = u64::try_from(row_position)
+ .map_err(|_| data_invalid("resolved row position must not be
negative"))?;
+ if dv.is_deleted(pos) {
+ return Err(data_invalid(format!(
+ "ANN segment returned snapshot-deleted row position
{row_position} in {data_file_name}"
+ )));
+ }
+ }
+ results.push(PkVectorSearchResult {
+ data_file_name,
+ row_position,
+ distance: metric.score_to_distance(score),
+ });
+ }
+ results.sort_by(|a, b| {
+ a.distance
+ .total_cmp(&b.distance)
+ .then_with(|| a.data_file_name.cmp(&b.data_file_name))
+ .then_with(|| a.row_position.cmp(&b.row_position))
+ });
+ Ok(results)
+}
+
+/// One ANN segment's search dependency for the bucket kernel. Bucket tests
fake
+/// this (mirroring Java's mock of `PkVectorAnnSegmentSearcher`).
+pub(crate) trait PkVectorAnnSearcher {
+ #[allow(clippy::too_many_arguments)]
+ fn search(
+ &self,
+ segment: &BucketAnnSegment,
+ query: &[f32],
+ metric: VectorSearchMetric,
+ limit: usize,
+ active_source_files: &HashSet<String>,
+ deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+ search_options: &HashMap<String, String>,
+ ) -> crate::Result<Vec<PkVectorSearchResult>>;
+}
+
+/// Scorer seam: drives the underlying vindex ANN reader. Returns `ordinal ->
+/// score` (higher-is-better). Any negative labels are skipped by the existing
+/// `vindex` reader (`collect_results` drops `row_id < 0`), so this seam only
+/// ever yields non-negative `u64` ordinals — no signed-label handling is
needed
+/// downstream.
+///
+/// The production scorer drives
`VindexVectorGlobalIndexReader::visit_vector_search`
+/// with a segment's index bytes; tests inject a synthetic scorer. The
adapter's
+/// own logic (live-row masking, ordinal mapping, deletion checks, ordering) is
+/// exercised independently of the scorer.
+type Scorer = Box<dyn Fn(&VectorSearch) -> crate::Result<Option<HashMap<u64,
f32>>>>;
+
+/// Structural vindex-backed `PkVectorAnnSearcher`. Composes the pure helpers
+/// (`build_live_row_ids`, `map_ann_results`) around the scorer seam.
+pub(crate) struct VindexAnnSearcher {
+ field_name: String,
+ scorer: Scorer,
+}
+
+impl VindexAnnSearcher {
+ pub(crate) fn new(field_name: String, scorer: Scorer) -> Self {
+ Self { field_name, scorer }
+ }
+}
+
+impl PkVectorAnnSearcher for VindexAnnSearcher {
+ fn search(
+ &self,
+ segment: &BucketAnnSegment,
+ query: &[f32],
+ metric: VectorSearchMetric,
+ limit: usize,
+ active_source_files: &HashSet<String>,
+ deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+ search_options: &HashMap<String, String>,
+ ) -> crate::Result<Vec<PkVectorSearchResult>> {
+ if limit == 0 {
+ return Err(data_invalid("vector search limit must be positive"));
+ }
+ let source_files = segment.source_meta.source_files();
+ let mut search = VectorSearch::new(query.to_vec(), limit,
self.field_name.clone())?
+ .with_options(search_options.clone());
+ if let Some(live) = build_live_row_ids(source_files,
active_source_files, deletion_vectors)?
+ {
+ search = search.with_include_row_ids(live);
+ }
+ let scored = match (self.scorer)(&search)? {
+ Some(map) => map,
+ None => return Ok(Vec::new()),
+ };
+ let scored: Vec<(u64, f32)> = scored.into_iter().collect();
+ map_ann_results(
+ &scored,
+ &segment.source_meta,
+ active_source_files,
+ deletion_vectors,
+ metric,
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use roaring::RoaringBitmap;
+
+ fn source_meta(files: &[(&str, i64)]) -> PkVectorSourceMeta {
+ let files = files
+ .iter()
+ .map(|(name, rows)| PkVectorSourceFile::new((*name).to_string(),
*rows).unwrap())
+ .collect();
+ PkVectorSourceMeta::new(files).unwrap()
+ }
+
+ fn dv(deleted: &[u32]) -> Arc<DeletionVector> {
+ let mut bitmap = RoaringBitmap::new();
+ for &p in deleted {
+ bitmap.insert(p);
+ }
+ Arc::new(DeletionVector::from_bitmap(bitmap))
+ }
+
+ fn active_set(names: &[&str]) -> HashSet<String> {
+ names.iter().map(|n| (*n).to_string()).collect()
+ }
+
+ #[test]
+ fn test_build_live_row_ids_none_when_all_active_and_no_relevant_dv() {
+ let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
+ let active = active_set(&["f0"]);
+ // All active + empty map -> None.
+ assert!(build_live_row_ids(&files, &active, &HashMap::new())
+ .unwrap()
+ .is_none());
+ // All active + non-empty map but no matching file name -> None.
+ let mut dvs = HashMap::new();
+ dvs.insert("other".to_string(), dv(&[0]));
+ assert!(build_live_row_ids(&files, &active, &dvs).unwrap().is_none());
+ }
+
+ #[test]
+ fn test_build_live_row_ids_masks_inactive_source_ordinal_range() {
+ // f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4). f1 is
inactive,
+ // so its whole ordinal range is masked out; f0 stays fully live. No
DV.
+ let files = vec![
+ PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+ PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ ];
+ let live = build_live_row_ids(&files, &active_set(&["f0"]),
&HashMap::new())
+ .unwrap()
+ .unwrap();
+ assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 1, 2]);
+ }
+
+ #[test]
+ fn test_build_live_row_ids_masks_deleted_positions_with_file_offsets() {
+ // f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4).
+ let files = vec![
+ PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+ PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ ];
+ let mut dvs = HashMap::new();
+ dvs.insert("f0".to_string(), dv(&[1])); // deletes global 1
+ dvs.insert("f1".to_string(), dv(&[0])); // deletes global 3
+ let live = build_live_row_ids(&files, &active_set(&["f0", "f1"]), &dvs)
+ .unwrap()
+ .unwrap();
+ assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 2, 4]);
+ }
+
+ #[test]
+ fn test_map_ann_results_maps_ordinals_to_positions_and_scores() {
+ let meta = source_meta(&[("f0", 3), ("f1", 5)]);
+ // ordinal 3 -> (f1, 0); ordinal 0 -> (f0, 0). l2
score_to_distance(0.5)=1.0.
+ let scored = [(3u64, 0.5f32), (0u64, 0.5f32)];
+ let results = map_ann_results(
+ &scored,
+ &meta,
+ &active_set(&["f0", "f1"]),
+ &HashMap::new(),
+ VectorSearchMetric::L2,
+ )
+ .unwrap();
+ assert_eq!(
+ results,
+ vec![
+ PkVectorSearchResult {
+ data_file_name: "f0".into(),
+ row_position: 0,
+ distance: 1.0
+ },
+ PkVectorSearchResult {
+ data_file_name: "f1".into(),
+ row_position: 0,
+ distance: 1.0
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_map_ann_results_rejects_out_of_range_ordinal() {
+ let meta = source_meta(&[("f0", 3)]);
+ let err = map_ann_results(
+ &[(3u64, 0.5)],
+ &meta,
+ &active_set(&["f0"]),
+ &HashMap::new(),
+ VectorSearchMetric::L2,
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("out of range") ||
err.to_string().contains("ordinal"));
+ }
+
+ #[test]
+ fn test_map_ann_results_rejects_hit_resolving_to_inactive_source() {
+ // ordinal 3 resolves to f1, which is not in the active set -> error.
+ let meta = source_meta(&[("f0", 3), ("f1", 5)]);
+ let err = map_ann_results(
+ &[(3u64, 0.5)],
+ &meta,
+ &active_set(&["f0"]),
+ &HashMap::new(),
+ VectorSearchMetric::L2,
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("inactive"));
+ }
+
+ #[test]
+ fn test_map_ann_results_rejects_hit_on_deleted_position() {
+ let meta = source_meta(&[("f0", 3)]);
+ let mut dvs = HashMap::new();
+ dvs.insert("f0".to_string(), dv(&[1])); // position 1 deleted
+ let err = map_ann_results(
+ &[(1u64, 0.5)],
+ &meta,
+ &active_set(&["f0"]),
+ &dvs,
+ VectorSearchMetric::L2,
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("deleted"));
+ }
+
+ #[test]
+ fn test_vindex_adapter_composes_live_rows_and_maps_results() {
+ // Scorer records the VectorSearch it received and returns synthetic
ordinals.
+ // The scorer must be `'static`, so share the recording cells via `Rc`
moved
+ // into the closure rather than borrowing locals.
+ use std::cell::RefCell;
+ use std::rc::Rc;
+ let seen_limit = Rc::new(RefCell::new(0usize));
+ let seen_has_filter = Rc::new(RefCell::new(false));
+ let scorer_limit = Rc::clone(&seen_limit);
+ let scorer_has_filter = Rc::clone(&seen_has_filter);
+ let searcher = VindexAnnSearcher::new(
+ "embedding".to_string(),
+ Box::new(move |search: &VectorSearch| {
+ *scorer_limit.borrow_mut() = search.limit;
+ *scorer_has_filter.borrow_mut() =
search.include_row_ids.is_some();
+ let mut scores = HashMap::new();
+ scores.insert(3u64, 0.5f32); // -> (f1, 0)
+ scores.insert(0u64, 0.25f32); // -> (f0, 0), l2 dist 3.0
+ Ok(Some(scores))
+ }),
+ );
+ let segment = BucketAnnSegment {
+ source_meta: {
+ use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
+ PkVectorSourceMeta::new(vec![
+ PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+ PkVectorSourceFile::new("f1".into(), 5).unwrap(),
+ ])
+ .unwrap()
+ },
+ };
+ let mut dvs = HashMap::new();
+ dvs.insert("f0".to_string(), dv(&[1]));
+ let results = searcher
+ .search(
+ &segment,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &active_set(&["f0", "f1"]),
+ &dvs,
+ &HashMap::new(),
+ )
+ .unwrap();
+ // Sorted BEST_FIRST by distance: (f1,0) dist 1.0 then (f0,0) dist 3.0.
+ assert_eq!(results[0].data_file_name, "f1");
+ assert_eq!(results[1].data_file_name, "f0");
+ assert_eq!(*seen_limit.borrow(), 2);
+ assert!(
+ *seen_has_filter.borrow(),
+ "DV present -> include_row_ids set"
+ );
+ }
+
+ #[test]
+ fn test_vindex_adapter_rejects_non_positive_limit() {
+ let searcher = VindexAnnSearcher::new(
+ "embedding".to_string(),
+ Box::new(|_: &VectorSearch| Ok(None)),
+ );
+ let segment = BucketAnnSegment {
+ source_meta: {
+ use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
+
PkVectorSourceMeta::new(vec![PkVectorSourceFile::new("f0".into(), 1).unwrap()])
+ .unwrap()
+ },
+ };
+ let err = searcher
+ .search(
+ &segment,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 0,
+ &active_set(&["f0"]),
+ &HashMap::new(),
+ &HashMap::new(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("positive"));
+ }
+
+ #[test]
+ fn test_vindex_adapter_empty_scorer_result_is_empty() {
+ let searcher = VindexAnnSearcher::new(
+ "embedding".to_string(),
+ Box::new(|_: &VectorSearch| Ok(None)),
+ );
+ let segment = BucketAnnSegment {
+ source_meta: {
+ use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
+
PkVectorSourceMeta::new(vec![PkVectorSourceFile::new("f0".into(), 1).unwrap()])
+ .unwrap()
+ },
+ };
+ let results = searcher
+ .search(
+ &segment,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &active_set(&["f0"]),
+ &HashMap::new(),
+ &HashMap::new(),
+ )
+ .unwrap();
+ assert!(results.is_empty());
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs
b/crates/paimon/src/vindex/pkvector/bucket.rs
new file mode 100644
index 0000000..23a2b1c
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -0,0 +1,554 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::cmp::Ordering;
+use std::collections::{BinaryHeap, HashMap, HashSet};
+use std::sync::Arc;
+
+use super::ann::PkVectorAnnSearcher;
+use super::data_invalid;
+use super::exact::exact_search;
+use super::metric::VectorSearchMetric;
+use super::reader::PkVectorReader;
+use super::result::PkVectorSearchResult;
+use crate::deletion_vector::DeletionVector;
+use crate::spec::PkVectorSourceMeta;
+
+/// One ANN segment to be searched by the bucket kernel: the source metadata
+/// resolving segment ordinals back to physical `(data file, position)`. Only
+/// `source_meta` is needed for ordinal mapping and live-row masking.
+pub(crate) struct BucketAnnSegment {
+ pub source_meta: PkVectorSourceMeta,
+}
+
+/// A data file participating in the bucket search, with its row count. Used by
+/// the bucket kernel to plan exact vs. ANN search over active files.
+pub(crate) struct BucketActiveFile {
+ pub file_name: String,
+ pub row_count: i64,
+}
+
+/// Total BEST_FIRST order over results: distance ASC, then data_file_name ASC,
+/// then row_position ASC. `total_cmp` keeps it NaN-safe and panic-free.
+fn best_first(a: &PkVectorSearchResult, b: &PkVectorSearchResult) -> Ordering {
+ a.distance
+ .total_cmp(&b.distance)
+ .then_with(|| a.data_file_name.cmp(&b.data_file_name))
+ .then_with(|| a.row_position.cmp(&b.row_position))
+}
+
+/// A candidate wrapped so a max-heap keeps the WORST (BEST_FIRST-largest)
+/// candidate on top; popping evicts the least-wanted one. Mirrors the
+/// `PriorityQueue<>(limit, BEST_FIRST.reversed())` in Java
+/// `PrimaryKeyVectorBucketSearch`.
+struct WorstFirst(PkVectorSearchResult);
+
+impl PartialEq for WorstFirst {
+ fn eq(&self, other: &Self) -> bool {
+ best_first(&self.0, &other.0) == Ordering::Equal
+ }
+}
+impl Eq for WorstFirst {}
+impl PartialOrd for WorstFirst {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+impl Ord for WorstFirst {
+ fn cmp(&self, other: &Self) -> Ordering {
+ best_first(&self.0, &other.0)
+ }
+}
+
+/// Add `candidate` to a bounded (size `limit`) BEST_FIRST Top-K max-heap:
push if
+/// under capacity, else replace the current worst iff the candidate beats it.
+/// `O(log limit)` per call. Mirrors Java `PrimaryKeyVectorBucketSearch.add`.
+fn add_candidate(heap: &mut BinaryHeap<WorstFirst>, candidate:
PkVectorSearchResult, limit: usize) {
+ if heap.len() < limit {
+ heap.push(WorstFirst(candidate));
+ } else if heap
+ .peek()
+ .is_some_and(|worst| best_first(&candidate, &worst.0) ==
Ordering::Less)
+ {
+ heap.pop();
+ heap.push(WorstFirst(candidate));
+ }
+}
+
+/// ANN + exact data-file fallback search for one snapshot bucket. Mirrors Java
+/// `org.apache.paimon.index.pkvector.PrimaryKeyVectorBucketSearch.search`.
+///
+/// `ann_searcher` may be `None` only when there are no ANN segments; segments
+/// present with `None` is an error.
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn bucket_search(
+ ann_searcher: Option<&dyn PkVectorAnnSearcher>,
+ ann_segments: &[BucketAnnSegment],
+ active_files: &[BucketActiveFile],
+ deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+ exact_reader_factory: &mut dyn FnMut(
+ &BucketActiveFile,
+ ) -> crate::Result<Box<dyn PkVectorReader>>,
+ query: &[f32],
+ metric: VectorSearchMetric,
+ limit: usize,
+ search_options: &HashMap<String, String>,
+) -> crate::Result<Vec<PkVectorSearchResult>> {
+ if limit == 0 {
+ return Err(data_invalid("vector search limit must be positive"));
+ }
+
+ let mut files_by_name: HashMap<&str, &BucketActiveFile> = HashMap::new();
+ for file in active_files {
+ if file.row_count < 0 {
+ return Err(data_invalid(format!(
+ "active data file {} row count must not be negative: {}",
+ file.file_name, file.row_count
+ )));
+ }
+ if files_by_name
+ .insert(file.file_name.as_str(), file)
+ .is_some()
+ {
+ return Err(data_invalid(format!(
+ "duplicate data file: {}",
+ file.file_name
+ )));
+ }
+ }
+
+ let mut heap: BinaryHeap<WorstFirst> = BinaryHeap::with_capacity(limit +
1);
+ let active_source_files: HashSet<String> =
+ files_by_name.keys().map(|name| name.to_string()).collect();
+ let mut covered: HashSet<String> = HashSet::new();
+
+ for segment in ann_segments {
+ for source in segment.source_meta.source_files() {
+ // An ANN source that is no longer an active file (e.g. compacted
away)
+ // is skipped, not rejected: its ordinal range is masked out of
the ANN
+ // live-row bitmap and the remaining active sources are still
searched.
+ // Mirrors Java master `PrimaryKeyVectorBucketSearch` (`file ==
null`
+ // -> continue). Active sources still require a row-count match.
+ match files_by_name.get(source.file_name()) {
+ Some(active) if active.row_count == source.row_count() => {
+ covered.insert(source.file_name().to_string());
+ }
+ Some(_) => {
+ return Err(data_invalid(format!(
+ "ANN source {} does not match the active data file",
+ source.file_name()
+ )));
+ }
+ None => continue,
+ }
+ }
+ let searcher = ann_searcher.ok_or_else(|| data_invalid("ANN search is
not configured"))?;
+ for result in searcher.search(
+ segment,
+ query,
+ metric,
+ limit,
+ &active_source_files,
+ deletion_vectors,
+ search_options,
+ )? {
+ add_candidate(&mut heap, result, limit);
+ }
+ }
+
+ for file in active_files {
+ if covered.contains(&file.file_name) {
+ continue;
+ }
+ let dv = deletion_vectors.get(&file.file_name).cloned();
+ let is_excluded = move |position: i64| -> bool {
+ match &dv {
+ Some(dv) => u64::try_from(position)
+ .map(|p| dv.is_deleted(p))
+ .unwrap_or(false),
+ None => false,
+ }
+ };
+ let mut reader = exact_reader_factory(file)?;
+ for result in exact_search(
+ &file.file_name,
+ reader.as_mut(),
+ query,
+ metric,
+ limit,
+ &is_excluded,
+ )? {
+ add_candidate(&mut heap, result, limit);
+ }
+ }
+
+ let mut results: Vec<PkVectorSearchResult> = heap.into_iter().map(|w|
w.0).collect();
+ results.sort_by(best_first);
+ Ok(results)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::PkVectorSourceFile;
+ use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
+ use crate::vindex::pkvector::reader::test_support::ArrayReader;
+ use roaring::RoaringBitmap;
+ use std::cell::RefCell;
+
+ fn meta(files: &[(&str, i64)]) -> PkVectorSourceMeta {
+ PkVectorSourceMeta::new(
+ files
+ .iter()
+ .map(|(n, r)| PkVectorSourceFile::new((*n).into(),
*r).unwrap())
+ .collect(),
+ )
+ .unwrap()
+ }
+
+ fn active(name: &str, rows: i64) -> BucketActiveFile {
+ BucketActiveFile {
+ file_name: name.into(),
+ row_count: rows,
+ }
+ }
+
+ /// Fake ANN searcher returning preset results and recording calls.
+ struct FakeAnnSearcher {
+ result: Vec<PkVectorSearchResult>,
+ }
+ impl PkVectorAnnSearcher for FakeAnnSearcher {
+ fn search(
+ &self,
+ _segment: &BucketAnnSegment,
+ _query: &[f32],
+ _metric: VectorSearchMetric,
+ _limit: usize,
+ _active_source_files: &HashSet<String>,
+ _dvs: &HashMap<String, Arc<DeletionVector>>,
+ _opts: &HashMap<String, String>,
+ ) -> crate::Result<Vec<PkVectorSearchResult>> {
+ Ok(self.result.clone())
+ }
+ }
+
+ #[test]
+ fn test_rejects_non_positive_limit() {
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let err = bucket_search(
+ None,
+ &[],
+ &[],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 0,
+ &HashMap::new(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("positive"));
+ }
+
+ #[test]
+ fn test_bounded_heap_evicts_by_best_first_tiebreak_over_limit() {
+ // All candidates share distance 1.0, so eviction is decided purely by
the
+ // BEST_FIRST tie-break (data_file_name ASC, then row_position ASC).
Feed
+ // more than `limit` ANN hits and assert the kept set is the smallest
+ // (file, position) pairs in that order. Locks the bounded-heap merge.
+ let segment = BucketAnnSegment {
+ source_meta: meta(&[("data-1", 3)]),
+ };
+ let hit = |file: &str, pos: i64| PkVectorSearchResult {
+ data_file_name: file.into(),
+ row_position: pos,
+ distance: 1.0,
+ };
+ // Deliberately unsorted input across two files at the same distance.
+ let ann = FakeAnnSearcher {
+ result: vec![
+ hit("data-2", 0),
+ hit("data-1", 2),
+ hit("data-1", 0),
+ hit("data-2", 1),
+ hit("data-1", 1),
+ ],
+ };
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let results = bucket_search(
+ Some(&ann),
+ &[segment],
+ &[active("data-1", 3)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 3,
+ &HashMap::new(),
+ )
+ .unwrap();
+ // 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!(
+ results
+ .iter()
+ .map(|r| (r.data_file_name.as_str(), r.row_position))
+ .collect::<Vec<_>>(),
+ vec![("data-1", 0), ("data-1", 1), ("data-1", 2)]
+ );
+ }
+
+ #[test]
+ fn test_merges_ann_and_exact_without_rescanning_covered_files() {
+ // data-1 is ANN-covered; data-2 is exact fallback. Factory must never
be
+ // called for data-1.
+ let segment = BucketAnnSegment {
+ source_meta: meta(&[("data-1", 2)]),
+ };
+ let ann = FakeAnnSearcher {
+ result: vec![PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 1,
+ distance: 0.5,
+ }],
+ };
+ let calls = RefCell::new(Vec::<String>::new());
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ calls.borrow_mut().push(f.file_name.clone());
+ // data-2 vectors: pos0 {1,0} dist 1.0, pos1 {3,0} dist 9.0
+ Ok(Box::new(ArrayReader::new(
+ 2,
+ vec![Some(vec![1.0, 0.0]), Some(vec![3.0, 0.0])],
+ )))
+ };
+ let results = bucket_search(
+ Some(&ann),
+ &[segment],
+ &[active("data-1", 2), active("data-2", 2)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &HashMap::new(),
+ )
+ .unwrap();
+ assert_eq!(
+ results,
+ vec![
+ PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 1,
+ distance: 0.5
+ },
+ PkVectorSearchResult {
+ data_file_name: "data-2".into(),
+ row_position: 0,
+ distance: 1.0
+ },
+ ]
+ );
+ assert_eq!(calls.borrow().as_slice(), &["data-2".to_string()]);
+ }
+
+ #[test]
+ fn test_exact_fallback_merges_files_and_applies_deletion_vectors() {
+ // No ANN. data-1 pos0 {0,0} deleted; remaining candidates merge
across files.
+ let calls = RefCell::new(0);
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ *calls.borrow_mut() += 1;
+ let vectors = match f.file_name.as_str() {
+ "data-1" => vec![Some(vec![0.0, 0.0]), Some(vec![2.0, 0.0])],
+ "data-2" => vec![Some(vec![1.0, 0.0]), None],
+ _ => unreachable!(),
+ };
+ Ok(Box::new(ArrayReader::new(2, vectors)))
+ };
+ let mut dvs: HashMap<String, Arc<DeletionVector>> = HashMap::new();
+ let mut bm = RoaringBitmap::new();
+ bm.insert(0); // data-1 position 0 deleted
+ dvs.insert("data-1".into(), Arc::new(DeletionVector::from_bitmap(bm)));
+
+ let results = bucket_search(
+ None,
+ &[],
+ &[active("data-1", 2), active("data-2", 2)],
+ &dvs,
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &HashMap::new(),
+ )
+ .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.)
+ assert_eq!(
+ results,
+ vec![
+ PkVectorSearchResult {
+ data_file_name: "data-2".into(),
+ row_position: 0,
+ distance: 1.0
+ },
+ PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 1,
+ distance: 4.0
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_rejects_duplicate_active_file_name() {
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let err = bucket_search(
+ None,
+ &[],
+ &[active("dup", 1), active("dup", 1)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &HashMap::new(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("duplicate") ||
err.to_string().contains("Duplicate"));
+ }
+
+ #[test]
+ fn test_rejects_ann_source_row_count_mismatch_for_active_file() {
+ let ann = FakeAnnSearcher { result: vec![] };
+ // Segment references data-1 with 2 rows, but the active file has 3
rows.
+ // An active source with a mismatched row count is still a hard error.
+ let segment = BucketAnnSegment {
+ source_meta: meta(&[("data-1", 2)]),
+ };
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let err = bucket_search(
+ Some(&ann),
+ &[segment],
+ &[active("data-1", 3)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &HashMap::new(),
+ )
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("does not match") ||
err.to_string().contains("ANN source")
+ );
+ }
+
+ #[test]
+ fn test_skips_inactive_ann_source_and_searches_active_ones() {
+ // Segment covers [data-1, data-2] but only data-1 is still active
+ // (data-2 was compacted away). Java master skips the inactive source
+ // instead of failing the whole query; data-2 is neither covered (so it
+ // is not treated as ANN-covered) nor an active file (so it is not
exact
+ // scanned). The ANN searcher still runs for the segment.
+ let segment = BucketAnnSegment {
+ source_meta: meta(&[("data-1", 2), ("data-2", 2)]),
+ };
+ let ann = FakeAnnSearcher {
+ result: vec![PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 0,
+ distance: 0.5,
+ }],
+ };
+ let calls = RefCell::new(Vec::<String>::new());
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ calls.borrow_mut().push(f.file_name.clone());
+ unreachable!("only data-1 is active and it is ANN-covered")
+ };
+ let results = bucket_search(
+ Some(&ann),
+ &[segment],
+ &[active("data-1", 2)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &HashMap::new(),
+ )
+ .unwrap();
+ assert_eq!(
+ results,
+ vec![PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 0,
+ distance: 0.5
+ }]
+ );
+ // No exact fallback ran: data-1 is ANN-covered, data-2 is not active.
+ assert!(calls.borrow().is_empty());
+ }
+
+ #[test]
+ fn test_rejects_segments_without_ann_searcher() {
+ let segment = BucketAnnSegment {
+ source_meta: meta(&[("data-1", 2)]),
+ };
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let err = bucket_search(
+ None,
+ &[segment],
+ &[active("data-1", 2)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &HashMap::new(),
+ )
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("ANN search is not configured")
+ || err.to_string().contains("not configured")
+ );
+ }
+
+ #[test]
+ fn test_negative_active_row_count_rejected() {
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let err = bucket_search(
+ None,
+ &[],
+ &[active("data-1", -1)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &HashMap::new(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("row count") ||
err.to_string().contains("-1"));
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/exact.rs
b/crates/paimon/src/vindex/pkvector/exact.rs
new file mode 100644
index 0000000..c9c0480
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/exact.rs
@@ -0,0 +1,289 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::cmp::Ordering;
+use std::collections::BinaryHeap;
+
+use super::data_invalid;
+use super::metric::VectorSearchMetric;
+use super::reader::PkVectorReader;
+use super::result::PkVectorSearchResult;
+
+/// A candidate wrapped so a max-heap keeps the WORST candidate on top:
+/// worst = largest distance, ties broken by largest row_position. Popping the
+/// top therefore evicts the least-wanted candidate. Uses `total_cmp` for a
+/// deterministic total order over f32 (NaN-safe, no panic).
+struct WorstFirst(PkVectorSearchResult);
+
+impl PartialEq for WorstFirst {
+ fn eq(&self, other: &Self) -> bool {
+ self.cmp(other) == Ordering::Equal
+ }
+}
+impl Eq for WorstFirst {}
+impl PartialOrd for WorstFirst {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+impl Ord for WorstFirst {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.0
+ .distance
+ .total_cmp(&other.0.distance)
+ .then_with(|| self.0.row_position.cmp(&other.0.row_position))
+ }
+}
+
+/// True if `candidate` ranks strictly better (BEST_FIRST) than the current
+/// worst-on-heap `weakest`: smaller distance, ties broken by smaller position.
+fn is_better_than(candidate: &PkVectorSearchResult, weakest:
&PkVectorSearchResult) -> bool {
+ candidate
+ .distance
+ .total_cmp(&weakest.distance)
+ .then_with(|| candidate.row_position.cmp(&weakest.row_position))
+ == Ordering::Less
+}
+
+/// Exact Top-K over one sequential physical-row vector source. Mirrors Java
+/// `PkVectorExactSearcher.search`. Results are sorted BEST_FIRST: distance
ASC,
+/// then row_position ASC (single file, so data_file_name is constant).
+pub(crate) fn exact_search(
+ data_file_name: &str,
+ reader: &mut dyn PkVectorReader,
+ query: &[f32],
+ metric: VectorSearchMetric,
+ limit: usize,
+ is_excluded: &dyn Fn(i64) -> bool,
+) -> crate::Result<Vec<PkVectorSearchResult>> {
+ if query.len() != reader.dimension() {
+ return Err(data_invalid(format!(
+ "query vector dimension does not match: index expects {}, got {}",
+ reader.dimension(),
+ query.len()
+ )));
+ }
+ if limit == 0 {
+ return Err(data_invalid("vector search limit must be positive"));
+ }
+ if let Some(i) = query.iter().position(|v| !v.is_finite()) {
+ return Err(data_invalid(format!(
+ "query vector element at position {i} must be finite"
+ )));
+ }
+ let row_count = reader.row_count();
+ if row_count < 0 {
+ return Err(data_invalid(format!(
+ "vector reader row count must not be negative: {row_count}"
+ )));
+ }
+
+ let mut reuse = vec![0.0f32; reader.dimension()];
+ let mut heap: BinaryHeap<WorstFirst> = BinaryHeap::with_capacity(limit +
1);
+ for position in 0..row_count {
+ let has_vector = reader.read_next_vector(&mut reuse)?;
+ if !has_vector || is_excluded(position) {
+ continue;
+ }
+ let candidate = PkVectorSearchResult {
+ data_file_name: data_file_name.to_string(),
+ row_position: position,
+ distance: metric.compute_distance(query, &reuse),
+ };
+ if heap.len() < limit {
+ heap.push(WorstFirst(candidate));
+ } else if heap
+ .peek()
+ .is_some_and(|worst| is_better_than(&candidate, &worst.0))
+ {
+ heap.pop();
+ heap.push(WorstFirst(candidate));
+ }
+ }
+
+ let mut results: Vec<PkVectorSearchResult> = heap.into_iter().map(|w|
w.0).collect();
+ results.sort_by(|a, b| {
+ a.distance
+ .total_cmp(&b.distance)
+ .then_with(|| a.row_position.cmp(&b.row_position))
+ });
+ Ok(results)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::vindex::pkvector::metric::VectorSearchMetric;
+ use crate::vindex::pkvector::reader::test_support::ArrayReader;
+
+ fn no_exclusion() -> impl Fn(i64) -> bool {
+ |_| false
+ }
+
+ #[test]
+ fn test_distances_for_supported_metrics() {
+ // Java testDistancesForSupportedMetrics: q=[2,0] over stored [1,0].
+ for (metric, expected) in [
+ (VectorSearchMetric::L2, 1.0f32),
+ (VectorSearchMetric::Cosine, 0.0),
+ (VectorSearchMetric::InnerProduct, -2.0),
+ ] {
+ let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 0.0])]);
+ let results = exact_search(
+ "data-file",
+ &mut reader,
+ &[2.0, 0.0],
+ metric,
+ 1,
+ &no_exclusion(),
+ )
+ .unwrap();
+ assert_eq!(results[0].distance, expected);
+ }
+ }
+
+ #[test]
+ fn test_rejects_dimension_mismatch() {
+ let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 0.0])]);
+ let err = exact_search(
+ "data-file",
+ &mut reader,
+ &[1.0],
+ VectorSearchMetric::L2,
+ 1,
+ &no_exclusion(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("dimension"));
+ }
+
+ #[test]
+ fn test_rejects_non_positive_limit() {
+ let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 0.0])]);
+ let err = exact_search(
+ "data-file",
+ &mut reader,
+ &[1.0, 0.0],
+ VectorSearchMetric::L2,
+ 0,
+ &no_exclusion(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("positive"));
+ }
+
+ #[test]
+ fn test_rejects_non_finite_query() {
+ let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 0.0])]);
+ let err = exact_search(
+ "data-file",
+ &mut reader,
+ &[f32::NAN, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &no_exclusion(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("finite"));
+ }
+
+ #[test]
+ fn test_rejects_negative_row_count() {
+ struct NegativeReader;
+ impl PkVectorReader for NegativeReader {
+ fn dimension(&self) -> usize {
+ 2
+ }
+ fn row_count(&self) -> i64 {
+ -1
+ }
+ fn read_next_vector(&mut self, _reuse: &mut [f32]) ->
crate::Result<bool> {
+ unreachable!()
+ }
+ }
+ let mut reader = NegativeReader;
+ let err = exact_search(
+ "data-file",
+ &mut reader,
+ &[1.0, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &no_exclusion(),
+ )
+ .unwrap_err();
+ assert!(err.to_string().contains("row count") ||
err.to_string().contains("-1"));
+ }
+
+ #[test]
+ fn test_preserves_null_and_excluded_physical_positions() {
+ // Java testPreservesNullAndDeletedPhysicalPositions:
+ // vectors [{3,0}, null, {1,0}, {2,0}], q=[0,0], l2, limit 2, exclude
pos==2.
+ let mut reader = ArrayReader::new(
+ 2,
+ vec![
+ Some(vec![3.0, 0.0]),
+ None,
+ Some(vec![1.0, 0.0]),
+ Some(vec![2.0, 0.0]),
+ ],
+ );
+ let results = exact_search(
+ "data-file",
+ &mut reader,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &|pos| pos == 2,
+ )
+ .unwrap();
+ assert_eq!(
+ results,
+ vec![
+ PkVectorSearchResult {
+ data_file_name: "data-file".into(),
+ row_position: 3,
+ distance: 4.0
+ },
+ PkVectorSearchResult {
+ data_file_name: "data-file".into(),
+ row_position: 0,
+ distance: 9.0
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_tie_break_prefers_smaller_row_position() {
+ // Equal distances -> smaller row_position ranks first.
+ let mut reader =
+ ArrayReader::new(1, vec![Some(vec![1.0]), Some(vec![1.0]),
Some(vec![1.0])]);
+ let results = exact_search(
+ "data-file",
+ &mut reader,
+ &[0.0],
+ VectorSearchMetric::L2,
+ 2,
+ &no_exclusion(),
+ )
+ .unwrap();
+ assert_eq!(
+ results.iter().map(|r| r.row_position).collect::<Vec<_>>(),
+ vec![0, 1]
+ );
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/metric.rs
b/crates/paimon/src/vindex/pkvector/metric.rs
new file mode 100644
index 0000000..1dda56c
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/metric.rs
@@ -0,0 +1,211 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::data_invalid;
+
+/// Normalize a metric name: lowercase and `-` → `_`. NO trim (deliberately
+/// stricter than the build-side `vindex::normalize_metric`, to match Java
+/// `VectorSearchMetric.normalize`).
+pub(crate) fn normalize_metric(metric: &str) -> String {
+ metric.to_ascii_lowercase().replace('-', "_")
+}
+
+/// True if the (normalized) metric is one of the three supported metrics.
+pub(crate) fn is_supported_metric(metric: &str) -> bool {
+ matches!(
+ normalize_metric(metric).as_str(),
+ "l2" | "cosine" | "inner_product"
+ )
+}
+
+/// Numeric semantics for a supported vector search metric. Mirrors Java
+/// `org.apache.paimon.globalindex.VectorSearchMetric`.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum VectorSearchMetric {
+ L2,
+ Cosine,
+ InnerProduct,
+}
+
+impl VectorSearchMetric {
+ /// Normalize, validate, and map to the enum. Errors on an unsupported
metric.
+ pub(crate) fn parse(metric: &str) -> crate::Result<Self> {
+ match normalize_metric(metric).as_str() {
+ "l2" => Ok(Self::L2),
+ "cosine" => Ok(Self::Cosine),
+ "inner_product" => Ok(Self::InnerProduct),
+ other => Err(data_invalid(format!(
+ "unsupported vector distance metric: {other}"
+ ))),
+ }
+ }
+
+ /// Higher-is-better score for exact vector search.
+ pub(crate) fn compute_score(&self, query: &[f32], stored: &[f32]) -> f32 {
+ match self {
+ Self::L2 => 1.0 / (1.0 + squared_l2(query, stored)),
+ Self::Cosine => cosine_similarity(query, stored),
+ Self::InnerProduct => inner_product(query, stored),
+ }
+ }
+
+ /// Lower-is-better distance for exact vector search.
+ pub(crate) fn compute_distance(&self, query: &[f32], stored: &[f32]) ->
f32 {
+ match self {
+ Self::L2 => squared_l2(query, stored),
+ Self::Cosine => cosine_distance(cosine_similarity(query, stored)),
+ Self::InnerProduct => -inner_product(query, stored),
+ }
+ }
+
+ /// Convert a higher-is-better standardized index score to a
lower-is-better
+ /// distance. For L2 with `score == 0.0` this yields `inf` (natural f32
+ /// behavior, matching Java — no clamp).
+ pub(crate) fn score_to_distance(&self, score: f32) -> f32 {
+ match self {
+ Self::L2 => 1.0 / score - 1.0,
+ Self::Cosine => cosine_distance(score),
+ Self::InnerProduct => -score,
+ }
+ }
+}
+
+fn squared_l2(query: &[f32], stored: &[f32]) -> f32 {
+ let mut squared = 0.0f32;
+ for i in 0..query.len() {
+ let delta = query[i] - stored[i];
+ squared += delta * delta;
+ }
+ squared
+}
+
+fn cosine_similarity(query: &[f32], stored: &[f32]) -> f32 {
+ let mut dot = 0.0f32;
+ let mut query_norm = 0.0f32;
+ let mut stored_norm = 0.0f32;
+ for i in 0..query.len() {
+ dot += query[i] * stored[i];
+ query_norm += query[i] * query[i];
+ stored_norm += stored[i] * stored[i];
+ }
+ let denominator = ((query_norm as f64).sqrt() * (stored_norm as
f64).sqrt()) as f32;
+ if denominator == 0.0 {
+ 0.0
+ } else {
+ dot / denominator
+ }
+}
+
+fn inner_product(query: &[f32], stored: &[f32]) -> f32 {
+ let mut dot = 0.0f32;
+ for i in 0..query.len() {
+ dot += query[i] * stored[i];
+ }
+ dot
+}
+
+fn cosine_distance(similarity: f32) -> f32 {
+ 1.0 - similarity.clamp(-1.0, 1.0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_normalize_lowercases_and_replaces_hyphens_without_trimming() {
+ assert_eq!(normalize_metric("Inner-Product"), "inner_product");
+ assert_eq!(normalize_metric("L2"), "l2");
+ // No trim: surrounding whitespace is preserved (unlike the build-side
helper).
+ assert_eq!(normalize_metric(" l2 "), " l2 ");
+ }
+
+ #[test]
+ fn test_is_supported_only_for_three_metrics() {
+ assert!(is_supported_metric("L2"));
+ assert!(is_supported_metric("cosine"));
+ assert!(is_supported_metric("inner-product"));
+ assert!(!is_supported_metric("manhattan"));
+ assert!(!is_supported_metric(" l2 "));
+ }
+
+ #[test]
+ fn test_parse_rejects_unsupported_metric() {
+ assert!(VectorSearchMetric::parse("l2").is_ok());
+ assert!(VectorSearchMetric::parse("cosine").is_ok());
+ assert!(VectorSearchMetric::parse("inner_product").is_ok());
+ assert!(VectorSearchMetric::parse("manhattan").is_err());
+ }
+
+ #[test]
+ fn test_compute_distance_matches_java_anchor() {
+ // Java PkVectorExactSearcherTest.testDistancesForSupportedMetrics:
+ // q=[2,0], s=[1,0] -> l2=1.0, cosine=0.0, inner_product=-2.0
+ let q = [2.0f32, 0.0];
+ let s = [1.0f32, 0.0];
+ assert_eq!(VectorSearchMetric::L2.compute_distance(&q, &s), 1.0);
+ assert_eq!(VectorSearchMetric::Cosine.compute_distance(&q, &s), 0.0);
+ assert_eq!(
+ VectorSearchMetric::InnerProduct.compute_distance(&q, &s),
+ -2.0
+ );
+ }
+
+ #[test]
+ fn test_compute_score_higher_is_better() {
+ let q = [2.0f32, 0.0];
+ let s = [1.0f32, 0.0];
+ assert_eq!(VectorSearchMetric::L2.compute_score(&q, &s), 0.5); //
1/(1+1)
+ assert_eq!(VectorSearchMetric::Cosine.compute_score(&q, &s), 1.0); //
parallel
+ assert_eq!(VectorSearchMetric::InnerProduct.compute_score(&q, &s),
2.0);
+ }
+
+ #[test]
+ fn test_cosine_zero_norm_similarity_is_zero() {
+ let zero = [0.0f32, 0.0];
+ let s = [1.0f32, 0.0];
+ assert_eq!(VectorSearchMetric::Cosine.compute_score(&zero, &s), 0.0);
+ assert_eq!(VectorSearchMetric::Cosine.compute_distance(&zero, &s),
1.0);
+ }
+
+ #[test]
+ fn test_cosine_non_perfect_square_norm_uses_f64_sqrt() {
+ // query [0,3] -> norm 9, stored [1,2] -> norm 5; sqrt(5) is irrational
+ // so the f32-sqrt path (each sqrt taken in f32, then widened) and the
+ // f64-sqrt path (widen first, sqrt in f64) produce different f32 bits:
+ // buggy score 0.8944271 vs correct 0.8944272. Encode the f64 contract
in
+ // the expected value (dot / (sqrt(9.0f64) * sqrt(5.0f64)) as f32)
rather
+ // than a magic literal, so this pins the Java-matching f64 arithmetic.
+ let q = [0.0f32, 3.0];
+ let s = [1.0f32, 2.0];
+ let dot = 6.0f32;
+ let denominator = ((9.0f64).sqrt() * (5.0f64).sqrt()) as f32;
+ let expected = dot / denominator;
+ assert_eq!(VectorSearchMetric::Cosine.compute_score(&q, &s), expected);
+ }
+
+ #[test]
+ fn test_score_to_distance_l2_zero_score_is_infinite() {
+ assert!(VectorSearchMetric::L2.score_to_distance(0.0).is_infinite());
+ assert_eq!(VectorSearchMetric::L2.score_to_distance(0.5), 1.0); //
1/0.5 - 1
+ assert_eq!(
+ VectorSearchMetric::InnerProduct.score_to_distance(2.0),
+ -2.0
+ );
+ assert_eq!(VectorSearchMetric::Cosine.score_to_distance(1.0), 0.0);
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/mod.rs
b/crates/paimon/src/vindex/pkvector/mod.rs
new file mode 100644
index 0000000..3a685ca
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/mod.rs
@@ -0,0 +1,42 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Primary-key vector (bucket-local ANN) search kernel.
+//!
+//! Read-only bucket-local approximate-nearest-neighbour search over the
+//! primary-key vector index.
+
+// The kernel is crate-private and has no production caller yet, so its items
+// are unreachable outside their own tests. Suppress the resulting dead_code
+// lint at the module boundary until the read path wires it in.
+#![allow(dead_code)]
+
+pub(crate) mod ann;
+pub(crate) mod bucket;
+pub(crate) mod exact;
+pub(crate) mod metric;
+pub(crate) mod reader;
+pub(crate) mod result;
+
+/// Shared constructor for validation failures in this module (mirrors Java
+/// `checkArgument` / `IllegalArgumentException`).
+pub(crate) fn data_invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/reader.rs
b/crates/paimon/src/vindex/pkvector/reader.rs
new file mode 100644
index 0000000..3298013
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/reader.rs
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// Sequential exact-scan source of vectors for one data file. Mirrors Java
+/// `org.apache.paimon.index.pkvector.PkVectorReader`.
+pub(crate) trait PkVectorReader {
+ fn dimension(&self) -> usize;
+
+ fn row_count(&self) -> i64;
+
+ /// Read the next row's vector into `reuse` (`reuse.len() == dimension()`).
+ /// Returns `false` when the physical row is a NULL vector: it is not
scored,
+ /// but the physical position still advances by one. Each call advances
+ /// exactly one physical row.
+ fn read_next_vector(&mut self, reuse: &mut [f32]) -> crate::Result<bool>;
+}
+
+#[cfg(test)]
+pub(crate) mod test_support {
+ use super::PkVectorReader;
+
+ /// In-memory `PkVectorReader` for tests. `None` entries are NULL rows.
+ /// Mirrors Java's test `ArrayReader`.
+ pub(crate) struct ArrayReader {
+ dimension: usize,
+ vectors: Vec<Option<Vec<f32>>>,
+ position: usize,
+ }
+
+ impl ArrayReader {
+ pub(crate) fn new(dimension: usize, vectors: Vec<Option<Vec<f32>>>) ->
Self {
+ Self {
+ dimension,
+ vectors,
+ position: 0,
+ }
+ }
+ }
+
+ impl PkVectorReader for ArrayReader {
+ fn dimension(&self) -> usize {
+ self.dimension
+ }
+
+ fn row_count(&self) -> i64 {
+ self.vectors.len() as i64
+ }
+
+ fn read_next_vector(&mut self, reuse: &mut [f32]) ->
crate::Result<bool> {
+ assert_eq!(
+ reuse.len(),
+ self.dimension,
+ "reuse buffer must equal dimension"
+ );
+ let entry = &self.vectors[self.position];
+ self.position += 1;
+ match entry {
+ Some(vector) => {
+ reuse.copy_from_slice(vector);
+ Ok(true)
+ }
+ None => Ok(false),
+ }
+ }
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/result.rs
b/crates/paimon/src/vindex/pkvector/result.rs
new file mode 100644
index 0000000..97bea5e
--- /dev/null
+++ b/crates/paimon/src/vindex/pkvector/result.rs
@@ -0,0 +1,27 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// One vector search result addressed by source data-file row position.
+/// Mirrors Java `org.apache.paimon.index.pkvector.PkVectorSearchResult`.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct PkVectorSearchResult {
+ pub data_file_name: String,
+ /// Zero-based physical row position within the source data file.
+ pub row_position: i64,
+ /// Lower-is-better distance.
+ pub distance: f32,
+}