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 f55ad02 [core] Add primary-key vector read kernel (#517)
f55ad02 is described below
commit f55ad02fccab274e279c67925509311d3ddc3bf0
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Jul 16 15:26:47 2026 +0800
[core] Add primary-key vector read kernel (#517)
---
crates/paimon/src/table/data_file_reader.rs | 161 ++-
crates/paimon/src/table/mod.rs | 3 +
.../src/table/pk_vector_indexed_split_read.rs | 778 +++++++++++++
crates/paimon/src/table/pk_vector_orchestrator.rs | 1181 ++++++++++++++++++++
crates/paimon/src/table/pk_vector_position_read.rs | 1079 ++++++++++++++++++
crates/paimon/src/vindex/pkvector/ann.rs | 5 +-
crates/paimon/src/vindex/pkvector/bucket.rs | 52 +-
crates/paimon/src/vindex/pkvector/exact.rs | 41 +-
crates/paimon/src/vindex/pkvector/metric.rs | 106 ++
9 files changed, 3362 insertions(+), 44 deletions(-)
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 05df8b3..3395f8d 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -71,6 +71,35 @@ impl DataFileReader {
self
}
+ // These three accessors exist for the sibling `pk_vector_position_read`,
+ // `pk_vector_indexed_split_read`, and `pk_vector_orchestrator` modules.
The
+ // read path that drives that chain lands in a later change, so under
clippy
+ // -D warnings they read as dead_code until then.
+ /// Return a copy with a replaced read-type. Used by
`pk_vector_position_read`
+ /// to inject the internal `_ROW_ID` column for physical-position recovery.
+ #[allow(dead_code)]
+ pub(super) fn with_read_type(mut self, read_type: Vec<DataField>) -> Self {
+ self.read_type = read_type;
+ self
+ }
+
+ /// The effective read-type (requested output fields) of this reader.
+ /// Exposed for the sibling `pk_vector_position_read` module.
+ #[allow(dead_code)]
+ pub(super) fn read_type(&self) -> &[DataField] {
+ &self.read_type
+ }
+
+ /// True if any configured predicate can actually drop rows. A lone
+ /// `Predicate::AlwaysTrue` keeps every row in order and is not
row-filtering,
+ /// matching `reject_row_id_with_predicate`'s notion.
+ #[allow(dead_code)]
+ pub(super) fn has_row_filtering_predicate(&self) -> bool {
+ self.predicates
+ .iter()
+ .any(|p| !matches!(p, Predicate::AlwaysTrue))
+ }
+
/// Reject projecting `_ROW_ID` alongside a data predicate. `_ROW_ID` is
/// assigned positionally from post-filter batch row counts, so a residual
/// filter that drops rows would desync it. (`_ROW_ID` predicates travel
via
@@ -111,35 +140,16 @@ impl DataFileReader {
Ok(try_stream! {
for split in splits {
// Create DV factory for this split only.
- let dv_factory = if split
- .data_deletion_files()
- .is_some_and(|files| files.iter().any(Option::is_some))
- {
- Some(
- DeletionVectorFactory::new(
- &reader.file_io,
- split.data_files(),
- split.data_deletion_files(),
- )
- .await?,
- )
- } else {
- None
- };
+ let dv_factory = reader.build_split_dv_factory(&split).await?;
for file_meta in split.data_files().to_vec() {
- let dv = dv_factory
- .as_ref()
- .and_then(|factory|
factory.get_deletion_vector(&file_meta.file_name))
- .cloned();
+ let dv = DataFileReader::deletion_vector_for_file(
+ dv_factory.as_ref(),
+ &file_meta.file_name,
+ );
// Load data file's schema if it differs from the table
schema.
- let data_fields: Option<Vec<DataField>> = if
file_meta.schema_id != reader.table_schema_id {
- let data_schema =
reader.schema_manager.schema(file_meta.schema_id).await?;
- Some(data_schema.fields().to_vec())
- } else {
- None
- };
+ let data_fields =
reader.derive_data_fields(&file_meta).await?;
let mut stream = reader.read_single_file_stream(
&split,
@@ -157,6 +167,55 @@ impl DataFileReader {
.boxed())
}
+ /// Build the deletion-vector factory for a split, or `None` when the split
+ /// carries no deletion files. One factory per split (not per file),
matching
+ /// `read`. Shared with `pk_vector_indexed_split_read` so that path reuses
the
+ /// exact production derivation instead of duplicating it.
+ pub(super) async fn build_split_dv_factory(
+ &self,
+ split: &DataSplit,
+ ) -> crate::Result<Option<DeletionVectorFactory>> {
+ if split
+ .data_deletion_files()
+ .is_some_and(|files| files.iter().any(Option::is_some))
+ {
+ Ok(Some(
+ DeletionVectorFactory::new(
+ &self.file_io,
+ split.data_files(),
+ split.data_deletion_files(),
+ )
+ .await?,
+ ))
+ } else {
+ Ok(None)
+ }
+ }
+
+ /// Look up the deletion vector for one file from a split-level factory.
+ pub(super) fn deletion_vector_for_file(
+ factory: Option<&DeletionVectorFactory>,
+ file_name: &str,
+ ) -> Option<Arc<DeletionVector>> {
+ factory
+ .and_then(|factory| factory.get_deletion_vector(file_name))
+ .cloned()
+ }
+
+ /// Load the data file's own schema fields when its `schema_id` differs
from
+ /// the table schema id (schema evolution); `None` when they match.
+ pub(super) async fn derive_data_fields(
+ &self,
+ file_meta: &DataFileMeta,
+ ) -> crate::Result<Option<Vec<DataField>>> {
+ if file_meta.schema_id != self.table_schema_id {
+ let data_schema =
self.schema_manager.schema(file_meta.schema_id).await?;
+ Ok(Some(data_schema.fields().to_vec()))
+ } else {
+ Ok(None)
+ }
+ }
+
/// Read a single parquet file from a split, returning a lazy stream of
batches.
/// Optionally applies a deletion vector.
///
@@ -994,6 +1053,58 @@ mod tests {
use roaring::RoaringBitmap;
use std::io;
+ #[test]
+ fn test_accessors_expose_read_type_and_row_filtering_predicate() {
+ use crate::spec::{DataField, DataType, IntType};
+ let fields = vec![DataField::new(
+ 0,
+ "id".to_string(),
+ DataType::Int(IntType::new()),
+ )];
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
"memory:/acc".to_string());
+
+ let no_pred = DataFileReader::new(
+ file_io.clone(),
+ schema_manager.clone(),
+ 1,
+ fields.clone(),
+ fields.clone(),
+ vec![],
+ );
+ assert_eq!(no_pred.read_type().len(), 1);
+ assert!(!no_pred.has_row_filtering_predicate());
+
+ let always_true = DataFileReader::new(
+ file_io.clone(),
+ schema_manager.clone(),
+ 1,
+ fields.clone(),
+ fields.clone(),
+ vec![crate::spec::Predicate::AlwaysTrue],
+ );
+ assert!(
+ !always_true.has_row_filtering_predicate(),
+ "AlwaysTrue is not row-filtering"
+ );
+
+ let filtering = PredicateBuilder::new(&fields)
+ .equal("id", crate::spec::Datum::Int(10))
+ .unwrap();
+ let with_filter = DataFileReader::new(
+ file_io.clone(),
+ schema_manager.clone(),
+ 1,
+ fields.clone(),
+ fields.clone(),
+ vec![filtering],
+ );
+ assert!(
+ with_filter.has_row_filtering_predicate(),
+ "a real (non-AlwaysTrue) predicate is row-filtering"
+ );
+ }
+
struct MemOutputFile {
data: Vec<u8>,
}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 617c1fb..2a239e9 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -55,6 +55,9 @@ mod lumina_index_build_builder;
pub(crate) mod merge_tree_split_generator;
mod partition_filter;
mod partition_stat;
+mod pk_vector_indexed_split_read;
+mod pk_vector_orchestrator;
+mod pk_vector_position_read;
mod postpone_file_writer;
mod prepared_files;
mod read_builder;
diff --git a/crates/paimon/src/table/pk_vector_indexed_split_read.rs
b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
new file mode 100644
index 0000000..00bd1ae
--- /dev/null
+++ b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
@@ -0,0 +1,778 @@
+// 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 indexed-split read-path contract (read-path subset of
+//! apache/paimon#8576).
+//!
+//! `PkVectorIndexedSplit` carries one data file + inclusive physical-position
+//! ranges + an optional aligned score array. `PkVectorIndexedSplitRead`
validates
+//! the split, expands the ranges into an ascending position set and a
+//! `position -> score` map, and delegates to the sibling
`PkVectorPositionRead`.
+//! It is a pure consumer: no bucket/ANN search, no cross-bucket
orchestration, no
+//! serialization.
+
+// This module wires the position reader into the indexed read contract; the
+// sibling `pk_vector_orchestrator` adds cross-bucket orchestration on top. The
+// read path that drives that chain lands in a later change, so under clippy
+// -D warnings these items read as dead_code until then. Suppress at the module
+// boundary.
+#![allow(dead_code)]
+
+use std::collections::BTreeMap;
+
+use futures::StreamExt;
+
+use crate::spec::DataFileMeta;
+use crate::table::data_file_reader::DataFileReader;
+use crate::table::pk_vector_position_read::PkVectorPositionRead;
+use crate::table::source::DataSplit;
+use crate::table::{ArrowRecordBatchStream, RowRange};
+
+fn data_invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+/// A single-file indexed split for the PK-vector read path.
+///
+/// `row_ranges` are 0-based PHYSICAL positions within the one data file,
inclusive
+/// on both ends, required strictly ascending and non-overlapping. Adjacent
+/// (touching) ranges are allowed and need not be coalesced by the producer.
+/// `scores`, when present, is aligned to the expanded-range order (ascending
+/// position); `None` means no `_PKEY_VECTOR_SCORE` column in the output.
+///
+/// Deliberately NOT reusing `DataSplit.row_ranges`, whose ranges mean
stable/global
+/// row ids on the append/data-evolution path. Not serialized.
+pub(crate) struct PkVectorIndexedSplit {
+ pub split: DataSplit,
+ pub row_ranges: Vec<RowRange>,
+ pub scores: Option<Vec<f32>>,
+}
+
+/// Expand inclusive physical-position ranges into an ascending `Vec<i64>` of
+/// 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>> {
+ if ranges.is_empty() {
+ return Err(data_invalid("indexed split must select at least one row"));
+ }
+ let mut positions = Vec::new();
+ let mut prev_to: Option<i64> = None;
+ for range in ranges {
+ if range.from() < 0 || range.to() >= row_count {
+ return Err(data_invalid(format!(
+ "indexed-split range [{}, {}] is outside [0, {})",
+ range.from(),
+ range.to(),
+ row_count
+ )));
+ }
+ if let Some(prev) = prev_to {
+ // Rejects overlap AND descending order; touching ranges
+ // (range.from() == prev + 1) are accepted.
+ if range.from() <= prev {
+ return Err(data_invalid(
+ "indexed-split ranges must be ascending and
non-overlapping",
+ ));
+ }
+ }
+ for position in range.from()..=range.to() {
+ positions.push(position);
+ }
+ prev_to = Some(range.to());
+ }
+ Ok(positions)
+}
+
+/// Eagerly validate the split and expand it into `(file_meta, positions,
scores)`.
+/// Single-file check, range bounds/ordering, and score-length alignment happen
+/// here so their errors surface at the `read(...)` call site.
+#[allow(clippy::type_complexity)]
+fn validate_and_expand(
+ split: &PkVectorIndexedSplit,
+) -> crate::Result<(DataFileMeta, Vec<i64>, Option<BTreeMap<i64, f32>>)> {
+ let files = split.split.data_files();
+ if files.len() != 1 {
+ return Err(data_invalid(
+ "indexed split for a primary-key table must contain exactly one
data file",
+ ));
+ }
+ let file_meta = files[0].clone();
+ let positions = expand_ranges(&split.row_ranges, file_meta.row_count)?;
+
+ let score_map = match &split.scores {
+ Some(scores) => {
+ if scores.len() != positions.len() {
+ return Err(data_invalid(format!(
+ "indexed-split scores length {} does not match selected
positions {}",
+ scores.len(),
+ positions.len()
+ )));
+ }
+ // positions is ascending == expanded-range order; zip aligns each
+ // score to its position.
+ Some(
+ positions
+ .iter()
+ .copied()
+ .zip(scores.iter().copied())
+ .collect(),
+ )
+ }
+ None => None,
+ };
+
+ Ok((file_meta, positions, score_map))
+}
+
+/// Validates a `PkVectorIndexedSplit` and delegates its materialization to the
+/// sibling `PkVectorPositionRead`. Rust equivalent of Java
`PrimaryKeyIndexedSplitRead`.
+pub(crate) struct PkVectorIndexedSplitRead {
+ reader: DataFileReader,
+}
+
+impl PkVectorIndexedSplitRead {
+ pub(crate) fn new(reader: DataFileReader) -> Self {
+ Self { reader }
+ }
+
+ /// Validate the split eagerly, then return a lazy stream that derives
+ /// `data_fields`/deletion-vector and delegates to `PkVectorPositionRead`.
+ /// Eager errors (single-file, range bounds/ordering, score length) surface
+ /// here; lazy errors (schema/DV load, the position reader's own guards)
surface
+ /// on first poll.
+ pub(crate) fn read(
+ &self,
+ split: &PkVectorIndexedSplit,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ let (file_meta, positions, score_map) = validate_and_expand(split)?;
+ let reader = self.reader.clone();
+ let data_split = split.split.clone();
+
+ let stream = async_stream::try_stream! {
+ let dv_factory = reader.build_split_dv_factory(&data_split).await?;
+ let dv = DataFileReader::deletion_vector_for_file(
+ dv_factory.as_ref(),
+ &file_meta.file_name,
+ );
+ let data_fields = reader.derive_data_fields(&file_meta).await?;
+
+ let inner = PkVectorPositionRead::new(&reader).read(
+ &data_split,
+ file_meta,
+ data_fields,
+ dv,
+ positions,
+ score_map,
+ )?;
+ futures::pin_mut!(inner);
+ while let Some(batch) = inner.next().await {
+ yield batch?;
+ }
+ };
+ Ok(Box::pin(stream))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::DataFileMeta;
+ use crate::table::source::DataSplitBuilder;
+
+ fn data_file(row_count: i64) -> DataFileMeta {
+ DataFileMeta {
+ file_name: "part-0.mosaic".to_string(),
+ file_size: 1,
+ row_count,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 1,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: Some(0),
+ write_cols: None,
+ }
+ }
+
+ fn split_with_files(files: Vec<DataFileMeta>) -> DataSplit {
+ DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/pkvisr/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .build()
+ .unwrap()
+ }
+
+ fn indexed_split(
+ row_count: i64,
+ row_ranges: Vec<RowRange>,
+ scores: Option<Vec<f32>>,
+ ) -> PkVectorIndexedSplit {
+ PkVectorIndexedSplit {
+ split: split_with_files(vec![data_file(row_count)]),
+ row_ranges,
+ scores,
+ }
+ }
+
+ #[test]
+ fn expand_single_contiguous_range() {
+ let positions = expand_ranges(&[RowRange::new(0, 3)], 10).unwrap();
+ assert_eq!(positions, vec![0, 1, 2, 3]);
+ }
+
+ #[test]
+ fn expand_multiple_ascending_ranges() {
+ // Non-overlapping with a gap, plus an adjacent (touching) pair.
+ let positions = expand_ranges(
+ &[
+ RowRange::new(0, 1),
+ RowRange::new(2, 2),
+ RowRange::new(5, 6),
+ ],
+ 10,
+ )
+ .unwrap();
+ assert_eq!(positions, vec![0, 1, 2, 5, 6]);
+ }
+
+ #[test]
+ fn expand_single_row_range() {
+ let positions = expand_ranges(&[RowRange::new(4, 4)], 5).unwrap();
+ assert_eq!(positions, vec![4]);
+ }
+
+ #[test]
+ fn expand_rejects_empty_ranges() {
+ let err = expand_ranges(&[], 5).expect_err("empty ranges must error");
+ assert!(
+ format!("{err:?}").contains("at least one row"),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn expand_rejects_range_at_or_past_row_count() {
+ let err = expand_ranges(&[RowRange::new(3, 5)], 5).expect_err("to >=
row_count must error");
+ assert!(
+ format!("{err:?}").contains("outside [0, 5)"),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn expand_rejects_negative_from() {
+ let err = expand_ranges(&[RowRange::new(-1, 2)],
5).expect_err("negative from must error");
+ assert!(format!("{err:?}").contains("outside"), "got: {err:?}");
+ }
+
+ #[test]
+ fn expand_rejects_overlapping_ranges() {
+ // range[1].from()==2 <= range[0].to()==3 -> overlap.
+ let err = expand_ranges(&[RowRange::new(0, 3), RowRange::new(2, 4)],
10)
+ .expect_err("overlapping ranges must error");
+ assert!(
+ format!("{err:?}").contains("ascending and non-overlapping"),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn expand_rejects_descending_ranges() {
+ let err = expand_ranges(&[RowRange::new(5, 6), RowRange::new(0, 1)],
10)
+ .expect_err("descending ranges must error");
+ assert!(
+ format!("{err:?}").contains("ascending and non-overlapping"),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn validate_rejects_multi_file_split() {
+ let split = PkVectorIndexedSplit {
+ split: split_with_files(vec![data_file(5), data_file(5)]),
+ row_ranges: vec![RowRange::new(0, 0)],
+ scores: None,
+ };
+ let err = validate_and_expand(&split).expect_err("multi-file split
must error");
+ assert!(
+ format!("{err:?}").contains("exactly one data file"),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn validate_builds_score_map_for_non_contiguous_ranges() {
+ let split = indexed_split(
+ 10,
+ vec![
+ RowRange::new(0, 0),
+ RowRange::new(2, 2),
+ RowRange::new(5, 5),
+ ],
+ Some(vec![0.9, 0.5, 0.1]),
+ );
+ let (_file, positions, score_map) =
validate_and_expand(&split).unwrap();
+ assert_eq!(positions, vec![0, 2, 5]);
+ let score_map = score_map.unwrap();
+ assert_eq!(score_map, BTreeMap::from([(0, 0.9f32), (2, 0.5), (5,
0.1)]));
+ }
+
+ #[test]
+ fn validate_no_scores_yields_none() {
+ let split = indexed_split(5, vec![RowRange::new(0, 1)], None);
+ let (_file, positions, score_map) =
validate_and_expand(&split).unwrap();
+ assert_eq!(positions, vec![0, 1]);
+ assert!(score_map.is_none());
+ }
+
+ #[test]
+ fn validate_rejects_score_length_mismatch() {
+ // select 2 positions but supply 1 score.
+ let split = indexed_split(5, vec![RowRange::new(0, 1)],
Some(vec![0.5]));
+ let err = validate_and_expand(&split).expect_err("score length
mismatch must error");
+ assert!(format!("{err:?}").contains("scores length"), "got: {err:?}");
+ }
+}
+
+#[cfg(test)]
+mod e2e_tests {
+ use super::*;
+ use crate::arrow::build_target_arrow_schema;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{DataField, DataFileMeta, DataType, IntType};
+ use crate::table::data_file_reader::DataFileReader;
+ use crate::table::pk_vector_position_read::{
+ PKEY_VECTOR_POSITION_COLUMN, PKEY_VECTOR_SCORE_COLUMN,
+ };
+ use crate::table::schema_manager::SchemaManager;
+ use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
+ use arrow_array::{Array, Float32Array, Int32Array, Int64Array,
RecordBatch};
+ use bytes::Bytes;
+ use futures::TryStreamExt;
+ use paimon_mosaic_core::spec::COMPRESSION_NONE;
+ use paimon_mosaic_core::writer::{MosaicWriter, OutputFile, WriterOptions};
+ use roaring::RoaringBitmap;
+ use std::io;
+ use std::sync::Arc;
+
+ struct MemOutputFile {
+ data: Vec<u8>,
+ }
+ impl MemOutputFile {
+ fn new() -> Self {
+ Self { data: Vec::new() }
+ }
+ }
+ impl OutputFile for MemOutputFile {
+ fn write(&mut self, data: &[u8]) -> io::Result<()> {
+ self.data.extend_from_slice(data);
+ Ok(())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ fn pos(&self) -> u64 {
+ self.data.len() as u64
+ }
+ }
+
+ fn id_field() -> DataField {
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new()))
+ }
+ fn id_fields() -> Vec<DataField> {
+ vec![id_field()]
+ }
+ fn id_batch(ids: Vec<i32>) -> RecordBatch {
+ let schema = build_target_arrow_schema(&id_fields()).unwrap();
+ RecordBatch::try_new(schema,
vec![Arc::new(Int32Array::from(ids))]).unwrap()
+ }
+
+ fn data_file(file_name: &str, file_size: i64, row_count: i64) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: file_name.to_string(),
+ file_size,
+ row_count,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 1,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: Some(0),
+ write_cols: None,
+ }
+ }
+
+ fn write_mosaic_single_group(batch: &RecordBatch) -> Bytes {
+ let out = MemOutputFile::new();
+ let mut writer = MosaicWriter::new(
+ out,
+ batch.schema().as_ref(),
+ WriterOptions {
+ compression: COMPRESSION_NONE,
+ num_buckets: 2,
+ row_group_max_size: u64::MAX,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ writer.write_batch(batch).unwrap();
+ writer.close().unwrap();
+ Bytes::from(writer.output().data.to_vec())
+ }
+
+ fn write_mosaic_multi_group(batches: &[RecordBatch]) -> Bytes {
+ let out = MemOutputFile::new();
+ let mut writer = MosaicWriter::new(
+ out,
+ batches[0].schema().as_ref(),
+ WriterOptions {
+ compression: COMPRESSION_NONE,
+ num_buckets: 2,
+ row_group_max_size: 1,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ for batch in batches {
+ writer.write_batch(batch).unwrap();
+ }
+ writer.close().unwrap();
+ Bytes::from(writer.output().data.to_vec())
+ }
+
+ async fn write_deletion_file(
+ file_io: &crate::io::FileIO,
+ path: &str,
+ deleted_rows: &[u32],
+ ) -> DeletionFile {
+ 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::from(blob))
+ .await
+ .unwrap();
+ DeletionFile::new(
+ path.to_string(),
+ 0,
+ bitmap_length as i64,
+ Some(deleted_rows.len() as i64),
+ )
+ }
+
+ /// Build a predicate-free `DataFileReader` over an in-memory mosaic file
plus
+ /// the matching split. `deleted_rows`, when non-empty, writes a DV into
the split.
+ async fn build_reader_and_split(
+ table_path: &str,
+ data: &Bytes,
+ row_count: i64,
+ deleted_rows: &[u32],
+ ) -> (DataFileReader, DataSplit) {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.mosaic";
+ file_io
+ .new_output(&format!("{bucket_path}/{file_name}"))
+ .unwrap()
+ .write(data.clone())
+ .await
+ .unwrap();
+
+ let mut split_builder = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path)
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file(file_name, data.len() as i64,
row_count)]);
+ if !deleted_rows.is_empty() {
+ let df =
+ write_deletion_file(&file_io,
&format!("{table_path}/index/dv-0"), deleted_rows)
+ .await;
+ split_builder =
split_builder.with_data_deletion_files(vec![Some(df)]);
+ }
+ let split = split_builder.build().unwrap();
+
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 1,
+ id_fields(),
+ id_fields(),
+ Vec::new(),
+ );
+ (reader, split)
+ }
+
+ fn column_by_name<'a>(batch: &'a RecordBatch, name: &str) -> Option<&'a
Arc<dyn Array>> {
+ batch
+ .schema()
+ .index_of(name)
+ .ok()
+ .map(|idx| batch.column(idx))
+ }
+ fn collect_i32(batches: &[RecordBatch], name: &str) -> Vec<i32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+ fn collect_i64(batches: &[RecordBatch], name: &str) -> Vec<i64> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+ fn collect_f32(batches: &[RecordBatch], name: &str) -> Vec<f32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+
+ #[tokio::test]
+ async fn reads_ranges_with_position_column_no_scores() {
+ // rows id=[10,11,12], ranges [0..=0, 2..=2] -> ids [10,12], positions
[0,2];
+ // no _ROW_ID leak, no _PKEY_VECTOR_SCORE.
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
+ let (reader, split) = build_reader_and_split("memory:/pkvisr_basic",
&data, 3, &[]).await;
+ let indexed = PkVectorIndexedSplit {
+ split,
+ row_ranges: vec![RowRange::new(0, 0), RowRange::new(2, 2)],
+ scores: None,
+ };
+
+ let batches = PkVectorIndexedSplitRead::new(reader)
+ .read(&indexed)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 12]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2]
+ );
+ for batch in &batches {
+ assert!(
+ column_by_name(batch, "_ROW_ID").is_none(),
+ "_ROW_ID must not leak"
+ );
+ assert!(column_by_name(batch, PKEY_VECTOR_SCORE_COLUMN).is_none());
+ }
+ }
+
+ #[tokio::test]
+ async fn reads_ranges_with_aligned_scores() {
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13]));
+ let (reader, split) = build_reader_and_split("memory:/pkvisr_scores",
&data, 4, &[]).await;
+ let indexed = PkVectorIndexedSplit {
+ split,
+ row_ranges: vec![RowRange::new(0, 0), RowRange::new(2, 3)],
+ scores: Some(vec![0.9, 0.5, 0.1]),
+ };
+
+ let batches = PkVectorIndexedSplitRead::new(reader)
+ .read(&indexed)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 12, 13]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2, 3]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.9, 0.5, 0.1]
+ );
+ }
+
+ #[tokio::test]
+ async fn deletion_vector_drops_selected_position_and_its_score() {
+ // select positions [0,1,2,3] via ranges, scores aligned; DV deletes
position 1.
+ // -> returned positions [0,2,3], scores [.4,.2,.1]; input scores
still had key 1.
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13]));
+ let (reader, split) = build_reader_and_split("memory:/pkvisr_dv",
&data, 4, &[1]).await;
+ let indexed = PkVectorIndexedSplit {
+ split,
+ row_ranges: vec![RowRange::new(0, 3)],
+ scores: Some(vec![0.4, 0.3, 0.2, 0.1]),
+ };
+
+ let batches = PkVectorIndexedSplitRead::new(reader)
+ .read(&indexed)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 12, 13]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2, 3]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.4, 0.2, 0.1]
+ );
+ }
+
+ #[tokio::test]
+ async fn alignment_holds_across_multiple_batches() {
+ // Three row groups [10,11] [12,13] [14,15] -> reader yields >1 batch.
+ // ranges [1..=2, 4..=4] span batch boundaries.
+ let data = write_mosaic_multi_group(&[
+ id_batch(vec![10, 11]),
+ id_batch(vec![12, 13]),
+ id_batch(vec![14, 15]),
+ ]);
+ let (reader, split) =
+ build_reader_and_split("memory:/pkvisr_multibatch", &data, 6,
&[]).await;
+ let indexed = PkVectorIndexedSplit {
+ split,
+ row_ranges: vec![RowRange::new(1, 2), RowRange::new(4, 4)],
+ scores: Some(vec![0.9, 0.5, 0.1]),
+ };
+
+ let batches = PkVectorIndexedSplitRead::new(reader)
+ .read(&indexed)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert!(
+ batches.len() > 1,
+ "expected multiple batches, got {}",
+ batches.len()
+ );
+ assert_eq!(collect_i32(&batches, "id"), vec![11, 12, 14]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![1, 2, 4]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.9, 0.5, 0.1]
+ );
+ }
+
+ #[tokio::test]
+ async fn multi_file_split_errors_at_call_site() {
+ // Eager validation: multi-file split errors from read(...) itself,
not the stream.
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11]));
+ let (reader, _split) =
+ build_reader_and_split("memory:/pkvisr_multifile", &data, 2,
&[]).await;
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/pkvisr_multifile/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![
+ data_file("part-0.mosaic", data.len() as i64, 2),
+ data_file("part-1.mosaic", data.len() as i64, 2),
+ ])
+ .build()
+ .unwrap();
+ let indexed = PkVectorIndexedSplit {
+ split,
+ row_ranges: vec![RowRange::new(0, 0)],
+ scores: None,
+ };
+
+ let err = PkVectorIndexedSplitRead::new(reader)
+ .read(&indexed)
+ .err()
+ .expect("multi-file split must error eagerly");
+ assert!(
+ format!("{err:?}").contains("exactly one data file"),
+ "got: {err:?}"
+ );
+ }
+}
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs
b/crates/paimon/src/table/pk_vector_orchestrator.rs
new file mode 100644
index 0000000..b8664cc
--- /dev/null
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -0,0 +1,1181 @@
+// 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 read orchestration: Rust equivalent of Java
+//! `PrimaryKeyVectorRead` + `PrimaryKeyVectorResult.splits()`.
+//!
+//! Per-bucket search via `bucket_search`, cross-bucket global Top-K merge,
+//! grouping survivors by data file into `PkVectorIndexedSplit`s, and lazy
+//! materialization via `PkVectorIndexedSplitRead`. Inputs are per-bucket
+//! `PkVectorSearchSplit`s supplied by the caller.
+
+// The read path that drives this module lands in a later change, so under
+// clippy -D warnings the module reads as dead_code until then. Suppress at the
+// module boundary.
+#![allow(dead_code)]
+
+use std::cmp::Ordering;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use futures::StreamExt;
+
+use crate::deletion_vector::DeletionVector;
+use crate::spec::BinaryRow;
+use crate::table::data_file_reader::DataFileReader;
+use crate::table::pk_vector_indexed_split_read::{PkVectorIndexedSplit,
PkVectorIndexedSplitRead};
+use crate::table::source::{DataSplit, DataSplitBuilder, RowRange};
+use crate::table::ArrowRecordBatchStream;
+use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
+use crate::vindex::pkvector::bucket::{bucket_search, BucketActiveFile,
BucketAnnSegment};
+use crate::vindex::pkvector::metric::{java_float_compare, VectorSearchMetric};
+use crate::vindex::pkvector::reader::PkVectorReader;
+use crate::vindex::pkvector::result::PkVectorSearchResult;
+
+fn data_invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+/// Per-bucket search input. Rust equivalent of Java `BucketVectorSearchSplit`.
+/// A `PrimaryKeyVectorScan` mirror constructs these from a snapshot/manifest
plan.
+pub(crate) struct PkVectorSearchSplit {
+ /// The bucket's combined data split (>= 1 data file); source of the
+ /// partition/bucket/bucket_path/snapshot, the per-file `DataFileMeta`,
and the
+ /// deletion files. Its `data_files()` is the authority for re-associating
a hit's
+ /// file name back to a `DataFileMeta`.
+ pub data_split: DataSplit,
+ /// ANN payload segments for this bucket.
+ pub ann_segments: Vec<BucketAnnSegment>,
+ /// Files eligible for exact fallback.
+ pub active_files: Vec<BucketActiveFile>,
+}
+
+/// A `bucket_search` hit tagged with its source bucket. Rust equivalent of
Java
+/// `PrimaryKeyVectorRead.Candidate`. `partition`/`bucket` are the cross-bucket
+/// merge dimensions a lone `PkVectorSearchResult` lacks; `split_index` is the
+/// re-association handle back to `splits[split_index].data_split`.
+struct Candidate {
+ split_index: usize,
+ partition: BinaryRow,
+ bucket: i32,
+ data_file_name: String,
+ row_position: i64,
+ distance: f32,
+}
+
+/// 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
+/// partition's serialized bytes; Rust `Vec<u8>::cmp` is unsigned lexicographic
+/// then shorter-is-less, exactly the spec's contract (`[0x7f] < [0x80] <
[0xff]`).
+fn candidate_cmp(a: &Candidate, b: &Candidate) -> Ordering {
+ java_float_compare(a.distance, b.distance)
+ .then_with(|| {
+ a.partition
+ .to_serialized_bytes()
+ .cmp(&b.partition.to_serialized_bytes())
+ })
+ .then_with(|| a.bucket.cmp(&b.bucket))
+ .then_with(|| a.data_file_name.cmp(&b.data_file_name))
+ .then_with(|| a.row_position.cmp(&b.row_position))
+}
+
+/// Collect all candidates, order BEST_FIRST, keep the best `limit`.
+fn global_top_k(mut candidates: Vec<Candidate>, limit: usize) ->
Vec<Candidate> {
+ candidates.sort_by(candidate_cmp);
+ candidates.truncate(limit);
+ candidates
+}
+
+/// 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
+/// emitted in ascending group-key order (deterministic file/position output
+/// order). Mirrors Java `PrimaryKeyVectorResult.splits()`.
+fn build_indexed_splits(
+ survivors: Vec<Candidate>,
+ splits: &[PkVectorSearchSplit],
+ metric: VectorSearchMetric,
+) -> crate::Result<Vec<PkVectorIndexedSplit>> {
+ // Group key: (partition bytes, bucket, file name). BTreeMap keeps
ascending
+ // group order deterministically. Value: (split_index, Vec<(position,
distance)>).
+ use std::collections::BTreeMap;
+ type GroupKey = (Vec<u8>, i32, String);
+ let mut groups: BTreeMap<GroupKey, (usize, Vec<(i64, f32)>)> =
BTreeMap::new();
+ for c in survivors {
+ let key = (
+ c.partition.to_serialized_bytes(),
+ c.bucket,
+ c.data_file_name.clone(),
+ );
+ let entry = groups
+ .entry(key)
+ .or_insert_with(|| (c.split_index, Vec::new()));
+ // A (partition, bucket, file_name) group must map to a single source
+ // split. Two candidates sharing the group key but tagged with
different
+ // split_index means malformed input (e.g. duplicate buckets);
+ // silently merging them would materialize against the wrong split, so
+ // fail loud instead.
+ if entry.0 != c.split_index {
+ return Err(data_invalid(format!(
+ "vector search hits for {} map to different splits ({} and
{})",
+ c.data_file_name, entry.0, c.split_index
+ )));
+ }
+ entry.1.push((c.row_position, c.distance));
+ }
+
+ let mut out = Vec::with_capacity(groups.len());
+ for ((_partition, _bucket, file_name), (split_index, mut hits)) in groups {
+ // Sort positions ascending; reject duplicate (file, position).
+ hits.sort_by_key(|(pos, _)| *pos);
+ for pair in hits.windows(2) {
+ if pair[0].0 == pair[1].0 {
+ return Err(data_invalid(format!(
+ "duplicate (file, position) in vector search result: {} @
{}",
+ file_name, pair[0].0
+ )));
+ }
+ }
+
+ // Re-associate the file to its DataFileMeta + aligned deletion file.
+ let source = &splits[split_index].data_split;
+ let file_idx = source
+ .data_files()
+ .iter()
+ .position(|f| f.file_name == file_name)
+ .ok_or_else(|| {
+ data_invalid(format!(
+ "vector search hit references data file {file_name} not
present in its bucket split"
+ ))
+ })?;
+ let file_meta = source.data_files()[file_idx].clone();
+ let deletion_file = source
+ .data_deletion_files()
+ .and_then(|dfs| dfs.get(file_idx).cloned().flatten());
+
+ // Coalesce ascending positions into inclusive ranges; scores aligned
to
+ // ascending-position order.
+ let mut row_ranges: Vec<RowRange> = Vec::new();
+ let mut scores: Vec<f32> = Vec::with_capacity(hits.len());
+ let mut start = hits[0].0;
+ let mut end = hits[0].0;
+ scores.push(metric.distance_to_score(hits[0].1));
+ for &(pos, distance) in &hits[1..] {
+ if pos == end + 1 {
+ end = pos;
+ } else {
+ row_ranges.push(RowRange::new(start, end));
+ start = pos;
+ end = pos;
+ }
+ scores.push(metric.distance_to_score(distance));
+ }
+ row_ranges.push(RowRange::new(start, end));
+
+ let mut builder = DataSplitBuilder::new()
+ .with_snapshot(source.snapshot_id())
+ .with_partition(source.partition().clone())
+ .with_bucket(source.bucket())
+ .with_bucket_path(source.bucket_path().to_string())
+ .with_total_buckets(source.total_buckets())
+ .with_data_files(vec![file_meta]);
+ if let Some(df) = deletion_file {
+ builder = builder.with_data_deletion_files(vec![Some(df)]);
+ }
+ let split = builder.build()?;
+
+ out.push(PkVectorIndexedSplit {
+ split,
+ row_ranges,
+ scores: Some(scores),
+ });
+ }
+ Ok(out)
+}
+
+/// Build one bucket's DV map: keys are the union of active-file names and all
+/// ANN-source file names, so an ANN-source file not in `active_files` still
gets
+/// its DV. Uses one split-level factory. (Search-time DV; materialization
loads
+/// its own DV again — an accepted redundancy.)
+async fn build_bucket_dv_map(
+ reader: &DataFileReader,
+ split: &PkVectorSearchSplit,
+) -> crate::Result<HashMap<String, Arc<DeletionVector>>> {
+ let factory = reader.build_split_dv_factory(&split.data_split).await?;
+ let mut names: Vec<&str> = split
+ .active_files
+ .iter()
+ .map(|f| f.file_name.as_str())
+ .collect();
+ for segment in &split.ann_segments {
+ for source in segment.source_meta.source_files() {
+ names.push(source.file_name());
+ }
+ }
+ let mut dvs = HashMap::new();
+ for name in names {
+ if dvs.contains_key(name) {
+ continue;
+ }
+ if let Some(dv) =
DataFileReader::deletion_vector_for_file(factory.as_ref(), name) {
+ dvs.insert(name.to_string(), dv);
+ }
+ }
+ Ok(dvs)
+}
+
+/// Read orchestrator for the PK-table vector search path. Mirrors Java
+/// `PrimaryKeyVectorRead` + `PrimaryKeyVectorResult.splits()`.
+pub(crate) struct PkVectorOrchestrator {
+ reader: DataFileReader,
+}
+
+impl PkVectorOrchestrator {
+ pub(crate) fn new(reader: DataFileReader) -> Self {
+ Self { reader }
+ }
+
+ /// Run the eager per-bucket search, then lazily materialize the surviving
+ /// rows. `async` because the eager search phase is genuine async IO
+ /// needing the borrowed `exact_reader_factory` / `ann_searcher`; the
returned
+ /// stream owns only the built splits + a reader clone (so it is
`'static`).
+ #[allow(clippy::too_many_arguments)]
+ pub(crate) async fn read(
+ &self,
+ splits: &[PkVectorSearchSplit],
+ query: &[f32],
+ metric: VectorSearchMetric,
+ limit: usize,
+ ann_searcher: Option<&dyn PkVectorAnnSearcher>,
+ exact_reader_factory: &mut dyn FnMut(
+ &BucketActiveFile,
+ ) -> crate::Result<Box<dyn PkVectorReader>>,
+ search_options: &HashMap<String, String>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ // Eager input-shape validation (Java checkArgument parity).
+ if limit == 0 {
+ return Err(data_invalid("vector search limit must be positive"));
+ }
+ if query.is_empty() {
+ return Err(data_invalid("vector search query must not be empty"));
+ }
+
+ // Eager per-bucket search -> tagged candidates.
+ let mut candidates: Vec<Candidate> = Vec::new();
+ for (split_index, split) in splits.iter().enumerate() {
+ let dvs = build_bucket_dv_map(&self.reader, split).await?;
+ let results = bucket_search(
+ ann_searcher,
+ &split.ann_segments,
+ &split.active_files,
+ &dvs,
+ exact_reader_factory,
+ query,
+ metric,
+ limit,
+ search_options,
+ )?;
+ for PkVectorSearchResult {
+ data_file_name,
+ row_position,
+ distance,
+ } in results
+ {
+ candidates.push(Candidate {
+ split_index,
+ partition: split.data_split.partition().clone(),
+ bucket: split.data_split.bucket(),
+ data_file_name,
+ row_position,
+ distance,
+ });
+ }
+ }
+
+ // Eager global merge + grouping + split construction.
+ let survivors = global_top_k(candidates, limit);
+ let indexed_splits = build_indexed_splits(survivors, splits, metric)?;
+
+ // Lazy materialization: own the splits + a reader clone.
+ let reader = self.reader.clone();
+ let stream = async_stream::try_stream! {
+ for indexed in indexed_splits {
+ let inner =
PkVectorIndexedSplitRead::new(reader.clone()).read(&indexed)?;
+ futures::pin_mut!(inner);
+ while let Some(batch) = inner.next().await {
+ yield batch?;
+ }
+ }
+ };
+ Ok(Box::pin(stream))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::DataFileMeta;
+
+ fn data_file(name: &str, row_count: i64) -> DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 1,
+ row_count,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 1,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: Some(0),
+ write_cols: None,
+ }
+ }
+
+ fn bucket_split(bucket: i32, files: Vec<DataFileMeta>) -> DataSplit {
+ DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(bucket)
+ .with_bucket_path(format!("memory:/pkvo/bucket-{bucket}"))
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .build()
+ .unwrap()
+ }
+
+ fn search_split(bucket: i32, files: Vec<DataFileMeta>) ->
PkVectorSearchSplit {
+ PkVectorSearchSplit {
+ data_split: bucket_split(bucket, files),
+ ann_segments: Vec::new(),
+ active_files: Vec::new(),
+ }
+ }
+
+ // Candidate carrying an empty (arity-0) partition, matching
bucket_split's partition.
+ fn cand(split_index: usize, bucket: i32, file: &str, pos: i64, distance:
f32) -> Candidate {
+ Candidate {
+ split_index,
+ partition: BinaryRow::new(0),
+ bucket,
+ data_file_name: file.to_string(),
+ row_position: pos,
+ distance,
+ }
+ }
+
+ fn candidate(
+ split_index: usize,
+ partition_bytes: Vec<u8>,
+ bucket: i32,
+ file: &str,
+ pos: i64,
+ distance: f32,
+ ) -> Candidate {
+ Candidate {
+ split_index,
+ partition: BinaryRow::from_bytes(1, partition_bytes),
+ bucket,
+ data_file_name: file.to_string(),
+ row_position: pos,
+ distance,
+ }
+ }
+
+ fn ids(c: &[Candidate]) -> Vec<(i32, String, i64)> {
+ c.iter()
+ .map(|c| (c.bucket, c.data_file_name.clone(), c.row_position))
+ .collect()
+ }
+
+ #[test]
+ fn merges_global_top_k_with_deterministic_ties() {
+ // Java
PrimaryKeyVectorReadTest.testMergesGlobalTopKWithDeterministicTies:
+ // (b1,file-c,pos0,d=2), (b1,file-b,pos1,d=1), (b0,file-a,pos2,d=1),
limit=2.
+ // Same partition -> drop file-c (d=2); two d=1 tie on bucket: 0 < 1.
+ // Result: [(0,"file-a",2), (1,"file-b",1)].
+ let part = vec![0x00];
+ let survivors = global_top_k(
+ vec![
+ candidate(0, part.clone(), 1, "file-c", 0, 2.0),
+ candidate(0, part.clone(), 1, "file-b", 1, 1.0),
+ candidate(1, part.clone(), 0, "file-a", 2, 1.0),
+ ],
+ 2,
+ );
+ assert_eq!(
+ ids(&survivors),
+ vec![(0, "file-a".to_string(), 2), (1, "file-b".to_string(), 1)]
+ );
+ }
+
+ #[test]
+ fn orders_partition_bytes_as_unsigned() {
+ // Guards against signed-byte comparison: 0x7f < 0x80 < 0xff
(unsigned).
+ // Equal distance so level 2 (partition bytes) decides.
+ let survivors = global_top_k(
+ vec![
+ candidate(2, vec![0xff], 0, "f", 0, 1.0),
+ candidate(0, vec![0x7f], 0, "f", 0, 1.0),
+ candidate(1, vec![0x80], 0, "f", 0, 1.0),
+ ],
+ 3,
+ );
+ assert_eq!(
+ survivors
+ .iter()
+ .map(|c| c.partition.to_serialized_bytes().pop().unwrap())
+ .collect::<Vec<u8>>(),
+ vec![0x7f, 0x80, 0xff]
+ );
+ }
+
+ #[test]
+ fn truncates_to_limit() {
+ let part = vec![0x00];
+ let survivors = global_top_k(
+ vec![
+ candidate(0, part.clone(), 0, "f", 0, 3.0),
+ candidate(0, part.clone(), 0, "f", 1, 1.0),
+ candidate(0, part.clone(), 0, "f", 2, 2.0),
+ ],
+ 1,
+ );
+ assert_eq!(ids(&survivors), vec![(0, "f".to_string(), 1)]); //
smallest distance
+ }
+
+ #[test]
+ fn empty_candidates_yield_empty() {
+ let survivors = global_top_k(Vec::new(), 5);
+ assert!(survivors.is_empty());
+ }
+
+ #[test]
+ fn builds_two_splits_with_ascending_position_ordered_scores() {
+ // One bucket, two files. file-a hits at global order [pos=10, pos=2];
+ // build must reorder to positions [2,10] with scores [score(2),
score(10)].
+ let splits = vec![search_split(
+ 0,
+ vec![data_file("file-a", 20), data_file("file-b", 20)],
+ )];
+ let survivors = vec![
+ cand(0, 0, "file-a", 10, 3.0),
+ cand(0, 0, "file-b", 5, 2.0),
+ cand(0, 0, "file-a", 2, 1.0),
+ ];
+ let built = build_indexed_splits(survivors, &splits,
VectorSearchMetric::L2).unwrap();
+ assert_eq!(built.len(), 2);
+
+ // Group order is ascending (partition, bucket, name): file-a before
file-b.
+ let a = &built[0];
+ assert_eq!(a.split.data_files()[0].file_name, "file-a");
+ assert_eq!(
+ a.row_ranges,
+ vec![RowRange::new(2, 2), RowRange::new(10, 10)]
+ );
+ // scores in ascending-position order: score(d=1.0) for pos2,
score(d=3.0) for pos10.
+ assert_eq!(
+ a.scores.as_deref(),
+ Some(
+ [
+ VectorSearchMetric::L2.distance_to_score(1.0),
+ VectorSearchMetric::L2.distance_to_score(3.0),
+ ]
+ .as_slice()
+ )
+ );
+
+ let b = &built[1];
+ assert_eq!(b.split.data_files()[0].file_name, "file-b");
+ assert_eq!(b.row_ranges, vec![RowRange::new(5, 5)]);
+ }
+
+ #[test]
+ fn coalesces_consecutive_positions_into_one_range() {
+ let splits = vec![search_split(0, vec![data_file("f", 10)])];
+ let survivors = vec![
+ cand(0, 0, "f", 0, 1.0),
+ cand(0, 0, "f", 1, 1.0),
+ cand(0, 0, "f", 2, 1.0),
+ cand(0, 0, "f", 5, 1.0),
+ ];
+ let built = build_indexed_splits(survivors, &splits,
VectorSearchMetric::L2).unwrap();
+ assert_eq!(
+ built[0].row_ranges,
+ vec![RowRange::new(0, 2), RowRange::new(5, 5)]
+ );
+ }
+
+ #[test]
+ fn rejects_file_absent_from_bucket_split() {
+ let splits = vec![search_split(0, vec![data_file("known", 10)])];
+ let survivors = vec![cand(0, 0, "unknown", 0, 1.0)];
+ // PkVectorIndexedSplit has no Debug; map the Ok value away so
expect_err's
+ // `T: Debug` bound is satisfied without touching the shared type.
+ let err = build_indexed_splits(survivors, &splits,
VectorSearchMetric::L2)
+ .map(|_| ())
+ .expect_err("unknown file must error");
+ assert!(
+ format!("{err:?}").contains("unknown") ||
format!("{err:?}").contains("not"),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_duplicate_file_position() {
+ let splits = vec![search_split(0, vec![data_file("f", 10)])];
+ let survivors = vec![cand(0, 0, "f", 3, 1.0), cand(0, 0, "f", 3, 2.0)];
+ let err = build_indexed_splits(survivors, &splits,
VectorSearchMetric::L2)
+ .map(|_| ())
+ .expect_err("duplicate (file,pos) must error");
+ assert!(format!("{err:?}").contains("duplicate"), "got: {err:?}");
+ }
+
+ #[test]
+ fn rejects_same_group_key_from_different_splits() {
+ // Two buckets share (partition, bucket, file_name) but sit at
different
+ // split_index. Silently merging them would materialize against the
wrong
+ // split; fail loud instead (defensive guard against malformed
+ // input). Both search_splits have the empty partition + bucket 0 +
file "f".
+ let splits = vec![
+ search_split(0, vec![data_file("f", 10)]),
+ search_split(0, vec![data_file("f", 10)]),
+ ];
+ let survivors = vec![cand(0, 0, "f", 1, 1.0), cand(1, 0, "f", 2, 1.0)];
+ let err = build_indexed_splits(survivors, &splits,
VectorSearchMetric::L2)
+ .map(|_| ())
+ .expect_err("same group key from different splits must error");
+ assert!(
+ format!("{err:?}").contains("different splits")
+ || format!("{err:?}").contains("distinct splits"),
+ "got: {err:?}"
+ );
+ }
+}
+
+#[cfg(test)]
+mod e2e_tests {
+ use super::*;
+ use crate::arrow::build_target_arrow_schema;
+ use crate::io::{FileIO, FileIOBuilder};
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{
+ DataField, DataFileMeta, DataType, IntType, PkVectorSourceFile,
PkVectorSourceMeta,
+ };
+ use crate::table::pk_vector_position_read::{
+ PKEY_VECTOR_POSITION_COLUMN, PKEY_VECTOR_SCORE_COLUMN,
+ };
+ use crate::table::schema_manager::SchemaManager;
+ use crate::table::source::DeletionFile;
+ use crate::vindex::pkvector::reader::test_support::ArrayReader;
+ use arrow_array::{Array, Float32Array, Int32Array, Int64Array,
RecordBatch};
+ use bytes::Bytes;
+ use futures::TryStreamExt;
+ use paimon_mosaic_core::spec::COMPRESSION_NONE;
+ use paimon_mosaic_core::writer::{MosaicWriter, OutputFile, WriterOptions};
+ use roaring::RoaringBitmap;
+ use std::collections::HashSet;
+ use std::io;
+
+ struct MemOutputFile {
+ data: Vec<u8>,
+ }
+ impl MemOutputFile {
+ fn new() -> Self {
+ Self { data: Vec::new() }
+ }
+ }
+ impl OutputFile for MemOutputFile {
+ fn write(&mut self, data: &[u8]) -> io::Result<()> {
+ self.data.extend_from_slice(data);
+ Ok(())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ fn pos(&self) -> u64 {
+ self.data.len() as u64
+ }
+ }
+
+ fn id_field() -> DataField {
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new()))
+ }
+ fn id_fields() -> Vec<DataField> {
+ vec![id_field()]
+ }
+ fn id_batch(ids: Vec<i32>) -> RecordBatch {
+ let schema = build_target_arrow_schema(&id_fields()).unwrap();
+ RecordBatch::try_new(schema,
vec![Arc::new(Int32Array::from(ids))]).unwrap()
+ }
+
+ fn data_file(file_name: &str, file_size: i64, row_count: i64) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: file_name.to_string(),
+ file_size,
+ row_count,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 1,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: Some(0),
+ write_cols: None,
+ }
+ }
+
+ fn write_mosaic_single_group(batch: &RecordBatch) -> Bytes {
+ let out = MemOutputFile::new();
+ let mut writer = MosaicWriter::new(
+ out,
+ batch.schema().as_ref(),
+ WriterOptions {
+ compression: COMPRESSION_NONE,
+ num_buckets: 2,
+ row_group_max_size: u64::MAX,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ writer.write_batch(batch).unwrap();
+ writer.close().unwrap();
+ Bytes::from(writer.output().data.to_vec())
+ }
+
+ async fn write_deletion_file(
+ file_io: &FileIO,
+ path: &str,
+ deleted_rows: &[u32],
+ ) -> DeletionFile {
+ 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::from(blob))
+ .await
+ .unwrap();
+ DeletionFile::new(
+ path.to_string(),
+ 0,
+ bitmap_length as i64,
+ Some(deleted_rows.len() as i64),
+ )
+ }
+
+ fn make_reader(file_io: FileIO, table_path: &str) -> DataFileReader {
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ DataFileReader::new(
+ file_io,
+ schema_manager,
+ 1,
+ id_fields(),
+ id_fields(),
+ Vec::new(),
+ )
+ }
+
+ /// Write one mosaic data file into a bucket path and return its
`DataFileMeta`.
+ async fn write_file(
+ file_io: &FileIO,
+ bucket_path: &str,
+ file_name: &str,
+ ids: Vec<i32>,
+ ) -> DataFileMeta {
+ let row_count = ids.len() as i64;
+ let data = write_mosaic_single_group(&id_batch(ids));
+ file_io
+ .new_output(&format!("{bucket_path}/{file_name}"))
+ .unwrap()
+ .write(data.clone())
+ .await
+ .unwrap();
+ data_file(file_name, data.len() as i64, row_count)
+ }
+
+ fn ann_segment(sources: &[(&str, i64)]) -> BucketAnnSegment {
+ BucketAnnSegment {
+ source_meta: PkVectorSourceMeta::new(
+ sources
+ .iter()
+ .map(|(n, r)| PkVectorSourceFile::new((*n).to_string(),
*r).unwrap())
+ .collect(),
+ )
+ .unwrap(),
+ }
+ }
+
+ fn active(name: &str, rows: i64) -> BucketActiveFile {
+ BucketActiveFile {
+ file_name: name.to_string(),
+ row_count: rows,
+ }
+ }
+
+ fn column_by_name<'a>(batch: &'a RecordBatch, name: &str) -> Option<&'a
Arc<dyn Array>> {
+ batch
+ .schema()
+ .index_of(name)
+ .ok()
+ .map(|idx| batch.column(idx))
+ }
+ fn collect_i32(batches: &[RecordBatch], name: &str) -> Vec<i32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+ fn collect_i64(batches: &[RecordBatch], name: &str) -> Vec<i64> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+ fn collect_f32(batches: &[RecordBatch], name: &str) -> Vec<f32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+
+ fn l2_score(distance: f32) -> f32 {
+ VectorSearchMetric::L2.distance_to_score(distance)
+ }
+
+ // Fake ANN searcher returning preset hits.
+ struct FakeAnn {
+ hits: Vec<PkVectorSearchResult>,
+ }
+ impl PkVectorAnnSearcher for FakeAnn {
+ 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.hits.clone())
+ }
+ }
+
+ #[tokio::test]
+ async fn eager_rejects_zero_limit() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let reader = make_reader(file_io, "memory:/pkvo_zero");
+ let splits: Vec<PkVectorSearchSplit> = Vec::new();
+ let mut factory = |_: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ unreachable!("no bucket search on eager-rejected input")
+ };
+ let opts = HashMap::new();
+ let err = PkVectorOrchestrator::new(reader)
+ .read(
+ &splits,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 0,
+ None,
+ &mut factory,
+ &opts,
+ )
+ .await
+ .map(|_| ())
+ .expect_err("limit == 0 must be rejected eagerly");
+ assert!(format!("{err:?}").contains("positive"), "got: {err:?}");
+ }
+
+ #[tokio::test]
+ async fn eager_rejects_empty_query() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let reader = make_reader(file_io, "memory:/pkvo_empty_query");
+ let splits: Vec<PkVectorSearchSplit> = Vec::new();
+ let mut factory = |_: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ unreachable!("no bucket search on eager-rejected input")
+ };
+ let opts = HashMap::new();
+ let err = PkVectorOrchestrator::new(reader)
+ .read(
+ &splits,
+ &[],
+ VectorSearchMetric::L2,
+ 5,
+ None,
+ &mut factory,
+ &opts,
+ )
+ .await
+ .map(|_| ())
+ .expect_err("empty query must be rejected eagerly");
+ assert!(format!("{err:?}").contains("empty"), "got: {err:?}");
+ }
+
+ #[tokio::test]
+ async fn
single_bucket_ann_plus_exact_merge_materializes_with_position_and_score() {
+ // One bucket, two files: "ann.mosaic" is ANN-covered (FakeAnn hit at
pos 1,
+ // distance 0.25); "exact.mosaic" is exact fallback (ArrayReader).
Covered
+ // files are NOT re-scanned by the exact fallback.
+ let table_path = "memory:/pkvo_single";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let ann_meta = write_file(&file_io, &bucket_path, "ann.mosaic",
vec![100, 101, 102]).await;
+ let exact_meta = write_file(&file_io, &bucket_path, "exact.mosaic",
vec![200, 201]).await;
+
+ 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![ann_meta, exact_meta])
+ .build()
+ .unwrap();
+ let split = PkVectorSearchSplit {
+ data_split,
+ ann_segments: vec![ann_segment(&[("ann.mosaic", 3)])],
+ active_files: vec![active("ann.mosaic", 3), active("exact.mosaic",
2)],
+ };
+
+ let ann = FakeAnn {
+ hits: vec![PkVectorSearchResult {
+ data_file_name: "ann.mosaic".to_string(),
+ row_position: 1,
+ distance: 0.25,
+ }],
+ };
+ // Exact fallback scans only "exact.mosaic": pos0 {1,0} d=1.0, pos1
{2,0} d=4.0.
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ let vectors = match f.file_name.as_str() {
+ "exact.mosaic" => vec![Some(vec![1.0, 0.0]), Some(vec![2.0,
0.0])],
+ other => panic!("unexpected exact scan of covered file
{other}"),
+ };
+ Ok(Box::new(ArrayReader::new(2, vectors)))
+ };
+ let opts = HashMap::new();
+ let batches = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
+ .read(
+ &[split],
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 3,
+ Some(&ann),
+ &mut factory,
+ &opts,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ // Output is ascending group (file name) then ascending position:
+ // ann.mosaic pos1 -> id 101; exact.mosaic pos0,1 -> ids 200,201.
+ assert_eq!(collect_i32(&batches, "id"), vec![101, 200, 201]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![1, 0, 1]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![l2_score(0.25), l2_score(1.0), l2_score(4.0)]
+ );
+ for batch in &batches {
+ assert!(
+ column_by_name(batch, "_ROW_ID").is_none(),
+ "_ROW_ID must not leak"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn multi_bucket_merge_keeps_global_top_k() {
+ // Two buckets, exact-only. limit=3 < 6 total hits; surviving rows are
the
+ // global-best 3 by distance across both buckets.
+ let table_path = "memory:/pkvo_multi";
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+
+ let b0_path = format!("{table_path}/bucket-0");
+ let b0_meta = write_file(&file_io, &b0_path, "b0.mosaic", vec![10, 11,
12]).await;
+ let split0 = PkVectorSearchSplit {
+ data_split: DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(b0_path)
+ .with_total_buckets(2)
+ .with_data_files(vec![b0_meta])
+ .build()
+ .unwrap(),
+ ann_segments: Vec::new(),
+ active_files: vec![active("b0.mosaic", 3)],
+ };
+
+ let b1_path = format!("{table_path}/bucket-1");
+ let b1_meta = write_file(&file_io, &b1_path, "b1.mosaic", vec![20, 21,
22]).await;
+ let split1 = PkVectorSearchSplit {
+ data_split: DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(1)
+ .with_bucket_path(b1_path)
+ .with_total_buckets(2)
+ .with_data_files(vec![b1_meta])
+ .build()
+ .unwrap(),
+ ann_segments: Vec::new(),
+ active_files: vec![active("b1.mosaic", 3)],
+ };
+
+ // b0: x = 1,4,6 -> d = 1,16,36. b1: x = 2,3,5 -> d = 4,9,25.
+ // Global best 3: d1 (b0 pos0 id10), d4 (b1 pos0 id20), d9 (b1 pos1
id21).
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ let vectors = match f.file_name.as_str() {
+ "b0.mosaic" => vec![
+ Some(vec![1.0, 0.0]),
+ Some(vec![4.0, 0.0]),
+ Some(vec![6.0, 0.0]),
+ ],
+ "b1.mosaic" => vec![
+ Some(vec![2.0, 0.0]),
+ Some(vec![3.0, 0.0]),
+ Some(vec![5.0, 0.0]),
+ ],
+ other => panic!("unexpected file {other}"),
+ };
+ Ok(Box::new(ArrayReader::new(2, vectors)))
+ };
+ let opts = HashMap::new();
+ let batches = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
+ .read(
+ &[split0, split1],
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 3,
+ None,
+ &mut factory,
+ &opts,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ // Ascending group order: bucket0 "b0.mosaic" pos0 -> 10; bucket1
"b1.mosaic"
+ // pos0,1 -> 20,21.
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 20, 21]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 0, 1]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![l2_score(1.0), l2_score(4.0), l2_score(9.0)]
+ );
+ }
+
+ #[tokio::test]
+ async fn dv_deleted_exact_position_is_absent_from_output() {
+ // Exact fallback over "d.mosaic" with a DV deleting position 1. The
deleted
+ // position is absent; remaining position/score alignment holds.
+ let table_path = "memory:/pkvo_dv";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let meta = write_file(&file_io, &bucket_path, "d.mosaic", vec![30, 31,
32, 33]).await;
+ let df = write_deletion_file(&file_io,
&format!("{table_path}/index/dv-0"), &[1]).await;
+ 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![meta])
+ .with_data_deletion_files(vec![Some(df)])
+ .build()
+ .unwrap();
+ let split = PkVectorSearchSplit {
+ data_split,
+ ann_segments: Vec::new(),
+ active_files: vec![active("d.mosaic", 4)],
+ };
+
+ // pos0 {1,0} d=1, pos1 {2,0} d=4 (DELETED), pos2 {3,0} d=9, pos3
{0,0} d=0.
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ let vectors = match f.file_name.as_str() {
+ "d.mosaic" => vec![
+ Some(vec![1.0, 0.0]),
+ Some(vec![2.0, 0.0]),
+ Some(vec![3.0, 0.0]),
+ Some(vec![0.0, 0.0]),
+ ],
+ other => panic!("unexpected file {other}"),
+ };
+ Ok(Box::new(ArrayReader::new(2, vectors)))
+ };
+ let opts = HashMap::new();
+ let batches = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
+ .read(
+ &[split],
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 4,
+ None,
+ &mut factory,
+ &opts,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ // Position 1 (id 31) is absent. Remaining ascending positions 0,2,3.
+ assert_eq!(collect_i32(&batches, "id"), vec![30, 32, 33]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2, 3]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![l2_score(1.0), l2_score(9.0), l2_score(0.0)]
+ );
+ }
+
+ #[tokio::test]
+ async fn output_is_file_position_order_not_best_first() {
+ // Best-first order (by distance) differs from file/position order.
Output must
+ // be ascending file/position order.
+ let table_path = "memory:/pkvo_order";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let meta = write_file(&file_io, &bucket_path, "o.mosaic", vec![40, 41,
42]).await;
+ let split = PkVectorSearchSplit {
+ 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![meta])
+ .build()
+ .unwrap(),
+ ann_segments: Vec::new(),
+ active_files: vec![active("o.mosaic", 3)],
+ };
+
+ // pos0 {3,0} d=9, pos1 {1,0} d=1, pos2 {2,0} d=4. Best-first =
[1,2,0].
+ let mut factory = |f: &BucketActiveFile| -> crate::Result<Box<dyn
PkVectorReader>> {
+ let vectors = match f.file_name.as_str() {
+ "o.mosaic" => vec![
+ Some(vec![3.0, 0.0]),
+ Some(vec![1.0, 0.0]),
+ Some(vec![2.0, 0.0]),
+ ],
+ other => panic!("unexpected file {other}"),
+ };
+ Ok(Box::new(ArrayReader::new(2, vectors)))
+ };
+ let opts = HashMap::new();
+ let batches = PkVectorOrchestrator::new(make_reader(file_io,
table_path))
+ .read(
+ &[split],
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 3,
+ None,
+ &mut factory,
+ &opts,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ // Ascending physical position order, not best-first distance order.
+ assert_eq!(collect_i32(&batches, "id"), vec![40, 41, 42]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 1, 2]
+ );
+ // Scores aligned to ascending position: d=9,1,4.
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![l2_score(9.0), l2_score(1.0), l2_score(4.0)]
+ );
+ }
+}
diff --git a/crates/paimon/src/table/pk_vector_position_read.rs
b/crates/paimon/src/table/pk_vector_position_read.rs
new file mode 100644
index 0000000..6a5ec45
--- /dev/null
+++ b/crates/paimon/src/table/pk_vector_position_read.rs
@@ -0,0 +1,1079 @@
+// 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 position read (reader-kernel subset of
apache/paimon#8576).
+//!
+//! Materializes the selected physical rows of one data file and appends
+//! `_PKEY_VECTOR_POSITION` (+ optional `_PKEY_VECTOR_SCORE`) metadata columns.
+//! Rust equivalent of Java `PrimaryKeyVectorPositionReader`. This is the
lowest
+//! layer of the PK-vector read kernel; the sibling
`pk_vector_indexed_split_read`
+//! and `pk_vector_orchestrator` modules build the indexed-split contract and
+//! cross-bucket merge on top of it.
+
+// The read path that would call these items lives in a later change, so under
+// clippy -D warnings they read as dead_code. Suppress at the module boundary.
+#![allow(dead_code)]
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use arrow_array::{Array, Float32Array, Int64Array, RecordBatch};
+use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Fields,
Schema as ArrowSchema};
+use futures::StreamExt;
+
+use crate::deletion_vector::DeletionVector;
+use crate::spec::{
+ BigIntType, DataField, DataFileMeta, DataType, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
+};
+use crate::table::data_file_reader::DataFileReader;
+use crate::table::source::DataSplit;
+use crate::table::{ArrowRecordBatchStream, RowRange};
+
+pub(crate) const PKEY_VECTOR_POSITION_COLUMN: &str = "_PKEY_VECTOR_POSITION";
+pub(crate) const PKEY_VECTOR_SCORE_COLUMN: &str = "_PKEY_VECTOR_SCORE";
+
+fn data_invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+/// Coalesce sorted, de-duplicated 0-based physical positions into contiguous
+/// global `RowRange`s (each bound offset by `first_row_id`). The read path
+/// converts these back to local ranges via
`to_local_row_ranges(first_row_id)`,
+/// so this round-trips to exactly the requested physical positions.
+fn positions_to_global_ranges(
+ sorted_positions: &[i64],
+ first_row_id: i64,
+) -> crate::Result<Vec<RowRange>> {
+ let mut ranges = Vec::new();
+ let mut iter = sorted_positions.iter().copied();
+ let Some(first) = iter.next() else {
+ return Ok(ranges);
+ };
+ let mut push_range = |start: i64, end: i64| -> crate::Result<()> {
+ let from = start
+ .checked_add(first_row_id)
+ .ok_or_else(|| data_invalid("vector position offset overflows
i64"))?;
+ let to = end
+ .checked_add(first_row_id)
+ .ok_or_else(|| data_invalid("vector position offset overflows
i64"))?;
+ ranges.push(RowRange::new(from, to));
+ Ok(())
+ };
+ let mut start = first;
+ let mut end = first;
+ for pos in iter {
+ if end.checked_add(1) == Some(pos) {
+ end = pos;
+ } else {
+ push_range(start, end)?;
+ start = pos;
+ end = pos;
+ }
+ }
+ push_range(start, end)?;
+ Ok(ranges)
+}
+
+/// Reads selected physical rows of one data file, appending position (+
optional
+/// score) metadata columns. Rust equivalent of Java
+/// `PrimaryKeyVectorPositionReader` (apache/paimon#8576, reader-kernel
subset).
+pub(crate) struct PkVectorPositionRead<'a> {
+ reader: &'a DataFileReader,
+}
+
+impl<'a> PkVectorPositionRead<'a> {
+ pub(crate) fn new(reader: &'a DataFileReader) -> Self {
+ Self { reader }
+ }
+
+ pub(crate) fn read(
+ &self,
+ split: &DataSplit,
+ file_meta: DataFileMeta,
+ data_fields: Option<Vec<DataField>>,
+ dv: Option<Arc<DeletionVector>>,
+ positions: impl IntoIterator<Item = i64>,
+ scores: Option<BTreeMap<i64, f32>>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ // (1) normalize + validate positions
+ let mut sorted: Vec<i64> = positions.into_iter().collect();
+ sorted.sort_unstable();
+ sorted.dedup();
+ if sorted.is_empty() {
+ return Err(data_invalid("Selected row positions must not be
empty"));
+ }
+ if let Some(&first) = sorted.first() {
+ if first < 0 {
+ return Err(data_invalid(format!(
+ "Vector position must not be negative: {first}"
+ )));
+ }
+ }
+ if let Some(&last) = sorted.last() {
+ if last >= file_meta.row_count {
+ return Err(data_invalid(format!(
+ "Vector position {last} is outside data file row count {}",
+ file_meta.row_count
+ )));
+ }
+ }
+
+ // (2) scores contract on the input
+ if let Some(scores) = scores.as_ref() {
+ let key_ok =
+ scores.len() == sorted.len() &&
scores.keys().copied().eq(sorted.iter().copied());
+ if !key_ok {
+ return Err(data_invalid(
+ "Scores keys must exactly match the selected row
positions",
+ ));
+ }
+ }
+
+ // (3) reserved-column-name check against the requested output fields
+ for field in self.reader.read_type() {
+ if field.name() == PKEY_VECTOR_POSITION_COLUMN
+ || field.name() == PKEY_VECTOR_SCORE_COLUMN
+ {
+ return Err(data_invalid(format!(
+ "Reserved metadata column name conflicts with a table
column: {}",
+ field.name()
+ )));
+ }
+ if field.name() == ROW_ID_FIELD_NAME {
+ return Err(data_invalid(
+ "PK vector position read does not support a requested
_ROW_ID column; \
+ it is used internally for physical-position recovery",
+ ));
+ }
+ }
+
+ // (4) predicate guard: a row-filtering predicate would desync
positional
+ // row-id recovery, so reject a reader that carries one.
+ if self.reader.has_row_filtering_predicate() {
+ return Err(data_invalid(
+ "PK vector position read requires a predicate-free reader",
+ ));
+ }
+
+ // (5) first_row_id guard
+ let first_row_id = file_meta.first_row_id.ok_or_else(|| {
+ data_invalid("PK vector position read requires a data file with
first_row_id")
+ })?;
+
+ // Build a reader whose read_type includes _ROW_ID so the lower-level
read
+ // emits real global row-ids we can convert to physical positions. A
+ // caller-requested _ROW_ID was already rejected in (3), so it is
always
+ // absent here and appended fresh.
+ let mut inner_read_type = self.reader.read_type().to_vec();
+ inner_read_type.push(DataField::new(
+ ROW_ID_FIELD_ID,
+ ROW_ID_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::new()),
+ ));
+ let inner_reader = self.reader.clone().with_read_type(inner_read_type);
+
+ let ranges = positions_to_global_ranges(&sorted, first_row_id)?;
+ let inner = inner_reader.read_single_file_stream(
+ split,
+ file_meta.clone(),
+ data_fields,
+ dv,
+ Some(ranges),
+ )?;
+
+ let want_score_col = scores.is_some();
+
+ let stream = async_stream::try_stream! {
+ futures::pin_mut!(inner);
+ while let Some(batch) = inner.next().await {
+ let batch = batch?;
+ let out = append_metadata_columns(
+ batch,
+ first_row_id,
+ want_score_col,
+ scores.as_ref(),
+ )?;
+ yield out;
+ }
+ };
+ Ok(Box::pin(stream))
+ }
+}
+
+/// Split `batch` (which contains a `_ROW_ID` column) into an output batch with
+/// `_ROW_ID` removed and `_PKEY_VECTOR_POSITION` (+ optional
`_PKEY_VECTOR_SCORE`)
+/// appended. Positions are `_ROW_ID - first_row_id`; scores are looked up by
the
+/// returned position. Returns the new batch.
+fn append_metadata_columns(
+ batch: RecordBatch,
+ first_row_id: i64,
+ want_score_col: bool,
+ scores: Option<&BTreeMap<i64, f32>>,
+) -> crate::Result<RecordBatch> {
+ let schema = batch.schema();
+ let row_id_idx = schema
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|_| data_invalid("internal: _ROW_ID column missing from
position read"))?;
+ let row_ids = batch
+ .column(row_id_idx)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| data_invalid("internal: _ROW_ID column is not Int64"))?;
+
+ let mut positions = Vec::with_capacity(row_ids.len());
+ let mut score_vals = if want_score_col {
+ Some(Vec::with_capacity(row_ids.len()))
+ } else {
+ None
+ };
+ for i in 0..row_ids.len() {
+ let position = row_ids.value(i) - first_row_id;
+ positions.push(position);
+ if let Some(sv) = score_vals.as_mut() {
+ let score = scores
+ .and_then(|m| m.get(&position).copied())
+ .ok_or_else(|| {
+ data_invalid(format!(
+ "internal: no score for returned position {position}"
+ ))
+ })?;
+ sv.push(score);
+ }
+ }
+
+ // Rebuild columns/fields excluding _ROW_ID, then append metadata columns.
+ let mut fields: Vec<ArrowField> = Vec::new();
+ let mut columns: Vec<Arc<dyn Array>> = Vec::new();
+ for (i, f) in schema.fields().iter().enumerate() {
+ if i == row_id_idx {
+ continue;
+ }
+ fields.push(f.as_ref().clone());
+ columns.push(batch.column(i).clone());
+ }
+ fields.push(ArrowField::new(
+ PKEY_VECTOR_POSITION_COLUMN,
+ ArrowDataType::Int64,
+ false,
+ ));
+ columns.push(Arc::new(Int64Array::from(positions)));
+ if let Some(sv) = score_vals {
+ fields.push(ArrowField::new(
+ PKEY_VECTOR_SCORE_COLUMN,
+ ArrowDataType::Float32,
+ false,
+ ));
+ columns.push(Arc::new(Float32Array::from(sv)));
+ }
+
+ let out_schema = Arc::new(ArrowSchema::new(Fields::from(fields)));
+ RecordBatch::try_new(out_schema, columns).map_err(|e|
crate::Error::UnexpectedError {
+ message: format!("Failed to build position-read batch: {e}"),
+ source: Some(Box::new(e)),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::arrow::build_target_arrow_schema;
+ use crate::deletion_vector::DeletionVectorFactory;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{
+ DataFileMeta, DataType, Datum, IntType, PredicateBuilder,
ROW_ID_FIELD_NAME,
+ };
+ use crate::table::data_file_reader::DataFileReader;
+ use crate::table::schema_manager::SchemaManager;
+ use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
+ use arrow_array::{Array, Float32Array, Int32Array, Int64Array,
RecordBatch};
+ use bytes::Bytes;
+ use futures::TryStreamExt;
+ use paimon_mosaic_core::spec::COMPRESSION_NONE;
+ use paimon_mosaic_core::writer::{MosaicWriter, OutputFile, WriterOptions};
+ use roaring::RoaringBitmap;
+ use std::collections::BTreeMap;
+ use std::io;
+ use std::sync::Arc;
+
+ struct MemOutputFile {
+ data: Vec<u8>,
+ }
+
+ impl MemOutputFile {
+ fn new() -> Self {
+ Self { data: Vec::new() }
+ }
+ }
+
+ impl OutputFile for MemOutputFile {
+ fn write(&mut self, data: &[u8]) -> io::Result<()> {
+ self.data.extend_from_slice(data);
+ Ok(())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+
+ fn pos(&self) -> u64 {
+ self.data.len() as u64
+ }
+ }
+
+ fn id_field() -> DataField {
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new()))
+ }
+
+ /// A single `id: Int32` field is the reader's read-type in every test.
+ fn id_fields() -> Vec<DataField> {
+ vec![id_field()]
+ }
+
+ fn id_batch(ids: Vec<i32>) -> RecordBatch {
+ let schema = build_target_arrow_schema(&id_fields()).unwrap();
+ RecordBatch::try_new(schema,
vec![Arc::new(Int32Array::from(ids))]).unwrap()
+ }
+
+ /// `DataFileMeta` with a specified `first_row_id` (the read path requires
it).
+ fn data_file(
+ file_name: &str,
+ file_size: i64,
+ row_count: i64,
+ schema_id: i64,
+ first_row_id: Option<i64>,
+ ) -> DataFileMeta {
+ DataFileMeta {
+ file_name: file_name.to_string(),
+ file_size,
+ row_count,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id,
+ write_cols: None,
+ }
+ }
+
+ fn write_mosaic_single_group(batch: &RecordBatch) -> Bytes {
+ let out = MemOutputFile::new();
+ let mut writer = MosaicWriter::new(
+ out,
+ batch.schema().as_ref(),
+ WriterOptions {
+ compression: COMPRESSION_NONE,
+ num_buckets: 2,
+ row_group_max_size: u64::MAX,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ writer.write_batch(batch).unwrap();
+ writer.close().unwrap();
+ Bytes::from(writer.output().data.to_vec())
+ }
+
+ /// Writes one row group per batch, so the reader yields one Arrow batch
per
+ /// input batch — used for the cross-batch alignment test.
+ fn write_mosaic_multi_group(batches: &[RecordBatch]) -> Bytes {
+ let out = MemOutputFile::new();
+ let mut writer = MosaicWriter::new(
+ out,
+ batches[0].schema().as_ref(),
+ WriterOptions {
+ compression: COMPRESSION_NONE,
+ num_buckets: 2,
+ row_group_max_size: 1,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ for batch in batches {
+ writer.write_batch(batch).unwrap();
+ }
+ writer.close().unwrap();
+ Bytes::from(writer.output().data.to_vec())
+ }
+
+ async fn write_deletion_file(
+ file_io: &crate::io::FileIO,
+ path: &str,
+ deleted_rows: &[u32],
+ ) -> DeletionFile {
+ 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::from(blob))
+ .await
+ .unwrap();
+ DeletionFile::new(
+ path.to_string(),
+ 0,
+ bitmap_length as i64,
+ Some(deleted_rows.len() as i64),
+ )
+ }
+
+ /// Build a `DataFileReader` over an in-memory mosaic file plus the
matching
+ /// `DataSplit`. `read_type`/`predicates` override the reader's projection
and
+ /// filter; `deleted_rows`, when non-empty, writes a DV into the split.
+ async fn build_reader_and_split(
+ table_path: &str,
+ data: &Bytes,
+ row_count: i64,
+ read_type: Vec<DataField>,
+ predicates: Vec<crate::spec::Predicate>,
+ deleted_rows: &[u32],
+ ) -> (DataFileReader, DataSplit, Option<Arc<DeletionVector>>) {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.mosaic";
+ file_io
+ .new_output(&format!("{bucket_path}/{file_name}"))
+ .unwrap()
+ .write(data.clone())
+ .await
+ .unwrap();
+
+ let schema_id = 1;
+ let mut split_builder = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path)
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file(
+ file_name,
+ data.len() as i64,
+ row_count,
+ schema_id,
+ Some(0),
+ )]);
+ let mut dv = None;
+ if !deleted_rows.is_empty() {
+ let df =
+ write_deletion_file(&file_io,
&format!("{table_path}/index/dv-0"), deleted_rows)
+ .await;
+ dv = Some(Arc::new(
+ DeletionVectorFactory::read(&file_io, &df).await.unwrap(),
+ ));
+ split_builder =
split_builder.with_data_deletion_files(vec![Some(df)]);
+ }
+ let split = split_builder.build().unwrap();
+
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ schema_id,
+ id_fields(),
+ read_type,
+ predicates,
+ );
+ (reader, split, dv)
+ }
+
+ fn column_by_name<'a>(batch: &'a RecordBatch, name: &str) -> Option<&'a
Arc<dyn Array>> {
+ batch
+ .schema()
+ .index_of(name)
+ .ok()
+ .map(|idx| batch.column(idx))
+ }
+
+ fn collect_i32(batches: &[RecordBatch], name: &str) -> Vec<i32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+
+ fn collect_i64(batches: &[RecordBatch], name: &str) -> Vec<i64> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+
+ fn collect_f32(batches: &[RecordBatch], name: &str) -> Vec<f32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ column_by_name(b, name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+ }
+
+ #[tokio::test]
+ async fn test_reads_selected_positions_with_position_column() {
+ // rows id=[10,11,12,13,14], first_row_id=0, select positions [0,2,4]
+ // -> output ids [10,12,14], _PKEY_VECTOR_POSITION [0,2,4], ascending;
+ // no _ROW_ID leak and no _PKEY_VECTOR_SCORE column.
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13,
14]));
+ let (reader, split, _dv) = build_reader_and_split(
+ "memory:/pkvpr_basic",
+ &data,
+ 5,
+ id_fields(),
+ Vec::new(),
+ &[],
+ )
+ .await;
+
+ let batches = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0, 2, 4],
+ None,
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 12, 14]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2, 4]
+ );
+ for batch in &batches {
+ assert!(
+ column_by_name(batch, ROW_ID_FIELD_NAME).is_none(),
+ "_ROW_ID must not leak into output"
+ );
+ assert!(
+ column_by_name(batch, PKEY_VECTOR_SCORE_COLUMN).is_none(),
+ "_PKEY_VECTOR_SCORE must be absent when no scores are supplied"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn test_score_alignment_non_contiguous() {
+ // select [0,2,5] with scores {0:0.9, 2:0.5, 5:0.1}
+ // -> _PKEY_VECTOR_SCORE aligned by returned position: [0.9,0.5,0.1].
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13,
14, 15]));
+ let (reader, split, _dv) = build_reader_and_split(
+ "memory:/pkvpr_scores",
+ &data,
+ 6,
+ id_fields(),
+ Vec::new(),
+ &[],
+ )
+ .await;
+
+ let scores = BTreeMap::from([(0, 0.9f32), (2, 0.5), (5, 0.1)]);
+ let batches = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0, 2, 5],
+ Some(scores),
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 12, 15]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2, 5]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.9, 0.5, 0.1]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_score_omitted_no_score_column() {
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
+ let (reader, split, _dv) = build_reader_and_split(
+ "memory:/pkvpr_noscore",
+ &data,
+ 3,
+ id_fields(),
+ Vec::new(),
+ &[],
+ )
+ .await;
+
+ let batches = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0, 1],
+ None,
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ for batch in &batches {
+ assert!(column_by_name(batch, PKEY_VECTOR_SCORE_COLUMN).is_none());
+ }
+ }
+
+ #[tokio::test]
+ async fn test_deletion_vector_skips_positions_and_keeps_alignment() {
+ // select [0,1,2,3] scores {0:.4,1:.3,2:.2,3:.1}, DV deletes position
1.
+ // -> returned positions [0,2,3], scores [.4,.2,.1]; position 1 absent.
+ // scores STILL contains key 1 (input contract), though row 1 is
deleted.
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13]));
+ let (reader, split, dv) =
+ build_reader_and_split("memory:/pkvpr_dv", &data, 4, id_fields(),
Vec::new(), &[1])
+ .await;
+
+ let scores = BTreeMap::from([(0, 0.4f32), (1, 0.3), (2, 0.2), (3,
0.1)]);
+ let batches = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ dv,
+ vec![0, 1, 2, 3],
+ Some(scores),
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(collect_i32(&batches, "id"), vec![10, 12, 13]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![0, 2, 3]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.4, 0.2, 0.1]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_multi_batch_alignment() {
+ // Three row groups [10,11] [12,13] [14,15] -> reader yields >1 batch.
+ // Select [1,2,4] spanning batch boundaries; assert position/score
+ // alignment holds across batches (row_id_offset advances correctly).
+ let data = write_mosaic_multi_group(&[
+ id_batch(vec![10, 11]),
+ id_batch(vec![12, 13]),
+ id_batch(vec![14, 15]),
+ ]);
+ let (reader, split, _dv) = build_reader_and_split(
+ "memory:/pkvpr_multibatch",
+ &data,
+ 6,
+ id_fields(),
+ Vec::new(),
+ &[],
+ )
+ .await;
+
+ let scores = BTreeMap::from([(1, 0.9f32), (2, 0.5), (4, 0.1)]);
+ let batches = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![1, 2, 4],
+ Some(scores),
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert!(
+ batches.len() > 1,
+ "expected multiple batches, got {}",
+ batches.len()
+ );
+ assert_eq!(collect_i32(&batches, "id"), vec![11, 12, 14]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![1, 2, 4]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.9, 0.5, 0.1]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_empty_positions_is_error() {
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
+ let (reader, split, _dv) = build_reader_and_split(
+ "memory:/pkvpr_empty",
+ &data,
+ 3,
+ id_fields(),
+ Vec::new(),
+ &[],
+ )
+ .await;
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ Vec::<i64>::new(),
+ None,
+ )
+ .err()
+ .expect("empty positions must be an error");
+ assert!(
+ format!("{err:?}").contains("must not be empty"),
+ "got: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_position_out_of_range_is_error() {
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
+ let (reader, split, _dv) =
+ build_reader_and_split("memory:/pkvpr_oor", &data, 3, id_fields(),
Vec::new(), &[])
+ .await;
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![5],
+ None,
+ )
+ .err()
+ .expect("out-of-range position must be an error");
+ let msg = format!("{err:?}");
+ assert!(msg.contains('5') && msg.contains('3'), "got: {msg}");
+ }
+
+ #[tokio::test]
+ async fn test_negative_position_is_error() {
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
+ let (reader, split, _dv) =
+ build_reader_and_split("memory:/pkvpr_neg", &data, 3, id_fields(),
Vec::new(), &[])
+ .await;
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![-1],
+ None,
+ )
+ .err()
+ .expect("negative position must be an error");
+ assert!(
+ format!("{err:?}").contains("must not be negative"),
+ "got: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_scores_key_mismatch_is_error() {
+ let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
+ let (reader, split, _dv) = build_reader_and_split(
+ "memory:/pkvpr_scoremismatch",
+ &data,
+ 3,
+ id_fields(),
+ Vec::new(),
+ &[],
+ )
+ .await;
+
+ // select [0,1] but scores only has key 0 -> mismatch.
+ let scores = BTreeMap::from([(0, 0.5f32)]);
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0, 1],
+ Some(scores),
+ )
+ .err()
+ .expect("score key mismatch must be an error");
+ assert!(format!("{err:?}").contains("Scores keys"), "got: {err:?}");
+ }
+
+ #[tokio::test]
+ async fn test_missing_first_row_id_is_error() {
+ // first_row_id = None -> Err mentioning first_row_id. Positions must
be
+ // valid so validation reaches the first_row_id guard.
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
"memory:/pkvpr_frid".to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 1,
+ id_fields(),
+ id_fields(),
+ Vec::new(),
+ );
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/pkvpr_frid/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file("part-0.mosaic", 1, 5, 1, None)])
+ .build()
+ .unwrap();
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0],
+ None,
+ )
+ .err()
+ .expect("missing first_row_id must be an error");
+ assert!(format!("{err:?}").contains("first_row_id"), "got: {err:?}");
+ }
+
+ #[tokio::test]
+ async fn test_predicate_reader_is_rejected() {
+ // A row-filtering predicate on the reader must be rejected.
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
"memory:/pkvpr_pred".to_string());
+ let predicate = PredicateBuilder::new(&id_fields())
+ .equal("id", Datum::Int(10))
+ .unwrap();
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 1,
+ id_fields(),
+ id_fields(),
+ vec![predicate],
+ );
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/pkvpr_pred/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file("part-0.mosaic", 1, 5, 1,
Some(0))])
+ .build()
+ .unwrap();
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0],
+ None,
+ )
+ .err()
+ .expect("predicate reader must be rejected");
+ assert!(
+ format!("{err:?}").contains("predicate-free"),
+ "got: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_reserved_column_name_conflict_is_error() {
+ // A reader whose read_type contains "_PKEY_VECTOR_POSITION" must be
rejected.
+ let reserved = DataField::new(
+ 0,
+ PKEY_VECTOR_POSITION_COLUMN.to_string(),
+ DataType::Int(IntType::new()),
+ );
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let schema_manager =
+ SchemaManager::new(file_io.clone(),
"memory:/pkvpr_reserved".to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 1,
+ id_fields(),
+ vec![reserved],
+ Vec::new(),
+ );
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/pkvpr_reserved/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file("part-0.mosaic", 1, 5, 1,
Some(0))])
+ .build()
+ .unwrap();
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0],
+ None,
+ )
+ .err()
+ .expect("reserved column name conflict must be an error");
+ assert!(
+ format!("{err:?}").contains("Reserved metadata column name"),
+ "got: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_requested_row_id_column_is_rejected() {
+ // A reader whose read_type requests _ROW_ID must be rejected loudly
rather
+ // than have the column silently stripped from the output.
+ let requested_row_id = DataField::new(
+ ROW_ID_FIELD_ID,
+ ROW_ID_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::new()),
+ );
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let schema_manager =
+ SchemaManager::new(file_io.clone(),
"memory:/pkvpr_reqrowid".to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 1,
+ id_fields(),
+ vec![id_field(), requested_row_id],
+ Vec::new(),
+ );
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/pkvpr_reqrowid/bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file("part-0.mosaic", 1, 5, 1,
Some(0))])
+ .build()
+ .unwrap();
+
+ let err = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ None,
+ vec![0],
+ None,
+ )
+ .err()
+ .expect("requested _ROW_ID must be an error");
+ assert!(format!("{err:?}").contains("_ROW_ID"), "got: {err:?}");
+ }
+
+ #[test]
+ fn test_positions_to_global_ranges_coalesces_and_offsets() {
+ // positions [0,1,2,4,5] with first_row_id 100 -> global ranges
+ // [100..=102] and [104..=105] (two coalesced ranges, offset by 100).
+ let ranges = positions_to_global_ranges(&[0, 1, 2, 4, 5],
100).unwrap();
+ assert_eq!(ranges.len(), 2);
+ assert_eq!((ranges[0].from(), ranges[0].to()), (100, 102));
+ assert_eq!((ranges[1].from(), ranges[1].to()), (104, 105));
+ }
+
+ #[test]
+ fn test_positions_to_global_ranges_single() {
+ let ranges = positions_to_global_ranges(&[3], 0).unwrap();
+ assert_eq!(ranges.len(), 1);
+ assert_eq!((ranges[0].from(), ranges[0].to()), (3, 3));
+ }
+
+ #[test]
+ fn test_positions_to_global_ranges_overflow_is_error() {
+ // position 1 offset by i64::MAX overflows -> Err.
+ let err = positions_to_global_ranges(&[1], i64::MAX)
+ .expect_err("offset overflow must be an error");
+ assert!(format!("{err:?}").contains("overflows"), "got: {err:?}");
+ }
+}
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs
b/crates/paimon/src/vindex/pkvector/ann.rs
index f87e7f9..b5f26fd 100644
--- a/crates/paimon/src/vindex/pkvector/ann.rs
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -20,7 +20,7 @@ use std::sync::Arc;
use super::bucket::BucketAnnSegment;
use super::data_invalid;
-use super::metric::VectorSearchMetric;
+use super::metric::{java_float_compare, VectorSearchMetric};
use super::result::PkVectorSearchResult;
use crate::deletion_vector::DeletionVector;
use crate::spec::{PkVectorSourceFile, PkVectorSourceMeta};
@@ -119,8 +119,7 @@ pub(crate) fn map_ann_results(
});
}
results.sort_by(|a, b| {
- a.distance
- .total_cmp(&b.distance)
+ java_float_compare(a.distance, b.distance)
.then_with(|| a.data_file_name.cmp(&b.data_file_name))
.then_with(|| a.row_position.cmp(&b.row_position))
});
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs
b/crates/paimon/src/vindex/pkvector/bucket.rs
index 23a2b1c..18fd8f8 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -22,7 +22,7 @@ use std::sync::Arc;
use super::ann::PkVectorAnnSearcher;
use super::data_invalid;
use super::exact::exact_search;
-use super::metric::VectorSearchMetric;
+use super::metric::{java_float_compare, VectorSearchMetric};
use super::reader::PkVectorReader;
use super::result::PkVectorSearchResult;
use crate::deletion_vector::DeletionVector;
@@ -43,10 +43,10 @@ pub(crate) struct BucketActiveFile {
}
/// 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.
+/// then row_position ASC. `java_float_compare` sorts NaN distances last (never
+/// best), matching Java `Float.compare`, and is panic-free.
fn best_first(a: &PkVectorSearchResult, b: &PkVectorSearchResult) -> Ordering {
- a.distance
- .total_cmp(&b.distance)
+ java_float_compare(a.distance, b.distance)
.then_with(|| a.data_file_name.cmp(&b.data_file_name))
.then_with(|| a.row_position.cmp(&b.row_position))
}
@@ -314,6 +314,50 @@ mod tests {
);
}
+ #[test]
+ fn nan_ann_hit_never_evicts_finite_candidate_from_top1() {
+ // The core failure mode from review: an ANN hit with a negative-NaN
+ // distance must not win the single bucket Top-1 slot over a finite
hit.
+ // Under f32::total_cmp the -NaN would rank best and evict the finite
+ // candidate here in the bucket heap, before any cross-bucket merge.
+ let negative_nan = f32::from_bits(0xffc00000);
+ assert!(negative_nan.is_nan());
+ let segment = BucketAnnSegment {
+ source_meta: meta(&[("data-1", 2)]),
+ };
+ let ann = FakeAnnSearcher {
+ result: vec![
+ PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 0,
+ distance: negative_nan,
+ },
+ PkVectorSearchResult {
+ data_file_name: "data-1".into(),
+ row_position: 1,
+ distance: -1.0,
+ },
+ ],
+ };
+ let mut factory =
+ |_: &BucketActiveFile| -> crate::Result<Box<dyn PkVectorReader>> {
unreachable!() };
+ let results = bucket_search(
+ Some(&ann),
+ &[segment],
+ &[active("data-1", 2)],
+ &HashMap::new(),
+ &mut factory,
+ &[0.0, 0.0],
+ VectorSearchMetric::L2,
+ 1,
+ &HashMap::new(),
+ )
+ .unwrap();
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].row_position, 1);
+ assert_eq!(results[0].distance, -1.0);
+ }
+
#[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
diff --git a/crates/paimon/src/vindex/pkvector/exact.rs
b/crates/paimon/src/vindex/pkvector/exact.rs
index c9c0480..e5d043c 100644
--- a/crates/paimon/src/vindex/pkvector/exact.rs
+++ b/crates/paimon/src/vindex/pkvector/exact.rs
@@ -19,14 +19,15 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use super::data_invalid;
-use super::metric::VectorSearchMetric;
+use super::metric::{java_float_compare, 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).
+/// top therefore evicts the least-wanted candidate. Uses `java_float_compare`
+/// for a deterministic total order over f32 that ranks NaN distances as worst
+/// (largest), so a NaN is evicted before any finite candidate (no panic).
struct WorstFirst(PkVectorSearchResult);
impl PartialEq for WorstFirst {
@@ -42,9 +43,7 @@ impl PartialOrd for WorstFirst {
}
impl Ord for WorstFirst {
fn cmp(&self, other: &Self) -> Ordering {
- self.0
- .distance
- .total_cmp(&other.0.distance)
+ java_float_compare(self.0.distance, other.0.distance)
.then_with(|| self.0.row_position.cmp(&other.0.row_position))
}
}
@@ -52,9 +51,7 @@ impl Ord for WorstFirst {
/// 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)
+ java_float_compare(candidate.distance, weakest.distance)
.then_with(|| candidate.row_position.cmp(&weakest.row_position))
== Ordering::Less
}
@@ -117,9 +114,7 @@ pub(crate) fn exact_search(
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))
+ java_float_compare(a.distance, b.distance).then_with(||
a.row_position.cmp(&b.row_position))
});
Ok(results)
}
@@ -156,6 +151,28 @@ mod tests {
}
}
+ #[test]
+ fn nan_distance_candidate_never_beats_finite_top1() {
+ // Row 0's stored vector contains NaN → its inner-product distance is a
+ // negative NaN (sign flipped by the metric's negation), which
+ // `f32::total_cmp` would rank as best (smallest) and select as Top-1.
+ // `java_float_compare` ranks NaN worst, so the finite row 1 wins the
+ // single Top-1 slot instead.
+ let mut reader = ArrayReader::new(2, vec![Some(vec![f32::NAN, 0.0]),
Some(vec![1.0, 0.0])]);
+ let results = exact_search(
+ "data-file",
+ &mut reader,
+ &[1.0, 0.0],
+ VectorSearchMetric::InnerProduct,
+ 1,
+ &no_exclusion(),
+ )
+ .unwrap();
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].row_position, 1);
+ assert_eq!(results[0].distance, -1.0);
+ }
+
#[test]
fn test_rejects_dimension_mismatch() {
let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 0.0])]);
diff --git a/crates/paimon/src/vindex/pkvector/metric.rs
b/crates/paimon/src/vindex/pkvector/metric.rs
index 1dda56c..799d9b8 100644
--- a/crates/paimon/src/vindex/pkvector/metric.rs
+++ b/crates/paimon/src/vindex/pkvector/metric.rs
@@ -16,6 +16,23 @@
// under the License.
use super::data_invalid;
+use std::cmp::Ordering;
+
+/// Order two distances the way Java `Float.compare` does: every NaN sorts
after
+/// all numeric values (so a NaN distance is always ranked worst, never best),
+/// while non-NaN values keep `total_cmp`'s IEEE total order (which also
matches
+/// Java in ranking `-0.0` before `+0.0`). `f32::total_cmp` alone is unsuitable
+/// here because it orders a negative NaN before every finite value, which
would
+/// let a NaN distance (e.g. from a non-finite stored vector under inner
product)
+/// win Top-1.
+pub(crate) fn java_float_compare(a: f32, b: f32) -> Ordering {
+ match (a.is_nan(), b.is_nan()) {
+ (true, true) => Ordering::Equal,
+ (true, false) => Ordering::Greater,
+ (false, true) => Ordering::Less,
+ (false, false) => a.total_cmp(&b),
+ }
+}
/// Normalize a metric name: lowercase and `-` → `_`. NO trim (deliberately
/// stricter than the build-side `vindex::normalize_metric`, to match Java
@@ -82,6 +99,21 @@ impl VectorSearchMetric {
Self::InnerProduct => -score,
}
}
+
+ /// Convert a lower-is-better canonical distance (as produced by
+ /// `bucket_search`) to a higher-is-better score. Mirrors Java
+ /// `PrimaryKeyVectorResult.score(distance)`. No clamping — natural f32
+ /// behavior (L2 with `distance=inf` -> `0.0`), consistent with the sibling
+ /// `score_to_distance`.
+ /// NOTE: cosine here is `1 - distance` applied directly to the canonical
+ /// distance; it does NOT reuse `score_to_distance`'s clamp.
+ pub(crate) fn distance_to_score(&self, distance: f32) -> f32 {
+ match self {
+ Self::L2 => 1.0 / (1.0 + distance),
+ Self::Cosine => 1.0 - distance,
+ Self::InnerProduct => -distance,
+ }
+ }
}
fn squared_l2(query: &[f32], stored: &[f32]) -> f32 {
@@ -126,6 +158,51 @@ fn cosine_distance(similarity: f32) -> f32 {
mod tests {
use super::*;
+ /// A negative NaN (sign bit set): `f32::total_cmp` would order this
*before*
+ /// every finite value; `java_float_compare` must order it *after*.
+ const NEGATIVE_NAN: f32 = f32::from_bits(0xffc00000);
+
+ #[test]
+ fn java_float_compare_sorts_all_nan_after_finite() {
+ // Positive NaN after a finite value.
+ assert_eq!(java_float_compare(f32::NAN, 1.0), Ordering::Greater);
+ assert_eq!(java_float_compare(1.0, f32::NAN), Ordering::Less);
+ // Negative NaN also after a finite value (the total_cmp trap).
+ assert!(NEGATIVE_NAN.is_nan());
+ assert_eq!(java_float_compare(NEGATIVE_NAN, 1.0), Ordering::Greater);
+ assert_eq!(java_float_compare(1.0, NEGATIVE_NAN), Ordering::Less);
+ // NaN after +inf too.
+ assert_eq!(
+ java_float_compare(f32::NAN, f32::INFINITY),
+ Ordering::Greater
+ );
+ // Two NaNs compare equal.
+ assert_eq!(java_float_compare(f32::NAN, NEGATIVE_NAN),
Ordering::Equal);
+ }
+
+ #[test]
+ fn java_float_compare_keeps_java_numeric_order() {
+ // Ascending finite order preserved.
+ assert_eq!(java_float_compare(1.0, 2.0), Ordering::Less);
+ assert_eq!(java_float_compare(2.0, 1.0), Ordering::Greater);
+ assert_eq!(java_float_compare(1.0, 1.0), Ordering::Equal);
+ // -0.0 sorts before +0.0, matching Java Float.compare.
+ assert_eq!(java_float_compare(-0.0, 0.0), Ordering::Less);
+ assert_eq!(java_float_compare(0.0, -0.0), Ordering::Greater);
+ }
+
+ #[test]
+ fn java_float_compare_ranks_negative_nan_worst_when_sorting() {
+ // A -NaN distance must never sort ahead of a finite one: sorting
ascending
+ // (smallest = best) puts finite first, NaN last.
+ let mut xs = [NEGATIVE_NAN, 3.0, 1.0, 2.0];
+ xs.sort_by(|a, b| java_float_compare(*a, *b));
+ assert_eq!(xs[0], 1.0);
+ assert_eq!(xs[1], 2.0);
+ assert_eq!(xs[2], 3.0);
+ assert!(xs[3].is_nan());
+ }
+
#[test]
fn test_normalize_lowercases_and_replaces_hyphens_without_trimming() {
assert_eq!(normalize_metric("Inner-Product"), "inner_product");
@@ -208,4 +285,33 @@ mod tests {
);
assert_eq!(VectorSearchMetric::Cosine.score_to_distance(1.0), 0.0);
}
+
+ #[test]
+ fn test_distance_to_score_per_metric_formula() {
+ // L2: 1/(1+d); Cosine: 1-d; InnerProduct: -d.
+ assert_eq!(VectorSearchMetric::L2.distance_to_score(0.0), 1.0);
+ assert_eq!(VectorSearchMetric::L2.distance_to_score(1.0), 0.5);
+ assert_eq!(VectorSearchMetric::L2.distance_to_score(3.0), 0.25);
+ assert_eq!(VectorSearchMetric::Cosine.distance_to_score(0.0), 1.0);
+ assert_eq!(VectorSearchMetric::Cosine.distance_to_score(1.0), 0.0);
+ assert_eq!(VectorSearchMetric::InnerProduct.distance_to_score(0.0),
0.0);
+ assert_eq!(
+ VectorSearchMetric::InnerProduct.distance_to_score(3.0),
+ -3.0
+ );
+ }
+
+ #[test]
+ fn test_distance_to_score_inverts_score_to_distance_off_boundary() {
+ // Avoid boundary values (no L2 score=0/distance=inf, no cosine/ip
infinities):
+ // distance_to_score(score_to_distance(s)) == s for representative s.
+ for (metric, s) in [
+ (VectorSearchMetric::L2, 0.5f32),
+ (VectorSearchMetric::Cosine, 0.25f32),
+ (VectorSearchMetric::InnerProduct, 2.0f32),
+ ] {
+ let d = metric.score_to_distance(s);
+ assert_eq!(metric.distance_to_score(d), s, "metric {metric:?}");
+ }
+ }
}