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 ebbf6ddc feat(vector-search): use data conjuncts only for the
PK-vector residual and pre-filter guard (#609)
ebbf6ddc is described below
commit ebbf6ddcf7bb076e043adb5f03c951c74a5ffe7c
Author: Junrui Lee <[email protected]>
AuthorDate: Sun Jul 26 19:41:09 2026 +0800
feat(vector-search): use data conjuncts only for the PK-vector residual and
pre-filter guard (#609)
---
crates/paimon/src/table/vector_search_builder.rs | 298 ++++++++++++++++++++---
1 file changed, 261 insertions(+), 37 deletions(-)
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 9bf00de0..6f04065f 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -26,6 +26,7 @@ use crate::spec::{
BigIntType, CoreOptions, DataField, DataType, FileKind,
GlobalIndexSearchMode, IndexFileMeta,
IndexManifest, IndexManifestEntry, Predicate, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
};
+use crate::table::bucket_filter::split_partition_and_data_predicates;
use crate::table::data_file_reader::DataFileReader;
use crate::table::global_index_scanner::{
deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
@@ -606,7 +607,7 @@ async fn plan_and_search_pk_candidates_batch(
PkVectorScanPlan,
VectorSearchMetric,
)> {
- // Residual pre-filter guard, mirroring Java `PrimaryKeyVectorScan`. A data
+ // Residual pre-filter guard, mirroring Java `PrimaryKeyVectorScan`. A DATA
// predicate set via `with_filter` is applied post-recall by re-reading
each
// candidate file's physical rows (see below). That physical-position
filtering
// only agrees with the bucket search when the table exposes physical rows
@@ -614,11 +615,26 @@ async fn plan_and_search_pk_candidates_batch(
// merge-on-read (or without deletion vectors) a read merges multiple key
// versions, so a scalar filter could retain a stale version whose live
version
// does not match — a silent wrong-read. Reject such queries rather than
answer
- // them incorrectly. No filter → nothing to guard, so the search-only and
read
- // paths are unaffected.
+ // them incorrectly.
+ //
+ // Guard on the DATA conjuncts, not the whole filter: partition-only
conjuncts
+ // are enforced entirely by scan planning (partition pruning) and produce
no
+ // per-row residual, so they need no physical-row read. This mirrors Java,
where
+ // `BatchVectorSearchBuilderImpl.withFilter` splits at the builder level
and
+ // leaves `this.filter == null` for a partition-only filter — the scan
guard is
+ // then skipped. No data predicate (partition-only or no filter) → nothing
to
+ // guard, so the search-only and read paths are unaffected.
let physical_row_read =
core.deletion_vectors_enabled() &&
!core.deletion_vectors_merge_on_read();
- if filter.is_some() && !physical_row_read {
+ let has_data_predicate = filter.is_some_and(|f| {
+ let (_partition, data) = split_partition_and_data_predicates(
+ f.clone(),
+ table.schema().fields(),
+ table.schema().partition_keys(),
+ );
+ !data.is_empty()
+ });
+ if has_data_predicate && !physical_row_read {
return Err(crate::Error::DataInvalid {
message:
"primary-key vector pre-filter requires deletion vectors
without merge-on-read"
@@ -767,44 +783,63 @@ async fn plan_and_search_pk_candidates_batch(
// Residual (post-recall) filtering: for each candidate file, re-read its
// physical rows and keep the positions whose rows satisfy the filter. The
// per-split allow-list is threaded into the bucket search so the residual
folds
- // into recall (best-first order and Top-K are preserved). Built only when
a
- // filter is set; otherwise `None` leaves the search unfiltered. The
residual
- // depends only on the filter and the plan, not the query vector, so it is
- // computed once here and shared across every query in the batch. The
residual
- // reader projects only the predicate columns and carries no pushdown;
- // `residual_positions_by_file` recovers each surviving row's file-local
- // physical position from its ordinal in the unfiltered scan (no
`_ROW_ID`, no
- // `first_row_id`). A file the allow-list leaves empty is skipped by the
bucket
- // search without opening an exact reader.
+ // into recall (best-first order and Top-K are preserved). Built only when
the
+ // filter has data (non-partition) conjuncts; a partition-only filter (or
no
+ // filter) leaves `None`, which leaves the search unfiltered — partition
+ // pruning is already handled in planning. The residual depends only on the
+ // filter and the plan, not the query vector, so it is computed once here
and
+ // shared across every query in the batch. The residual reader projects
only
+ // the predicate columns and carries no pushdown;
`residual_positions_by_file`
+ // recovers each surviving row's file-local physical position from its
ordinal
+ // in the unfiltered scan (no `_ROW_ID`, no `first_row_id`). A file the
+ // allow-list leaves empty is skipped by the bucket search without opening
an
+ // exact reader.
let residual_by_split: Option<Vec<HashMap<String, RoaringTreemap>>> =
match filter {
Some(filter) => {
- let file_predicates = FilePredicates {
- predicates: vec![filter.clone()],
- row_filter_factory: None,
- file_fields: table.schema().fields().to_vec(),
- };
- let residual_read_type = widen_scan_fields(&[],
Some(&file_predicates));
- let residual_reader = DataFileReader::new(
- table.file_io().clone(),
- table.schema_manager().clone(),
- table.schema().id(),
- table.schema().fields().to_vec(),
- residual_read_type,
- Vec::new(),
+ // The whole filter is pushed into scan planning (`PkVectorScan`),
where
+ // partition-only conjuncts already prune partitions/files.
Re-applying
+ // them as a per-row residual would be redundant, so keep only the
data
+ // conjuncts here — a partition-only filter then needs no residual
at
+ // all. Mixed partition/data conjuncts stay whole in
`data_predicates`
+ // and evaluate against the materialized partition column
(partition
+ // columns are physically present in primary-key data files), so
there
+ // is no missing-column case to reject.
+ let (_partition_predicate, data_predicates) =
split_partition_and_data_predicates(
+ filter.clone(),
+ table.schema().fields(),
+ table.schema().partition_keys(),
);
- let mut per_split = Vec::with_capacity(plan.splits.len());
- for split in &plan.splits {
- per_split.push(
- residual_positions_by_file(
- &residual_reader,
- &split.data_split,
- &split.active_files,
- &file_predicates,
- )
- .await?,
+ if data_predicates.is_empty() {
+ None
+ } else {
+ let file_predicates = FilePredicates {
+ predicates: data_predicates,
+ row_filter_factory: None,
+ file_fields: table.schema().fields().to_vec(),
+ };
+ let residual_read_type = widen_scan_fields(&[],
Some(&file_predicates));
+ let residual_reader = DataFileReader::new(
+ table.file_io().clone(),
+ table.schema_manager().clone(),
+ table.schema().id(),
+ table.schema().fields().to_vec(),
+ residual_read_type,
+ Vec::new(),
);
+ let mut per_split = Vec::with_capacity(plan.splits.len());
+ for split in &plan.splits {
+ per_split.push(
+ residual_positions_by_file(
+ &residual_reader,
+ &split.data_split,
+ &split.active_files,
+ &file_predicates,
+ )
+ .await?,
+ );
+ }
+ Some(per_split)
}
- Some(per_split)
}
None => None,
};
@@ -4493,6 +4528,77 @@ mod tests {
.unwrap()
}
+ /// The vector residual is derived from the DATA conjuncts of the filter:
+ /// partition-only conjuncts are enforced by scan planning (`PkVectorScan`
+ /// pushes the whole filter through the normal scan) and must not enter the
+ /// per-row residual, so a partition-only filter yields no residual at all.
+ #[test]
+ fn residual_uses_only_data_conjuncts_of_the_filter() {
+ use crate::spec::VarCharType;
+ use crate::table::bucket_filter::split_partition_and_data_predicates;
+
+ // Partitioned table: `dt` (partition key) + `id`.
+ let schema = Schema::builder()
+ .column("dt", DataType::VarChar(VarCharType::string_type()))
+ .column("id", DataType::Int(IntType::new()))
+ .partition_keys(["dt"])
+ .build()
+ .unwrap();
+ let ts = TableSchema::new(0, &schema);
+ let fields = ts.fields();
+ let partition_keys = ts.partition_keys();
+ let pb = PredicateBuilder::new(fields);
+
+ // Partition-only `dt = 'a'` -> no residual data predicate (residual
skipped;
+ // the partition is enforced by planning alone).
+ let (_p, data) = split_partition_and_data_predicates(
+ pb.equal("dt", Datum::String("a".to_string())).unwrap(),
+ fields,
+ partition_keys,
+ );
+ assert!(
+ data.is_empty(),
+ "partition-only filter must leave no residual data predicate"
+ );
+
+ // Data-only `id > 5` -> kept as the residual.
+ let (_p, data) = split_partition_and_data_predicates(
+ pb.greater_than("id", Datum::Int(5)).unwrap(),
+ fields,
+ partition_keys,
+ );
+ assert_eq!(data.len(), 1, "data-only filter must remain the residual");
+
+ // `dt = 'a' AND id > 5` -> only the data conjunct enters the residual.
+ let (_p, data) = split_partition_and_data_predicates(
+ Predicate::and(vec![
+ pb.equal("dt", Datum::String("a".to_string())).unwrap(),
+ pb.greater_than("id", Datum::Int(5)).unwrap(),
+ ]),
+ fields,
+ partition_keys,
+ );
+ assert_eq!(
+ data.len(),
+ 1,
+ "AND(partition, data) residual must drop the partition conjunct"
+ );
+
+ // `dt = 'a' OR id > 5` is a single mixed conjunct: it is NOT
partition-only,
+ // so it stays whole in the residual (evaluated against the
materialized
+ // partition column), rather than being dropped or split.
+ let mixed = Predicate::or(vec![
+ pb.equal("dt", Datum::String("a".to_string())).unwrap(),
+ pb.greater_than("id", Datum::Int(5)).unwrap(),
+ ]);
+ let (_p, data) = split_partition_and_data_predicates(mixed.clone(),
fields, partition_keys);
+ assert_eq!(
+ data,
+ vec![mixed],
+ "a mixed partition/data conjunct must stay whole in the residual"
+ );
+ }
+
#[tokio::test]
async fn execute_read_filter_without_deletion_vectors_fails_loud() {
let table = pk_vector_table(&[
@@ -4601,6 +4707,124 @@ mod tests {
assert!(stream.try_next().await.unwrap().is_none());
}
+ /// A partition-only `with_filter` needs no per-row residual (partition
pruning
+ /// happens in scan planning), so the deletion-vector pre-filter guard
must NOT
+ /// reject it even when deletion vectors are off. Mirrors Java, where a
+ /// partition-only filter leaves `this.filter == null` and the scan guard
is
+ /// skipped. Regression test for the guard keying on the whole filter
rather
+ /// than its data conjuncts.
+ #[tokio::test]
+ async fn
execute_read_partition_only_filter_without_deletion_vectors_passes_guard() {
+ use crate::spec::VarCharType;
+
+ // Partitioned PK-vector table, deletion vectors OFF (default).
+ let mut builder = Schema::builder()
+ .column("dt", DataType::VarChar(VarCharType::string_type()))
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .partition_keys(["dt"]);
+ for (k, v) in [
+ ("pk-vector.index.columns", "embedding"),
+ ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
+ ("fields.embedding.pk-vector.distance.metric", "l2"),
+ ("fields.embedding.dimension", "4"),
+ ] {
+ builder = builder.option(k, v);
+ }
+ let schema = builder.build().unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "pk_vector_partitioned"),
+ "memory:/pk_vector_partitioned".to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ // Partition-only `dt = 'a'`: no data residual, so the guard admits it
and
+ // (with no snapshot) the query yields an empty stream instead of the
+ // deletion-vector error.
+ let filter = PredicateBuilder::new(table.schema().fields())
+ .equal("dt", Datum::String("a".to_string()))
+ .unwrap();
+ let mut stream = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0; 4])
+ .with_limit(5)
+ .with_filter(filter)
+ .execute_read()
+ .await
+ .expect("partition-only filter must be admitted without deletion
vectors");
+ assert!(stream.try_next().await.unwrap().is_none());
+
+ // But a DATA conjunct (`id > 2`) on the same non-DV table must still
fail
+ // loud — the guard now keys on data predicates, not the whole filter.
+ let data_filter = id_gt_filter(&table, 2);
+ let err = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0; 4])
+ .with_limit(5)
+ .with_filter(data_filter)
+ .execute_read()
+ .await
+ .map(|_| ())
+ .expect_err("data filter without deletion vectors must still fail
loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. }
+ if message.contains("deletion vectors without merge-on-read")),
+ "unexpected error: {err:?}"
+ );
+
+ // `AND(partition, data)` still has a data conjunct after the split,
so it
+ // must fail loud on the non-DV table just like the data-only filter.
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let and_filter = Predicate::and(vec![
+ pb.equal("dt", Datum::String("a".to_string())).unwrap(),
+ pb.greater_than("id", Datum::Int(2)).unwrap(),
+ ]);
+ let err = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0; 4])
+ .with_limit(5)
+ .with_filter(and_filter)
+ .execute_read()
+ .await
+ .map(|_| ())
+ .expect_err("AND(partition, data) without deletion vectors must
fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. }
+ if message.contains("deletion vectors without merge-on-read")),
+ "unexpected error: {err:?}"
+ );
+
+ // A mixed `OR(partition, data)` conjunct is not partition-only, so it
stays
+ // whole as a data predicate and must also fail loud without deletion
vectors.
+ let or_filter = Predicate::or(vec![
+ pb.equal("dt", Datum::String("a".to_string())).unwrap(),
+ pb.greater_than("id", Datum::Int(2)).unwrap(),
+ ]);
+ let err = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0; 4])
+ .with_limit(5)
+ .with_filter(or_filter)
+ .execute_read()
+ .await
+ .map(|_| ())
+ .expect_err("mixed OR(partition, data) without deletion vectors
must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. }
+ if message.contains("deletion vectors without merge-on-read")),
+ "unexpected error: {err:?}"
+ );
+ }
+
fn make_lumina_entry(
file_name: &str,
index_type: &str,