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 6d3fa369 vector-search: support scalar pre-filter before Top-K (#760)
6d3fa369 is described below
commit 6d3fa369262cf9ea70839d3a108f040138078aca
Author: shyjsarah <[email protected]>
AuthorDate: Tue Sep 1 16:31:20 2026 +0800
vector-search: support scalar pre-filter before Top-K (#760)
---
.../integrations/datafusion/src/filter_pushdown.rs | 35 +
.../datafusion/src/lateral_vector_search.rs | 627 +++++++++++++-
.../integrations/datafusion/src/vector_search.rs | 84 +-
.../integrations/datafusion/tests/read_tables.rs | 223 +++++
crates/paimon/src/io/file_io.rs | 20 +
crates/paimon/src/lumina/reader.rs | 389 ++++++++-
crates/paimon/src/table/mod.rs | 53 +-
crates/paimon/src/table/vector_search_builder.rs | 916 ++++++++++++++++++---
crates/paimon/src/vector_search.rs | 18 +
crates/paimon/src/vindex/reader.rs | 140 +++-
crates/paimon/tests/pk_vector_batch_test.rs | 23 +-
docs/src/sql.md | 48 ++
12 files changed, 2362 insertions(+), 214 deletions(-)
diff --git a/crates/integrations/datafusion/src/filter_pushdown.rs
b/crates/integrations/datafusion/src/filter_pushdown.rs
index cdbe6460..6155062e 100644
--- a/crates/integrations/datafusion/src/filter_pushdown.rs
+++ b/crates/integrations/datafusion/src/filter_pushdown.rs
@@ -89,6 +89,20 @@ pub(crate) fn analyze_filters(
}
}
+pub(crate) fn is_safe_vector_prefilter(predicate: &Predicate) -> bool {
+ match predicate {
+ Predicate::Leaf { literals, .. } => !literals.iter().any(|literal| {
+ matches!(literal, Datum::Float(value) if value.is_nan())
+ || matches!(literal, Datum::Double(value) if value.is_nan())
+ }),
+ Predicate::And(children) | Predicate::Or(children) => {
+ children.iter().all(is_safe_vector_prefilter)
+ }
+ Predicate::Not(inner) => is_safe_vector_prefilter(inner),
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => true,
+ }
+}
+
#[cfg(test)]
pub(crate) fn build_pushed_predicate(filters: &[Expr], fields: &[DataField])
-> Option<Predicate> {
analyze_filters(filters, fields, true).pushed_predicate
@@ -912,6 +926,27 @@ mod tests {
);
}
+ #[test]
+ fn test_vector_prefilter_rejects_nan_literals() {
+ let predicate = Predicate::Leaf {
+ column: "score".to_string(),
+ index: 0,
+ data_type: DataType::Float(FloatType::new()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Float(f32::NAN)],
+ };
+ assert!(!is_safe_vector_prefilter(&predicate));
+
+ let finite = Predicate::Leaf {
+ column: "score".to_string(),
+ index: 0,
+ data_type: DataType::Float(FloatType::new()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Float(f32::INFINITY)],
+ };
+ assert!(is_safe_vector_prefilter(&finite));
+ }
+
#[test]
fn test_negated_inexact_float_array_membership_falls_open() {
use datafusion::functions_nested::expr_fn::array_has;
diff --git a/crates/integrations/datafusion/src/lateral_vector_search.rs
b/crates/integrations/datafusion/src/lateral_vector_search.rs
index 30342b14..674ef2af 100644
--- a/crates/integrations/datafusion/src/lateral_vector_search.rs
+++ b/crates/integrations/datafusion/src/lateral_vector_search.rs
@@ -17,10 +17,14 @@
use std::any::Any;
use std::cmp::Ordering;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
-use std::sync::Arc;
+use std::pin::Pin;
+use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
+use std::sync::{Arc, Mutex, Weak};
+use std::task::{Context, Poll};
+use std::time::Duration;
use async_trait::async_trait;
use datafusion::arrow::array::{
@@ -37,23 +41,28 @@ use datafusion::common::{
use datafusion::datasource::TableProvider;
use datafusion::execution::context::{QueryPlanner, SessionState};
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
-use datafusion::logical_expr::{Expr, Extension, LogicalPlan, TableScan,
UserDefinedLogicalNode};
+use datafusion::logical_expr::utils::{conjunction, split_conjunction};
+use datafusion::logical_expr::{
+ Expr, Extension, Filter, LogicalPlan, Projection, TableScan,
UserDefinedLogicalNode,
+};
use datafusion::optimizer::{ApplyOrder, Optimizer, OptimizerConfig,
OptimizerRule};
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties,
Partitioning,
- PlanProperties,
+ PlanProperties, RecordBatchStream,
};
use datafusion::physical_planner::{DefaultPhysicalPlanner, ExtensionPlanner,
PhysicalPlanner};
use datafusion::prelude::SessionConfig;
-use futures::{StreamExt, TryStreamExt};
-use paimon::spec::ROW_ID_FIELD_NAME;
-use paimon::table::{RowRange, Table};
+use futures::{Stream, StreamExt, TryStreamExt};
+use paimon::spec::{Predicate, ROW_ID_FIELD_NAME};
+use paimon::table::{PreparedVectorSearchFilter, RowRange, Table};
use paimon::vector_search::SearchResult;
+use tokio::sync::OnceCell;
use crate::error::to_datafusion_error;
+use crate::filter_pushdown::{analyze_filters, is_safe_vector_prefilter};
use crate::vector_search::LateralVectorSearchTableProvider;
#[derive(Debug)]
@@ -114,6 +123,84 @@ impl OptimizerRule for RewriteLateralVectorSearch {
plan: LogicalPlan,
_config: &dyn OptimizerConfig,
) -> DFResult<Transformed<LogicalPlan>> {
+ if let LogicalPlan::Filter(filter) = plan {
+ let (extension, projection) = match filter.input.as_ref() {
+ LogicalPlan::Extension(extension) => (extension, None),
+ LogicalPlan::Projection(projection)
+ if projection
+ .expr
+ .iter()
+ .all(|expr| matches!(expr, Expr::Column(_))) =>
+ {
+ let LogicalPlan::Extension(extension) =
projection.input.as_ref() else {
+ return
Ok(Transformed::no(LogicalPlan::Filter(filter)));
+ };
+ (extension, Some(projection))
+ }
+ _ => return Ok(Transformed::no(LogicalPlan::Filter(filter))),
+ };
+ let Some(node) = extension
+ .node
+ .as_any()
+ .downcast_ref::<LateralVectorSearchNode>()
+ else {
+ return Ok(Transformed::no(LogicalPlan::Filter(filter)));
+ };
+ let mut target_predicates = Vec::new();
+ let mut residual_predicates = Vec::new();
+ for conjunct in split_conjunction(&filter.predicate) {
+ if conjunct
+ .column_refs()
+ .iter()
+ .any(|column|
node.input.schema().index_of_column(column).is_ok())
+ {
+ residual_predicates.push(conjunct.clone());
+ continue;
+ }
+ let analysis = analyze_filters(
+ std::slice::from_ref(conjunct),
+ node.target_table.schema().fields(),
+ true,
+ );
+ match analysis.pushed_predicate {
+ Some(predicate)
+ if !analysis.requires_residual &&
is_safe_vector_prefilter(&predicate) =>
+ {
+ target_predicates.push(predicate);
+ }
+ _ => {
+ residual_predicates.push(conjunct.clone());
+ }
+ }
+ }
+ if target_predicates.is_empty() {
+ return Ok(Transformed::no(LogicalPlan::Filter(filter)));
+ }
+ let predicate = Predicate::and(target_predicates);
+ let predicate = match &node.filter {
+ Some(existing) => Predicate::and(vec![existing.clone(),
predicate]),
+ None => predicate,
+ };
+ let extension = LogicalPlan::Extension(Extension {
+ node: Arc::new(node.with_filter(predicate)),
+ });
+ let rewritten = match projection {
+ Some(projection) =>
LogicalPlan::Projection(Projection::try_new_with_schema(
+ projection.expr.clone(),
+ Arc::new(extension),
+ Arc::clone(&projection.schema),
+ )?),
+ None => extension,
+ };
+ let rewritten = match conjunction(residual_predicates) {
+ Some(predicate) => {
+ LogicalPlan::Filter(Filter::try_new(predicate,
Arc::new(rewritten))?)
+ }
+ None => rewritten,
+ };
+ return Ok(Transformed::yes(rewritten));
+ }
+
let LogicalPlan::Join(join) = plan else {
return Ok(Transformed::no(plan));
};
@@ -181,6 +268,7 @@ pub(crate) struct LateralVectorSearchNode {
query_vector_expr: Expr,
limit: usize,
schema: DFSchemaRef,
+ filter: Option<Predicate>,
}
impl LateralVectorSearchNode {
@@ -201,9 +289,16 @@ impl LateralVectorSearchNode {
query_vector_expr,
limit,
schema,
+ filter: None,
}
}
+ fn with_filter(&self, filter: Predicate) -> Self {
+ let mut node = self.clone();
+ node.filter = Some(filter);
+ node
+ }
+
fn target_table(&self) -> &Table {
&self.target_table
}
@@ -253,8 +348,8 @@ impl UserDefinedLogicalNode for LateralVectorSearchNode {
fn fmt_for_explain(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
- "LateralVectorSearch: column={}, limit={}",
- self.target_column, self.limit
+ "LateralVectorSearch: column={}, limit={}, filter={:?}",
+ self.target_column, self.limit, self.filter
)
}
@@ -274,6 +369,7 @@ impl UserDefinedLogicalNode for LateralVectorSearchNode {
query_vector_expr: exprs.into_iter().next().unwrap(),
limit: self.limit,
schema: Arc::clone(&self.schema),
+ filter: self.filter.clone(),
}))
}
@@ -284,6 +380,7 @@ impl UserDefinedLogicalNode for LateralVectorSearchNode {
self.target_column.hash(&mut state);
self.query_vector_expr.hash(&mut state);
self.limit.hash(&mut state);
+ format!("{:?}", self.filter).hash(&mut state);
}
fn dyn_eq(&self, other: &dyn UserDefinedLogicalNode) -> bool {
@@ -293,6 +390,7 @@ impl UserDefinedLogicalNode for LateralVectorSearchNode {
&& self.target_column == other.target_column
&& self.query_vector_expr == other.query_vector_expr
&& self.limit == other.limit
+ && self.filter == other.filter
})
}
@@ -331,7 +429,7 @@ impl ExtensionPlanner for
LateralVectorSearchExtensionPlanner {
logical_inputs[0].schema(),
session_state,
)?;
- Ok(Some(Arc::new(LateralVectorSearchExec::new(
+ let mut exec = LateralVectorSearchExec::new(
Arc::clone(&physical_inputs[0]),
node.target_table().clone(),
Arc::clone(node.target_schema()),
@@ -339,7 +437,11 @@ impl ExtensionPlanner for
LateralVectorSearchExtensionPlanner {
query_vector_expr,
node.limit(),
Arc::new(node.schema().as_arrow().clone()),
- ))))
+ );
+ if let Some(filter) = &node.filter {
+ exec = exec.with_filter(filter.clone());
+ }
+ Ok(Some(Arc::new(exec)))
}
}
@@ -352,9 +454,251 @@ struct LateralVectorSearchExec {
query_vector_expr: Arc<dyn PhysicalExpr>,
limit: usize,
output_schema: ArrowSchemaRef,
+ filter: Option<Predicate>,
+ prepared_filter_cache: Arc<ExecutionPreparedFilterCache>,
plan_properties: Arc<PlanProperties>,
}
+#[derive(Debug)]
+struct ExecutionPreparedFilterEntry {
+ context: Weak<TaskContext>,
+ prepared_filter: Arc<OnceCell<PreparedVectorSearchFilter>>,
+ partition_count: usize,
+ completed_partitions: HashSet<usize>,
+ active_partition_leases: HashMap<usize, usize>,
+}
+
+#[derive(Debug, Default)]
+struct ExecutionPreparedFilterCache {
+ // DataFusion passes the same TaskContext Arc to every partition of one
+ // execution. Keep the prepared filter alive for that TaskContext so
+ // sequential partitions resolve the same target snapshot. DataFusion 54
+ // exposes no TaskContext drop hook, so one cache-level reaper observes the
+ // weak contexts and removes subset executions after their context dies.
+ entries: Mutex<Vec<ExecutionPreparedFilterEntry>>,
+ reaper_running: AtomicBool,
+}
+
+#[derive(Clone)]
+struct ExecutionPreparedFilterLease {
+ prepared_filter: Arc<OnceCell<PreparedVectorSearchFilter>>,
+ _completion: Arc<ExecutionPartitionCompletion>,
+}
+
+impl ExecutionPreparedFilterLease {
+ fn new(
+ cache: &Arc<ExecutionPreparedFilterCache>,
+ context: Arc<TaskContext>,
+ prepared_filter: Arc<OnceCell<PreparedVectorSearchFilter>>,
+ partition: usize,
+ ) -> Self {
+ Self {
+ prepared_filter: Arc::clone(&prepared_filter),
+ _completion: Arc::new(ExecutionPartitionCompletion {
+ cache: Arc::downgrade(cache),
+ context,
+ prepared_filter,
+ partition,
+ }),
+ }
+ }
+}
+
+struct ExecutionPartitionCompletion {
+ cache: Weak<ExecutionPreparedFilterCache>,
+ context: Arc<TaskContext>,
+ prepared_filter: Arc<OnceCell<PreparedVectorSearchFilter>>,
+ partition: usize,
+}
+
+impl Drop for ExecutionPartitionCompletion {
+ fn drop(&mut self) {
+ if let Some(cache) = self.cache.upgrade() {
+ cache.finish_partition(&self.context, &self.prepared_filter,
self.partition);
+ }
+ }
+}
+
+impl ExecutionPreparedFilterCache {
+ fn for_execution(
+ self: &Arc<Self>,
+ context: &Arc<TaskContext>,
+ partition: usize,
+ partition_count: usize,
+ ) -> ExecutionPreparedFilterLease {
+ debug_assert!(partition < partition_count);
+ let mut entries = self
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ entries.retain(|entry| entry.context.strong_count() > 0);
+ for entry in entries.iter_mut() {
+ let Some(entry_context) = entry.context.upgrade() else {
+ continue;
+ };
+ if Arc::ptr_eq(&entry_context, context) && entry.partition_count
== partition_count {
+ entry.completed_partitions.remove(&partition);
+ *entry.active_partition_leases.entry(partition).or_default()
+= 1;
+ let lease = ExecutionPreparedFilterLease::new(
+ self,
+ Arc::clone(context),
+ Arc::clone(&entry.prepared_filter),
+ partition,
+ );
+ drop(entries);
+ self.ensure_context_reaper();
+ return lease;
+ }
+ }
+
+ let prepared_filter = Arc::new(OnceCell::new());
+ let mut active_partition_leases = HashMap::new();
+ active_partition_leases.insert(partition, 1);
+ entries.push(ExecutionPreparedFilterEntry {
+ context: Arc::downgrade(context),
+ prepared_filter: Arc::clone(&prepared_filter),
+ partition_count,
+ completed_partitions: HashSet::new(),
+ active_partition_leases,
+ });
+ let lease = ExecutionPreparedFilterLease::new(
+ self,
+ Arc::clone(context),
+ prepared_filter,
+ partition,
+ );
+ drop(entries);
+ self.ensure_context_reaper();
+ lease
+ }
+
+ fn ensure_context_reaper(self: &Arc<Self>) {
+ if self.reaper_running.swap(true, AtomicOrdering::AcqRel) {
+ return;
+ }
+ let Ok(runtime) = tokio::runtime::Handle::try_current() else {
+ self.reaper_running.store(false, AtomicOrdering::Release);
+ return;
+ };
+ let cache = Arc::downgrade(self);
+ runtime.spawn(async move {
+ loop {
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ let Some(cache) = cache.upgrade() else {
+ return;
+ };
+ if cache.prune_dead_contexts() {
+ cache.reaper_running.store(false, AtomicOrdering::Release);
+ let has_entries = !cache
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .is_empty();
+ if has_entries {
+ cache.ensure_context_reaper();
+ }
+ return;
+ }
+ }
+ });
+ }
+
+ fn prune_dead_contexts(&self) -> bool {
+ let mut entries = self
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ entries.retain(|entry| entry.context.strong_count() > 0);
+ entries.is_empty()
+ }
+
+ fn finish_partition(
+ &self,
+ context: &Arc<TaskContext>,
+ prepared_filter: &Arc<OnceCell<PreparedVectorSearchFilter>>,
+ partition: usize,
+ ) {
+ let mut entries = self
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let Some(entry_index) = entries
+ .iter()
+ .position(|entry| Arc::ptr_eq(&entry.prepared_filter,
prepared_filter))
+ else {
+ return;
+ };
+ let entry = &mut entries[entry_index];
+ let Some(active_leases) =
entry.active_partition_leases.get_mut(&partition) else {
+ return;
+ };
+ *active_leases -= 1;
+ if *active_leases == 0 {
+ entry.active_partition_leases.remove(&partition);
+ entry.completed_partitions.insert(partition);
+ }
+ if entry.active_partition_leases.is_empty()
+ && (Arc::strong_count(context) == 1
+ || entry.completed_partitions.len() == entry.partition_count)
+ {
+ entries.remove(entry_index);
+ }
+ }
+}
+
+struct ExecutionScopedStream {
+ schema: ArrowSchemaRef,
+ inner: Option<SendableRecordBatchStream>,
+ lease: Option<ExecutionPreparedFilterLease>,
+}
+
+impl ExecutionScopedStream {
+ fn new(
+ schema: ArrowSchemaRef,
+ inner: SendableRecordBatchStream,
+ lease: ExecutionPreparedFilterLease,
+ ) -> Self {
+ Self {
+ schema,
+ inner: Some(inner),
+ lease: Some(lease),
+ }
+ }
+
+ fn finish(&mut self) {
+ drop(self.inner.take());
+ drop(self.lease.take());
+ }
+}
+
+impl Stream for ExecutionScopedStream {
+ type Item = DFResult<RecordBatch>;
+
+ fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) ->
Poll<Option<Self::Item>> {
+ let this = self.get_mut();
+ let Some(inner) = &mut this.inner else {
+ return Poll::Ready(None);
+ };
+ let result = inner.as_mut().poll_next(cx);
+ if matches!(&result, Poll::Ready(None) | Poll::Ready(Some(Err(_)))) {
+ this.finish();
+ }
+ result
+ }
+}
+
+impl RecordBatchStream for ExecutionScopedStream {
+ fn schema(&self) -> ArrowSchemaRef {
+ Arc::clone(&self.schema)
+ }
+}
+
+impl Drop for ExecutionScopedStream {
+ fn drop(&mut self) {
+ self.finish();
+ }
+}
+
impl LateralVectorSearchExec {
fn new(
input: Arc<dyn ExecutionPlan>,
@@ -380,11 +724,22 @@ impl LateralVectorSearchExec {
query_vector_expr,
limit,
output_schema,
+ filter: None,
+ prepared_filter_cache:
Arc::new(ExecutionPreparedFilterCache::default()),
plan_properties,
}
}
- async fn process_batch(&self, batch: RecordBatch) -> DFResult<RecordBatch>
{
+ fn with_filter(mut self, filter: Predicate) -> Self {
+ self.filter = Some(filter);
+ self
+ }
+
+ async fn process_batch(
+ &self,
+ batch: RecordBatch,
+ prepared_filter: Option<&OnceCell<PreparedVectorSearchFilter>>,
+ ) -> DFResult<RecordBatch> {
if batch.num_rows() == 0 {
return empty_batch(self.output_schema.clone());
}
@@ -398,17 +753,39 @@ impl LateralVectorSearchExec {
return empty_batch(self.output_schema.clone());
}
- let mut builder = self.target_table.new_batch_vector_search_builder();
- let results = builder
+ let prepared_filter = match &self.filter {
+ Some(filter) => {
+ let prepared_filter = prepared_filter.ok_or_else(|| {
+ DataFusionError::Internal(
+ "filtered lateral vector search is missing execution
state".to_string(),
+ )
+ })?;
+ let prepared = prepared_filter
+ .get_or_try_init(|| {
+ self.target_table
+ .prepare_vector_search_filter(filter.clone())
+ })
+ .await
+ .map_err(to_datafusion_error)?;
+ Some(prepared)
+ }
+ None => None,
+ };
+ let target_table = prepared_filter
+ .map(PreparedVectorSearchFilter::table)
+ .unwrap_or(&self.target_table);
+ let mut builder = target_table.new_batch_vector_search_builder();
+ builder
.with_vector_column(&self.target_column)
.with_query_vectors(query_vectors)
- .with_limit(self.limit)
- .execute()
- .await
- .map_err(to_datafusion_error)?;
+ .with_limit(self.limit);
+ if let Some(prepared_filter) = prepared_filter {
+ builder.with_prepared_filter(prepared_filter.clone());
+ }
+ let results = builder.execute().await.map_err(to_datafusion_error)?;
let (target_batch, target_row_id_to_index) =
- read_target_rows(&self.target_table, &self.target_schema,
&results).await?;
+ read_target_rows(target_table, &self.target_schema,
&results).await?;
let mut left_indices = Vec::new();
let mut right_indices = Vec::new();
@@ -452,8 +829,8 @@ impl DisplayAs for LateralVectorSearchExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) ->
fmt::Result {
write!(
f,
- "LateralVectorSearchExec: column={}, limit={}",
- self.target_column, self.limit
+ "LateralVectorSearchExec: column={}, limit={}, filter={:?}",
+ self.target_column, self.limit, self.filter
)
}
}
@@ -478,7 +855,7 @@ impl ExecutionPlan for LateralVectorSearchExec {
if children.len() != 1 {
return internal_err!("LateralVectorSearchExec expects one child");
}
- Ok(Arc::new(Self::new(
+ let mut exec = Self::new(
children.remove(0),
self.target_table.clone(),
Arc::clone(&self.target_schema),
@@ -486,7 +863,12 @@ impl ExecutionPlan for LateralVectorSearchExec {
Arc::clone(&self.query_vector_expr),
self.limit,
Arc::clone(&self.output_schema),
- )))
+ );
+ if let Some(filter) = &self.filter {
+ exec = exec.with_filter(filter.clone());
+ }
+ exec.prepared_filter_cache = Arc::clone(&self.prepared_filter_cache);
+ Ok(Arc::new(exec))
}
fn execute(
@@ -494,19 +876,39 @@ impl ExecutionPlan for LateralVectorSearchExec {
partition: usize,
context: Arc<TaskContext>,
) -> DFResult<SendableRecordBatchStream> {
- let input = self.input.execute(partition, context)?;
+ let input = self.input.execute(partition, Arc::clone(&context))?;
+ let prepared_filter = self.filter.as_ref().map(|_| {
+ self.prepared_filter_cache.for_execution(
+ &context,
+ partition,
+ self.input.output_partitioning().partition_count(),
+ )
+ });
+ let prepared_filter_cell = prepared_filter
+ .as_ref()
+ .map(|lease| Arc::clone(&lease.prepared_filter));
let exec = self.clone();
let stream = input.then(move |batch| {
let exec = exec.clone();
+ let prepared_filter_cell = prepared_filter_cell.clone();
async move {
let batch = batch?;
- exec.process_batch(batch).await
+ exec.process_batch(batch, prepared_filter_cell.as_deref())
+ .await
}
});
- Ok(Box::pin(RecordBatchStreamAdapter::new(
+ let stream: SendableRecordBatchStream =
Box::pin(RecordBatchStreamAdapter::new(
self.output_schema.clone(),
Box::pin(stream),
- )))
+ ));
+ Ok(match prepared_filter {
+ Some(lease) => Box::pin(ExecutionScopedStream::new(
+ self.output_schema.clone(),
+ stream,
+ lease,
+ )),
+ None => stream,
+ })
}
fn partition_statistics(&self, _partition: Option<usize>) ->
DFResult<Arc<Statistics>> {
@@ -693,3 +1095,174 @@ fn empty_batch(schema: ArrowSchemaRef) ->
DFResult<RecordBatch> {
pub(crate) fn session_config() -> SessionConfig {
SessionConfig::new().with_information_schema(true)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ struct ContextHoldingStream {
+ schema: ArrowSchemaRef,
+ _context: Arc<TaskContext>,
+ }
+
+ impl Stream for ContextHoldingStream {
+ type Item = DFResult<RecordBatch>;
+
+ fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) ->
Poll<Option<Self::Item>> {
+ Poll::Pending
+ }
+ }
+
+ impl RecordBatchStream for ContextHoldingStream {
+ fn schema(&self) -> ArrowSchemaRef {
+ Arc::clone(&self.schema)
+ }
+ }
+
+ #[test]
+ fn prepared_filter_cache_releases_completed_execution() {
+ let cache = Arc::new(ExecutionPreparedFilterCache::default());
+ let context = Arc::new(TaskContext::default());
+ let first = cache.for_execution(&context, 0, 2);
+ let prepared_filter = Arc::downgrade(&first.prepared_filter);
+
+ drop(first);
+ let second = cache.for_execution(&context, 1, 2);
+ assert!(Arc::ptr_eq(
+ &prepared_filter
+ .upgrade()
+ .expect("an unfinished execution should retain its prepared
filter"),
+ &second.prepared_filter
+ ));
+
+ drop(second);
+ assert!(
+ prepared_filter.upgrade().is_none(),
+ "finishing every partition should release the prepared filter even
while the task context remains alive"
+ );
+ assert!(cache
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .is_empty());
+ }
+
+ #[test]
+ fn prepared_filter_cache_releases_subset_execution_after_context_drop() {
+ let cache = Arc::new(ExecutionPreparedFilterCache::default());
+ let context = Arc::new(TaskContext::default());
+ let lease = cache.for_execution(&context, 0, 4);
+ let prepared_filter = Arc::downgrade(&lease.prepared_filter);
+
+ drop(context);
+ drop(lease);
+
+ assert!(
+ prepared_filter.upgrade().is_none(),
+ "unstarted declared partitions must not retain a completed
execution"
+ );
+ assert!(cache
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .is_empty());
+ }
+
+ #[tokio::test]
+ async fn prepared_filter_cache_releases_when_context_drops_after_stream() {
+ let cache = Arc::new(ExecutionPreparedFilterCache::default());
+ let context = Arc::new(TaskContext::default());
+ let lease = cache.for_execution(&context, 0, 4);
+ let prepared_filter = Arc::downgrade(&lease.prepared_filter);
+
+ drop(lease);
+ assert!(
+ prepared_filter.upgrade().is_some(),
+ "the retained context must keep sequential partition reuse
possible"
+ );
+ drop(context);
+
+ tokio::time::timeout(std::time::Duration::from_secs(1), async {
+ while prepared_filter.upgrade().is_some() {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("dropping the execution context must trigger cache cleanup");
+ assert!(cache
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .is_empty());
+ }
+
+ #[test]
+ fn execution_stream_cancellation_drops_inner_before_cache_lease() {
+ let cache = Arc::new(ExecutionPreparedFilterCache::default());
+ let context = Arc::new(TaskContext::default());
+ let lease = cache.for_execution(&context, 0, 4);
+ let prepared_filter = Arc::downgrade(&lease.prepared_filter);
+ let schema = Arc::new(datafusion::arrow::datatypes::Schema::empty());
+ let inner: SendableRecordBatchStream = Box::pin(ContextHoldingStream {
+ schema: Arc::clone(&schema),
+ _context: Arc::clone(&context),
+ });
+ let stream = ExecutionScopedStream::new(schema, inner, lease);
+
+ drop(context);
+ drop(stream);
+
+ assert!(
+ prepared_filter.upgrade().is_none(),
+ "cancellation must drop the child stream's context before
releasing the cache lease"
+ );
+ }
+
+ #[test]
+ fn prepared_filter_cache_evicts_only_the_completed_execution() {
+ let cache = Arc::new(ExecutionPreparedFilterCache::default());
+ let first_context = Arc::new(TaskContext::default());
+ let second_context = Arc::new(TaskContext::default());
+ let first = cache.for_execution(&first_context, 0, 1);
+ let second = cache.for_execution(&second_context, 0, 1);
+ let first_filter = Arc::downgrade(&first.prepared_filter);
+ let second_filter = Arc::downgrade(&second.prepared_filter);
+
+ drop(first);
+
+ assert!(first_filter.upgrade().is_none());
+ assert!(
+ second_filter.upgrade().is_some(),
+ "completing one execution must not evict another execution's
filter"
+ );
+ assert_eq!(
+ cache
+ .entries
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .len(),
+ 1
+ );
+
+ drop(second);
+ assert!(second_filter.upgrade().is_none());
+ }
+
+ #[test]
+ fn prepared_filter_cache_waits_for_all_lease_clones() {
+ let cache = Arc::new(ExecutionPreparedFilterCache::default());
+ let context = Arc::new(TaskContext::default());
+ let lease = cache.for_execution(&context, 0, 1);
+ let lease_clone = lease.clone();
+ let prepared_filter = Arc::downgrade(&lease.prepared_filter);
+
+ drop(lease);
+ assert!(
+ prepared_filter.upgrade().is_some(),
+ "in-flight batch futures may retain a cloned lease"
+ );
+
+ drop(lease_clone);
+ assert!(prepared_filter.upgrade().is_none());
+ }
+}
diff --git a/crates/integrations/datafusion/src/vector_search.rs
b/crates/integrations/datafusion/src/vector_search.rs
index 5a38942f..f680800d 100644
--- a/crates/integrations/datafusion/src/vector_search.rs
+++ b/crates/integrations/datafusion/src/vector_search.rs
@@ -44,11 +44,12 @@ use datafusion::prelude::SessionContext;
use futures::{stream, TryStreamExt};
use paimon::catalog::Catalog;
use paimon::spec::{
- BigIntType, CoreOptions, DataField, DataType, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
+ BigIntType, CoreOptions, DataField, DataType, Predicate, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
};
use paimon::table::Table;
use crate::error::to_datafusion_error;
+use crate::filter_pushdown::{analyze_filters, is_safe_vector_prefilter};
use crate::runtime::{await_with_runtime, block_on_with_runtime};
use crate::table::{datafusion_read_fields, PaimonTableProvider};
use crate::table_function_args::{
@@ -262,10 +263,19 @@ impl TableProvider for VectorSearchTableProvider {
&self,
_state: &dyn Session,
projection: Option<&Vec<usize>>,
- _filters: &[Expr],
+ filters: &[Expr],
limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
let projected_schema = project_schema(&self.schema(), projection)?;
+ let filter_analysis = analyze_filters(filters,
self.inner.table().schema().fields(), true);
+ if filter_analysis.requires_residual {
+ return Err(DataFusionError::Plan(
+ "vector_search cannot apply a partially translated scalar
pre-filter".to_string(),
+ ));
+ }
+ let pushed_predicate = filter_analysis
+ .pushed_predicate
+ .filter(is_safe_vector_prefilter);
// An outer `LIMIT 0` needs no rows.
if limit == Some(0) {
@@ -278,7 +288,7 @@ impl TableProvider for VectorSearchTableProvider {
// small outer LIMIT doesn't read/materialize everything). All of this
— search,
// read and rank-order gather — runs at execution time in the exec's
stream, so
// planning / EXPLAIN stays cheap and the work is driven by the
TaskContext.
- Ok(Arc::new(VectorSearchExec::new(
+ let mut exec = VectorSearchExec::new(
self.inner.table().clone(),
self.column_name.clone(),
self.query_vector.clone(),
@@ -286,17 +296,36 @@ impl TableProvider for VectorSearchTableProvider {
limit,
projection.cloned(),
projected_schema,
- )))
+ );
+ if let Some(filter) = pushed_predicate {
+ exec = exec.with_filter(filter);
+ }
+ Ok(Arc::new(exec))
}
fn supports_filters_pushdown(
&self,
filters: &[&Expr],
) -> DFResult<Vec<TableProviderFilterPushDown>> {
- Ok(vec![
- TableProviderFilterPushDown::Unsupported;
- filters.len()
- ])
+ let fields = self.inner.table().schema().fields();
+ Ok(filters
+ .iter()
+ .map(|filter| {
+ let analysis = analyze_filters(std::slice::from_ref(*filter),
fields, true);
+ if analysis
+ .pushed_predicate
+ .as_ref()
+ .is_some_and(is_safe_vector_prefilter)
+ && !analysis.requires_residual
+ {
+ // Keep DataFusion's residual filter as a correctness
backstop
+ // while using the same predicate before vector Top-K.
+ TableProviderFilterPushDown::Inexact
+ } else {
+ TableProviderFilterPushDown::Unsupported
+ }
+ })
+ .collect())
}
}
@@ -315,6 +344,7 @@ struct VectorSearchExec {
output_limit: Option<usize>,
projection: Option<Vec<usize>>,
output_schema: ArrowSchemaRef,
+ filter: Option<Predicate>,
plan_properties: Arc<PlanProperties>,
}
@@ -342,21 +372,45 @@ impl VectorSearchExec {
output_limit,
projection,
output_schema,
+ filter: None,
plan_properties,
}
}
+ fn with_filter(mut self, filter: Predicate) -> Self {
+ self.filter = Some(filter);
+ self
+ }
+
async fn compute_batch(&self) -> DFResult<RecordBatch> {
+ let prepared_filter = match &self.filter {
+ Some(filter) => Some(
+ self.table
+ .prepare_vector_search_filter(filter.clone())
+ .await
+ .map_err(to_datafusion_error)?,
+ ),
+ None => None,
+ };
+ let search_table = prepared_filter
+ .as_ref()
+ .map(|prepared| prepared.table())
+ .unwrap_or(&self.table);
+
// Best-first row-ids from the index, searched at the full top-k so
the ANN
// recall is unchanged (data-evolution / global-index path; PK-vector
tables are
// unsupported here, as before).
let mut search_result = await_with_runtime(async {
- let mut builder = self.table.new_vector_search_builder();
+ let mut builder = self.table.new_batch_vector_search_builder();
builder
.with_vector_column(&self.column_name)
- .with_query_vector(self.query_vector.clone())
+ .with_query_vectors(vec![self.query_vector.clone()])
.with_limit(self.search_limit);
- builder.execute_scored().await.map_err(to_datafusion_error)
+ if let Some(prepared) = &prepared_filter {
+ builder.with_prepared_filter(prepared.clone());
+ }
+ let mut results =
builder.execute().await.map_err(to_datafusion_error)?;
+ Ok::<_, DataFusionError>(results.remove(0))
})
.await?;
@@ -376,10 +430,10 @@ impl VectorSearchExec {
// Read the projected columns (+ internal `_ROW_ID`); the row-range
scan yields
// file order, realigned to relevance rank below.
- let read_fields = projected_read_fields(&self.table,
self.projection.as_ref())?;
+ let read_fields = projected_read_fields(search_table,
self.projection.as_ref())?;
let row_ranges =
search_result.to_row_ranges().map_err(to_datafusion_error)?;
let batches = await_with_runtime(async {
- let mut read_builder = self.table.new_read_builder();
+ let mut read_builder = search_table.new_read_builder();
read_builder
.with_read_type(read_fields)
.with_row_ranges(row_ranges);
@@ -406,8 +460,8 @@ impl DisplayAs for VectorSearchExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) ->
fmt::Result {
write!(
f,
- "VectorSearchExec: column={}, search_limit={}, output_limit={:?}",
- self.column_name, self.search_limit, self.output_limit
+ "VectorSearchExec: column={}, search_limit={}, output_limit={:?},
filter={:?}",
+ self.column_name, self.search_limit, self.output_limit, self.filter
)
}
}
diff --git a/crates/integrations/datafusion/tests/read_tables.rs
b/crates/integrations/datafusion/tests/read_tables.rs
index e4fc4721..8756a6ce 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -1727,6 +1727,7 @@ mod vector_search_tests {
};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::datasource::MemTable;
+ use datafusion::physical_plan::ExecutionPlanProperties;
use paimon::catalog::Identifier;
use paimon::spec::{ArrayType, DataType, FloatType, IntType, Schema,
VarCharType};
use paimon::table::BranchManager;
@@ -2279,6 +2280,17 @@ mod vector_search_tests {
#[tokio::test]
async fn test_vector_search_lateral_join_uses_query_vectors() {
let (ctx, _catalog, _tmp) =
create_java_vindex_vector_search_context().await;
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.test_java_vindex_vector', \
+ index_column => 'id', \
+ index_type => 'btree')",
+ )
+ .await
+ .expect("BTree index build SQL should parse")
+ .collect()
+ .await
+ .expect("BTree index build SQL should execute");
let query_batch = build_vector_batch(
vec![10, 20],
vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]],
@@ -2303,6 +2315,192 @@ mod vector_search_tests {
let rows = extract_query_result_ids(&batches);
assert_eq!(rows, vec![(10, 0), (10, 1), (20, 1), (20, 2)]);
+
+ let filtered = ctx
+ .sql(
+ "SELECT q.id AS query_id, r.id AS result_id \
+ FROM paimon.default.queries q \
+ CROSS JOIN LATERAL
vector_search('paimon.default.test_java_vindex_vector', 'embedding',
q.embedding, 1) AS r \
+ WHERE r.id = 2 \
+ ORDER BY query_id, result_id",
+ )
+ .await
+ .expect("filtered lateral vector_search SQL should parse")
+ .collect()
+ .await
+ .expect("filtered lateral vector_search query should execute");
+ assert_eq!(
+ extract_query_result_ids(&filtered),
+ vec![(10, 2), (20, 2)],
+ "the target-side filter must be applied before each lateral Top-K"
+ );
+
+ let mixed_filter = ctx
+ .sql(
+ "SELECT q.id AS query_id, r.id AS result_id \
+ FROM paimon.default.queries q \
+ CROSS JOIN LATERAL
vector_search('paimon.default.test_java_vindex_vector', 'embedding',
q.embedding, 1) AS r \
+ WHERE q.id = 10 AND r.id = 2 \
+ ORDER BY query_id, result_id",
+ )
+ .await
+ .expect("mixed-filter lateral vector_search SQL should parse")
+ .collect()
+ .await
+ .expect("mixed-filter lateral vector_search query should execute");
+ assert_eq!(
+ extract_query_result_ids(&mixed_filter),
+ vec![(10, 2)],
+ "target-only conjuncts must be pre-filtered while left conjuncts
remain residual"
+ );
+
+ let cross_side_filter = ctx
+ .sql(
+ "SELECT q.id AS query_id, r.id AS result_id \
+ FROM paimon.default.queries q \
+ CROSS JOIN LATERAL
vector_search('paimon.default.test_java_vindex_vector', 'embedding',
q.embedding, 1) AS r \
+ WHERE r.id = 2 AND q.id = r.id * 5 \
+ ORDER BY query_id, result_id",
+ )
+ .await
+ .expect("cross-side filtered lateral vector_search SQL should
parse")
+ .collect()
+ .await
+ .expect("cross-side filtered lateral vector_search query should
execute");
+ assert_eq!(
+ extract_query_result_ids(&cross_side_filter),
+ vec![(10, 2)],
+ "cross-side conjuncts must remain residual"
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_lateral_vector_search_sequential_partitions_share_scalar_prefilter_snapshot()
{
+ let (ctx, catalog, _tmp) =
create_java_vindex_vector_search_context().await;
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.test_java_vindex_vector', \
+ index_column => 'id', \
+ index_type => 'btree')",
+ )
+ .await
+ .expect("initial BTree index build SQL should parse")
+ .collect()
+ .await
+ .expect("initial BTree index build should execute");
+
+ let first_query_batch = build_vector_batch(vec![10], vec![vec![1.0,
0.0, 0.0, 0.0]]);
+ let second_query_batch = build_vector_batch(vec![20], vec![vec![1.0,
0.0, 0.0, 0.0]]);
+ let query_table = MemTable::try_new(
+ first_query_batch.schema(),
+ vec![vec![first_query_batch], vec![second_query_batch]],
+ )
+ .expect("Failed to create partitioned query vector table");
+ ctx.register_temp_table("paimon.default.refresh_queries",
Arc::new(query_table))
+ .expect("Failed to register query vector table");
+
+ let dataframe = ctx
+ .sql(
+ "SELECT r.id \
+ FROM paimon.default.refresh_queries q \
+ CROSS JOIN LATERAL
vector_search('paimon.default.test_java_vindex_vector', 'embedding',
q.embedding, 1) AS r \
+ WHERE r.id = 6",
+ )
+ .await
+ .expect("filtered lateral vector_search SQL should parse");
+ let physical_plan = dataframe
+ .create_physical_plan()
+ .await
+ .expect("physical plan should be created");
+ assert_eq!(
+ physical_plan.output_partitioning().partition_count(),
+ 2,
+ "the regression requires sequential execution of two input
partitions"
+ );
+
+ let task_context = ctx.ctx().task_ctx();
+ let first = datafusion::physical_plan::common::collect(
+ physical_plan
+ .execute(0, Arc::clone(&task_context))
+ .expect("first partition should start"),
+ )
+ .await
+ .expect("first partition should finish");
+ assert!(extract_ids_in_order(&first).is_empty());
+
+ let identifier = Identifier::new("default", "test_java_vindex_vector");
+ let table = catalog
+ .get_table(&identifier)
+ .await
+ .expect("load vector target table");
+ let write_builder = table
+ .new_write_builder()
+ .with_commit_user("test-user")
+ .expect("configure target append");
+ let mut writer = write_builder.new_write().expect("create target
writer");
+ writer
+ .write_arrow_batch(&build_vector_batch(vec![6], vec![vec![1.0,
0.0, 0.0, 0.0]]))
+ .await
+ .expect("append target row");
+ let messages = writer
+ .prepare_commit()
+ .await
+ .expect("prepare target append");
+ write_builder
+ .new_commit()
+ .commit(messages)
+ .await
+ .expect("commit target append");
+
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.test_java_vindex_vector', \
+ index_column => 'embedding', \
+ index_type => 'ivf-flat', \
+ options =>
'ivf-flat.dimension=4,ivf-flat.nlist=1,ivf-flat.distance.metric=l2')",
+ )
+ .await
+ .expect("incremental vector index build SQL should parse")
+ .collect()
+ .await
+ .expect("incremental vector index build should execute");
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.test_java_vindex_vector', \
+ index_column => 'id', \
+ index_type => 'btree')",
+ )
+ .await
+ .expect("incremental BTree index build SQL should parse")
+ .collect()
+ .await
+ .expect("incremental BTree index build should execute");
+
+ let second = datafusion::physical_plan::common::collect(
+ physical_plan
+ .execute(1, Arc::clone(&task_context))
+ .expect("second partition should start"),
+ )
+ .await
+ .expect("second partition should finish");
+ assert!(
+ extract_ids_in_order(&second).is_empty(),
+ "sequential partitions in one execution must use the same
scalar-filter snapshot"
+ );
+
+ drop(task_context);
+ let refreshed = datafusion::physical_plan::common::collect(
+ physical_plan
+ .execute(1, ctx.ctx().task_ctx())
+ .expect("refreshed execution should start"),
+ )
+ .await
+ .expect("refreshed execution should finish");
+ assert_eq!(
+ extract_ids_in_order(&refreshed),
+ vec![6],
+ "a reused physical plan must refresh the scalar filter for a new
execution"
+ );
}
// Manual run with a local Lumina native library:
@@ -2450,6 +2648,18 @@ mod vector_search_tests {
.await
.expect("vindex index build SQL should execute");
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.vindex_build_query_e2e', \
+ index_column => 'id', \
+ index_type => 'btree')",
+ )
+ .await
+ .expect("BTree index build SQL should parse")
+ .collect()
+ .await
+ .expect("BTree index build SQL should execute");
+
let index_batches = ctx
.sql("SELECT index_type, row_count, row_range_start,
row_range_end, index_field_name FROM
paimon.default.`vindex_build_query_e2e$table_indexes` WHERE index_type =
'ivf-flat'")
.await
@@ -2475,6 +2685,19 @@ mod vector_search_tests {
.expect("vector_search query should execute");
let ids = extract_ids(&search_batches);
assert_eq!(ids, vec![0, 1]);
+
+ let filtered_batches = ctx
+ .sql("SELECT id FROM
vector_search('paimon.default.vindex_build_query_e2e', 'embedding', '[1.0,
0.0]', 2) WHERE id >= 4")
+ .await
+ .expect("filtered vector_search SQL should parse")
+ .collect()
+ .await
+ .expect("filtered vector_search query should execute");
+ assert_eq!(
+ extract_ids_in_order(&filtered_batches),
+ vec![5, 4],
+ "the scalar predicate must be applied before vector Top-K without
losing rank order"
+ );
}
}
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 1257f632..b15c1e2d 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -63,11 +63,16 @@ pub(crate) trait FileIOProvider: std::fmt::Debug + Send +
Sync {
#[derive(Clone)]
pub struct FileIO {
+ /// Private identity shared by clones, but not by independently built
storage backends.
+ storage_lineage: Arc<StorageLineage>,
storage: Arc<Storage>,
cache: Option<Arc<LocalCache>>,
provider: Option<Arc<dyn FileIOProvider>>,
}
+#[derive(Debug)]
+struct StorageLineage;
+
impl std::fmt::Debug for FileIO {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileIO")
@@ -79,6 +84,10 @@ impl std::fmt::Debug for FileIO {
}
impl FileIO {
+ pub(crate) fn shares_storage_lineage(&self, other: &Self) -> bool {
+ Arc::ptr_eq(&self.storage_lineage, &other.storage_lineage)
+ }
+
/// Attach an externally managed block cache.
///
/// `block_size` controls the aligned ranges presented to the cache.
@@ -533,6 +542,7 @@ impl FileIOBuilder {
let cache = self.cache.clone();
let storage = Storage::build(self)?;
Ok(FileIO {
+ storage_lineage: Arc::new(StorageLineage),
storage: Arc::new(storage),
cache,
provider: None,
@@ -1254,6 +1264,16 @@ mod file_action_test {
assert!(!file_io_2.exists(path).await.unwrap());
}
+ #[test]
+ fn test_storage_lineage_is_shared_only_by_file_io_clones() {
+ let file_io = setup_memory_file_io();
+ let clone = file_io.clone();
+ let independent = setup_memory_file_io();
+
+ assert!(file_io.shares_storage_lineage(&clone));
+ assert!(!file_io.shares_storage_lineage(&independent));
+ }
+
#[tokio::test]
async fn test_get_status_fs() {
let file_io = setup_fs_file_io();
diff --git a/crates/paimon/src/lumina/reader.rs
b/crates/paimon/src/lumina/reader.rs
index 25514a67..00025176 100644
--- a/crates/paimon/src/lumina/reader.rs
+++ b/crates/paimon/src/lumina/reader.rs
@@ -27,6 +27,65 @@ const MIN_SEARCH_LIST_SIZE: usize = 16;
// C ABI returns int64_t -1 for invalid results, which casts to u64::MAX in
Rust.
const SENTINEL: u64 = u64::MAX;
+trait LuminaSearch {
+ fn search(
+ &self,
+ query: &[f32],
+ n: i32,
+ k: i32,
+ distances: &mut [f32],
+ labels: &mut [u64],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()>;
+
+ #[allow(clippy::too_many_arguments)]
+ fn search_with_filter(
+ &self,
+ query: &[f32],
+ n: i32,
+ k: i32,
+ distances: &mut [f32],
+ labels: &mut [u64],
+ filter_ids: &[u64],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()>;
+
+ fn get_count(&self) -> crate::Result<u64>;
+}
+
+impl LuminaSearch for LuminaSearcher {
+ fn search(
+ &self,
+ query: &[f32],
+ n: i32,
+ k: i32,
+ distances: &mut [f32],
+ labels: &mut [u64],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ LuminaSearcher::search(self, query, n, k, distances, labels, options)
+ }
+
+ fn search_with_filter(
+ &self,
+ query: &[f32],
+ n: i32,
+ k: i32,
+ distances: &mut [f32],
+ labels: &mut [u64],
+ filter_ids: &[u64],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ LuminaSearcher::search_with_filter(
+ self, query, n, k, distances, labels, filter_ids, options,
+ )
+ }
+
+ fn get_count(&self) -> crate::Result<u64> {
+ LuminaSearcher::get_count(self)
+ }
+}
+
fn ensure_search_list_size(search_options: &mut HashMap<String, String>,
top_k: usize) {
if !search_options.contains_key("diskann.search.list_size") {
let list_size = std::cmp::max((top_k as f64 * 1.5) as usize,
MIN_SEARCH_LIST_SIZE);
@@ -285,8 +344,8 @@ impl LuminaVectorGlobalIndexReader {
}
}
-fn search_lumina(
- searcher: &LuminaSearcher,
+fn search_lumina<S: LuminaSearch + ?Sized>(
+ searcher: &S,
index_meta: &LuminaIndexMeta,
search_options_base: &HashMap<String, String>,
vector_search: &VectorSearch,
@@ -311,9 +370,9 @@ fn search_lumina(
return Ok(None);
}
- let include_row_ids = &vector_search.include_row_ids;
+ let include_row_ids = vector_search.effective_include_row_ids();
- let (distances, labels) = if let Some(ref include_ids) = include_row_ids {
+ let (distances, labels) = if let Some(include_ids) = include_row_ids {
let filter_id_list: Vec<u64> = include_ids.iter().collect();
if filter_id_list.is_empty() {
return Ok(None);
@@ -358,8 +417,8 @@ fn search_lumina(
Ok(Some(id_to_scores))
}
-fn search_lumina_batch(
- searcher: &LuminaSearcher,
+fn search_lumina_batch<S: LuminaSearch + ?Sized>(
+ searcher: &S,
index_meta: &LuminaIndexMeta,
search_options_base: &HashMap<String, String>,
vector_searches: &[VectorSearch],
@@ -367,23 +426,18 @@ fn search_lumina_batch(
if vector_searches.is_empty() {
return Ok(Vec::new());
}
- if vector_searches
- .iter()
- .any(|vector_search| vector_search.include_row_ids.is_some())
- {
- return vector_searches
- .iter()
- .map(|vector_search| {
- search_lumina(searcher, index_meta, search_options_base,
vector_search)
- })
- .collect();
- }
let limit = vector_searches[0].limit;
- if vector_searches
+ let same_limit = vector_searches
+ .iter()
+ .all(|vector_search| vector_search.limit == limit);
+ let shared_filter = same_limit
+ .then(|| shared_batch_include_row_ids(vector_searches))
+ .flatten();
+ let has_filter = vector_searches
.iter()
- .any(|vector_search| vector_search.limit != limit)
- {
+ .any(|vector_search|
vector_search.effective_include_row_ids().is_some());
+ if has_filter && shared_filter.is_none() || !same_limit {
return vector_searches
.iter()
.map(|vector_search| {
@@ -406,9 +460,18 @@ fn search_lumina_batch(
}
}
+ let filter_id_list =
+ shared_filter.map(|include_row_ids|
include_row_ids.iter().collect::<Vec<_>>());
+ if filter_id_list.as_ref().is_some_and(Vec::is_empty) {
+ return Ok(vec![None; vector_searches.len()]);
+ }
+
let index_metric = index_meta.metric()?;
let count = searcher.get_count()? as usize;
- let effective_k = std::cmp::min(limit, count);
+ let effective_k = filter_id_list.as_ref().map_or_else(
+ || std::cmp::min(limit, count),
+ |ids| std::cmp::min(std::cmp::min(limit, count), ids.len()),
+ );
if effective_k == 0 {
return Ok(vec![None; vector_searches.len()]);
}
@@ -422,14 +485,27 @@ fn search_lumina_batch(
let mut labels = new_label_buffer(vector_searches.len() * effective_k);
let mut search_opts: HashMap<String, String> = search_options_base.clone();
ensure_search_list_size(&mut search_opts, effective_k);
- searcher.search(
- &query,
- vector_searches.len() as i32,
- effective_k as i32,
- &mut distances,
- &mut labels,
- &search_opts,
- )?;
+ if let Some(filter_ids) = filter_id_list {
+ search_opts.insert("search.thread_safe_filter".to_string(),
"true".to_string());
+ searcher.search_with_filter(
+ &query,
+ vector_searches.len() as i32,
+ effective_k as i32,
+ &mut distances,
+ &mut labels,
+ &filter_ids,
+ &search_opts,
+ )?;
+ } else {
+ searcher.search(
+ &query,
+ vector_searches.len() as i32,
+ effective_k as i32,
+ &mut distances,
+ &mut labels,
+ &search_opts,
+ )?;
+ }
let mut results = Vec::with_capacity(vector_searches.len());
for query_index in 0..vector_searches.len() {
@@ -450,6 +526,22 @@ fn search_lumina_batch(
Ok(results)
}
+fn shared_batch_include_row_ids(
+ vector_searches: &[VectorSearch],
+) -> Option<&std::sync::Arc<roaring::RoaringTreemap>> {
+ let first = vector_searches.first()?.shared_include_row_ids.as_ref()?;
+ vector_searches
+ .iter()
+ .skip(1)
+ .all(|vector_search| {
+ vector_search
+ .shared_include_row_ids
+ .as_ref()
+ .is_some_and(|include_row_ids| std::sync::Arc::ptr_eq(first,
include_row_ids))
+ })
+ .then_some(first)
+}
+
fn write_temp_index_file<S: Read + Seek>(stream: &mut S) ->
crate::Result<PathBuf> {
stream
.seek(SeekFrom::Start(0))
@@ -498,8 +590,247 @@ impl Drop for LuminaVectorGlobalIndexReader {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::lumina::{KEY_DIMENSION, KEY_DISTANCE_METRIC};
use crate::vector_search::GlobalIndexIOMeta;
use std::io::Cursor;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex};
+
+ #[derive(Debug, PartialEq)]
+ struct FilteredSearchCall {
+ query: Vec<f32>,
+ n: i32,
+ k: i32,
+ filter_ids: Vec<u64>,
+ }
+
+ struct RecordingSearcher {
+ count: u64,
+ count_calls: AtomicUsize,
+ unfiltered_calls: Mutex<Vec<(Vec<f32>, i32, i32)>>,
+ filtered_calls: Mutex<Vec<FilteredSearchCall>>,
+ }
+
+ impl RecordingSearcher {
+ fn new(count: u64) -> Self {
+ Self {
+ count,
+ count_calls: AtomicUsize::new(0),
+ unfiltered_calls: Mutex::new(Vec::new()),
+ filtered_calls: Mutex::new(Vec::new()),
+ }
+ }
+ }
+
+ impl LuminaSearch for RecordingSearcher {
+ fn search(
+ &self,
+ query: &[f32],
+ n: i32,
+ k: i32,
+ distances: &mut [f32],
+ labels: &mut [u64],
+ _options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ self.unfiltered_calls
+ .lock()
+ .expect("unfiltered call lock")
+ .push((query.to_vec(), n, k));
+ for (index, (distance, label)) in
+ distances.iter_mut().zip(labels.iter_mut()).enumerate()
+ {
+ *distance = index as f32;
+ *label = index as u64;
+ }
+ Ok(())
+ }
+
+ fn search_with_filter(
+ &self,
+ query: &[f32],
+ n: i32,
+ k: i32,
+ distances: &mut [f32],
+ labels: &mut [u64],
+ filter_ids: &[u64],
+ _options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ self.filtered_calls
+ .lock()
+ .expect("filtered call lock")
+ .push(FilteredSearchCall {
+ query: query.to_vec(),
+ n,
+ k,
+ filter_ids: filter_ids.to_vec(),
+ });
+ for (index, (distance, label)) in
+ distances.iter_mut().zip(labels.iter_mut()).enumerate()
+ {
+ *distance = index as f32;
+ *label = filter_ids[index % filter_ids.len()];
+ }
+ Ok(())
+ }
+
+ fn get_count(&self) -> crate::Result<u64> {
+ self.count_calls.fetch_add(1, Ordering::Relaxed);
+ Ok(self.count)
+ }
+ }
+
+ fn test_index_meta(dim: usize) -> LuminaIndexMeta {
+ LuminaIndexMeta::new(HashMap::from([
+ (KEY_DIMENSION.to_string(), dim.to_string()),
+ (KEY_DISTANCE_METRIC.to_string(), "l2".to_string()),
+ ]))
+ }
+
+ #[test]
+ fn test_shared_filter_uses_one_lumina_batch_search() {
+ let searcher = RecordingSearcher::new(10);
+ let shared_filter = Arc::new(roaring::RoaringTreemap::from_iter([2, 4,
6]));
+ let mut first = VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap();
+ first.set_shared_include_row_ids(Arc::clone(&shared_filter));
+ let mut second = VectorSearch::new(vec![0.0, 1.0], 2,
"embedding".to_string()).unwrap();
+ second.set_shared_include_row_ids(Arc::clone(&shared_filter));
+
+ let results = search_lumina_batch(
+ &searcher,
+ &test_index_meta(2),
+ &HashMap::new(),
+ &[first, second],
+ )
+ .expect("shared filtered batch search should succeed");
+
+ assert_eq!(results.len(), 2);
+ assert!(searcher
+ .unfiltered_calls
+ .lock()
+ .expect("unfiltered call lock")
+ .is_empty());
+ assert_eq!(
+ *searcher.filtered_calls.lock().expect("filtered call lock"),
+ vec![FilteredSearchCall {
+ query: vec![1.0, 0.0, 0.0, 1.0],
+ n: 2,
+ k: 2,
+ filter_ids: vec![2, 4, 6],
+ }]
+ );
+ }
+
+ #[test]
+ fn test_equal_but_distinct_filters_keep_scalar_fallback() {
+ let searcher = RecordingSearcher::new(10);
+ let mut first = VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap();
+
first.set_shared_include_row_ids(Arc::new(roaring::RoaringTreemap::from_iter([2,
4, 6])));
+ let mut second = VectorSearch::new(vec![0.0, 1.0], 2,
"embedding".to_string()).unwrap();
+
second.set_shared_include_row_ids(Arc::new(roaring::RoaringTreemap::from_iter([2,
4, 6])));
+
+ search_lumina_batch(
+ &searcher,
+ &test_index_meta(2),
+ &HashMap::new(),
+ &[first, second],
+ )
+ .expect("distinct filtered searches should succeed");
+
+ let calls = searcher.filtered_calls.lock().expect("filtered call
lock");
+ assert_eq!(calls.len(), 2);
+ assert!(calls.iter().all(|call| call.n == 1));
+ }
+
+ #[test]
+ fn test_mixed_filters_and_limits_keep_scalar_fallback() {
+ let shared_filter = Arc::new(roaring::RoaringTreemap::from_iter([2, 4,
6]));
+ let mut filtered = VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap();
+ filtered.set_shared_include_row_ids(Arc::clone(&shared_filter));
+ let unfiltered = VectorSearch::new(vec![0.0, 1.0], 2,
"embedding".to_string()).unwrap();
+ let mixed_searcher = RecordingSearcher::new(10);
+
+ search_lumina_batch(
+ &mixed_searcher,
+ &test_index_meta(2),
+ &HashMap::new(),
+ &[filtered, unfiltered],
+ )
+ .expect("mixed filtered searches should succeed");
+
+ assert_eq!(
+ mixed_searcher
+ .filtered_calls
+ .lock()
+ .expect("filtered call lock")
+ .len(),
+ 1
+ );
+ assert_eq!(
+ mixed_searcher
+ .unfiltered_calls
+ .lock()
+ .expect("unfiltered call lock")
+ .len(),
+ 1
+ );
+
+ let mut first = VectorSearch::new(vec![1.0, 0.0], 1,
"embedding".to_string()).unwrap();
+ first.set_shared_include_row_ids(Arc::clone(&shared_filter));
+ let mut second = VectorSearch::new(vec![0.0, 1.0], 2,
"embedding".to_string()).unwrap();
+ second.set_shared_include_row_ids(shared_filter);
+ let differing_limit_searcher = RecordingSearcher::new(10);
+
+ search_lumina_batch(
+ &differing_limit_searcher,
+ &test_index_meta(2),
+ &HashMap::new(),
+ &[first, second],
+ )
+ .expect("differing-limit filtered searches should succeed");
+
+ let calls = differing_limit_searcher
+ .filtered_calls
+ .lock()
+ .expect("filtered call lock");
+ assert_eq!(calls.len(), 2);
+ assert_eq!(calls.iter().map(|call| call.k).collect::<Vec<_>>(), [1,
2]);
+ assert!(calls.iter().all(|call| call.n == 1));
+ }
+
+ #[test]
+ fn test_empty_shared_filter_skips_native_search() {
+ let searcher = RecordingSearcher::new(10);
+ let shared_filter = Arc::new(roaring::RoaringTreemap::new());
+ let mut first = VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap();
+ first.set_shared_include_row_ids(Arc::clone(&shared_filter));
+ let mut second = VectorSearch::new(vec![0.0, 1.0], 2,
"embedding".to_string()).unwrap();
+ second.set_shared_include_row_ids(Arc::clone(&shared_filter));
+
+ let results = search_lumina_batch(
+ &searcher,
+ &test_index_meta(2),
+ &HashMap::new(),
+ &[first, second],
+ )
+ .expect("empty shared filter should succeed");
+
+ assert_eq!(results, vec![None, None]);
+ assert_eq!(
+ searcher.count_calls.load(Ordering::Relaxed),
+ 0,
+ "an empty shared filter should avoid all native searcher calls"
+ );
+ assert!(searcher
+ .unfiltered_calls
+ .lock()
+ .expect("unfiltered call lock")
+ .is_empty());
+ assert!(searcher
+ .filtered_calls
+ .lock()
+ .expect("filtered call lock")
+ .is_empty());
+ }
#[test]
fn test_convert_distance_to_score() {
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 3b0f447d..ff99fefd 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -154,13 +154,18 @@ pub use table_scan::TableScan;
pub use table_update::TableUpdate;
pub use table_write::TableWrite;
pub use tag_manager::TagManager;
-pub use vector_search_builder::{BatchVectorSearchBuilder, VectorSearchBuilder};
+pub use vector_search_builder::{
+ BatchVectorSearchBuilder, PreparedVectorSearchFilter, VectorSearchBuilder,
+};
pub use vindex_index_build_builder::VindexIndexBuildBuilder;
pub use write_builder::WriteBuilder;
use crate::catalog::{validate_branch_name, Identifier, DEFAULT_MAIN_BRANCH};
use crate::io::FileIO;
-use crate::spec::{CoreOptions, DataField, Snapshot, TableSchema};
+use crate::spec::{
+ CoreOptions, DataField, Snapshot, TableSchema, SCAN_SNAPSHOT_ID_OPTION,
SCAN_TAG_NAME_OPTION,
+ SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION, SCAN_WATERMARK_OPTION,
+};
use std::collections::HashMap;
/// Table represents a table in the catalog.
@@ -425,6 +430,50 @@ impl Table {
}
}
+ /// Create a read-only copy pinned to an already resolved snapshot.
+ ///
+ /// Replaces any selector that originally resolved the snapshot with an
+ /// explicit `scan.snapshot-id`, so every subsequent scan stage observes
the
+ /// same snapshot. The snapshot's schema is loaded when it differs from the
+ /// current table schema.
+ pub(crate) async fn copy_with_resolved_snapshot(&self, snapshot:
&Snapshot) -> Result<Self> {
+ let mut options = self.schema.options().clone();
+ for selector in [
+ SCAN_TIMESTAMP_MILLIS_OPTION,
+ SCAN_WATERMARK_OPTION,
+ SCAN_VERSION_OPTION,
+ SCAN_SNAPSHOT_ID_OPTION,
+ SCAN_TAG_NAME_OPTION,
+ ] {
+ options.remove(selector);
+ }
+ options.insert(
+ SCAN_SNAPSHOT_ID_OPTION.to_string(),
+ snapshot.id().to_string(),
+ );
+
+ let schema = if snapshot.schema_id() == self.schema.id() {
+ self.schema.copy_with_replaced_options(options)
+ } else {
+ self.schema_manager
+ .schema(snapshot.schema_id())
+ .await?
+ .copy_with_replaced_options(options)
+ };
+ Ok(Self {
+ file_io: self.file_io.clone(),
+ identifier: self.identifier.clone(),
+ location: self.location.clone(),
+ schema,
+ schema_manager: self.schema_manager.clone(),
+ branch: self.branch.clone(),
+ branch_reference: self.branch_reference,
+ rest_env: self.rest_env.clone(),
+ time_traveled: true,
+ travel_snapshot: Some(snapshot.clone()),
+ })
+ }
+
/// Create a copy of this table with extra options merged in, switching to
/// the schema of the time-travelled snapshot when the merged options
/// select one.
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index aa1fdc6d..1fc662b9 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -45,6 +45,7 @@ use crate::table::pk_vector_position_read::{
};
use crate::table::pk_vector_scan::{PkVectorScan, PkVectorScanPlan};
use crate::table::read_builder::resolve_projected_fields;
+use crate::table::row_id_predicate::intersect_sorted_ranges;
use crate::table::source::DataSplit;
use crate::table::{
find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange,
Table,
@@ -196,6 +197,34 @@ pub struct BatchVectorSearchBuilder<'a> {
options: HashMap<String, String>,
projection: Option<Vec<String>>,
filter: Option<Predicate>,
+ include_row_ids: Option<Arc<RoaringTreemap>>,
+ prepared_filter: Option<PreparedVectorSearchFilter>,
+}
+
+/// A scalar vector pre-filter resolved once against one pinned snapshot.
+///
+/// Reusing this value avoids repeating the same scalar-index/table read for
+/// every input batch of a lateral vector query.
+#[derive(Debug, Clone)]
+pub struct PreparedVectorSearchFilter {
+ table: Table,
+ include_row_ids: Arc<RoaringTreemap>,
+}
+
+impl PreparedVectorSearchFilter {
+ pub fn table(&self) -> &Table {
+ &self.table
+ }
+
+ pub fn include_row_ids(&self) -> &Arc<RoaringTreemap> {
+ &self.include_row_ids
+ }
+}
+
+fn same_vector_search_table(left: &Table, right: &Table) -> bool {
+ left.file_io().shares_storage_lineage(right.file_io())
+ && left.location().trim_end_matches('/') ==
right.location().trim_end_matches('/')
+ && left.branch() == right.branch()
}
/// The primary-key vector route's search output plus the source context a
later
@@ -249,16 +278,14 @@ impl<'a> VectorSearchBuilder<'a> {
self
}
- /// Attach a residual scalar predicate applied *after* vector recall on the
- /// primary-key vector path: each recalled candidate file is re-read and
only
- /// rows satisfying `filter` survive, folded into the search so best-first
- /// order and Top-K still hold. Mirrors Java `PrimaryKeyVectorRead`'s
- /// residual-filter support. Only the primary-key vector path consumes it,
and
- /// only when the table exposes physical rows directly (deletion vectors
- /// enabled without merge-on-read); otherwise the query fails loud. A query
- /// that does not resolve to the primary-key vector path (no PK-vector
index,
- /// or a non-PK-vector column) also fails loud rather than silently
ignoring
- /// the filter.
+ /// Attach a scalar predicate applied before vector Top-K.
+ ///
+ /// On the primary-key vector path this remains a residual allow-list over
+ /// physical positions, mirroring Java `PrimaryKeyVectorRead`. On the
+ /// data-evolution/global-index path the predicate is evaluated through a
+ /// snapshot-pinned table read (which can use scalar global indexes such as
+ /// BTree), producing global row IDs that are localized for each
vector-index
+ /// shard and passed to the vector backend as an include filter.
///
/// The whole predicate is both pushed into the scan — where it prunes
whole
/// data files by their column stats — and applied per row as a residual
over
@@ -325,27 +352,16 @@ impl<'a> VectorSearchBuilder<'a> {
}
}
- // The data-evolution (global-index) fall-through path cannot honor a
- // residual filter — it never reads physical rows. Rather than silently
- // drop the predicate and return unfiltered results, fail loud when a
- // filter is set on a query that does not resolve to the primary-key
- // vector path.
- if self.filter.is_some() {
- return Err(crate::Error::DataInvalid {
- message: "vector search filter is only supported on the
primary-key vector path"
- .to_string(),
- source: None,
- });
- }
-
let mut batch_builder = BatchVectorSearchBuilder::new(self.table);
- let mut results = batch_builder
+ batch_builder
.with_vector_column(vector_column)
.with_query_vectors(vec![query_vector.clone()])
.with_limit(limit)
- .with_options(self.options.clone())
- .execute()
- .await?;
+ .with_options(self.options.clone());
+ if let Some(filter) = &self.filter {
+ batch_builder.with_filter(filter.clone());
+ }
+ let mut results = batch_builder.execute().await?;
debug_assert_eq!(results.len(), 1);
Ok(results.remove(0))
@@ -397,7 +413,8 @@ impl<'a> VectorSearchBuilder<'a> {
// Data-evolution (global-index) vector search: materialize rows from
the
// scored global row-ids and attach the unified score column. A
non-vector
// column or a set filter fails loud inside execute_scored below.
- self.execute_de_vector_read().await
+ self.execute_de_vector_read(vector_column, query_vector, limit)
+ .await
}
/// Materialize the best-first data-evolution vector search hits into Arrow
@@ -405,21 +422,20 @@ impl<'a> VectorSearchBuilder<'a> {
/// subsequent row-range read materializes those rows, and each row's
score is
/// joined back by `_ROW_ID`. Output columns are the projected user table
/// columns (all user columns by default) plus `__paimon_search_score`;
`_ROW_ID`
- /// is always hidden. A filter is unsupported here and fails loud inside
- /// `execute_scored`.
- async fn execute_de_vector_read(&self) ->
crate::Result<ArrowRecordBatchStream> {
+ /// is always hidden. A scalar filter is applied before vector Top-K by the
+ /// snapshot-pinned scored search below.
+ async fn execute_de_vector_read(
+ &self,
+ vector_column: &str,
+ query_vector: &[f32],
+ limit: usize,
+ ) -> crate::Result<ArrowRecordBatchStream> {
// Validate the target column exists and is a vector-bearing type
before any
// work. The data-evolution search returns an empty result for an
unknown
// field (its scored-path behavior), which would make a typo'd or
scalar
// column look like a normal empty read here — violating
`execute_read`'s
// fail-loud contract (a C/Doris caller would see EOF, not an input
error).
// Reject it up front instead.
- let vector_column =
- self.vector_column
- .as_deref()
- .ok_or_else(|| crate::Error::ConfigInvalid {
- message: "Vector column must be set via
with_vector_column()".to_string(),
- })?;
let field = self
.table
.schema()
@@ -449,12 +465,25 @@ impl<'a> VectorSearchBuilder<'a> {
});
}
- let sr = self.execute_scored().await?;
-
// Resolve the projected user columns up front so an invalid projection
// fails loud even when the result is empty.
let mut read_type = self.resolve_materialize_read_type()?;
+ let Some(snapshot) =
crate::table::time_travel::resolve_snapshot(self.table).await? else {
+ return Ok(Box::pin(stream::empty()));
+ };
+ let pinned_table =
self.table.copy_with_resolved_snapshot(&snapshot).await?;
+ let mut search_builder = pinned_table.new_vector_search_builder();
+ search_builder
+ .with_vector_column(vector_column)
+ .with_query_vector(query_vector.to_vec())
+ .with_limit(limit)
+ .with_options(self.options.clone());
+ if let Some(filter) = &self.filter {
+ search_builder.with_filter(filter.clone());
+ }
+ let sr = search_builder.execute_scored().await?;
+
if sr.is_empty() {
return Ok(Box::pin(stream::empty()));
}
@@ -472,7 +501,7 @@ impl<'a> VectorSearchBuilder<'a> {
read_type.push(row_id_data_field());
}
- let mut read_builder = self.table.new_read_builder();
+ let mut read_builder = pinned_table.new_read_builder();
read_builder
.with_read_type(read_type)
.with_row_ranges(ranges);
@@ -1171,6 +1200,8 @@ impl<'a> BatchVectorSearchBuilder<'a> {
options: HashMap::new(),
projection: None,
filter: None,
+ include_row_ids: None,
+ prepared_filter: None,
}
}
@@ -1194,14 +1225,36 @@ impl<'a> BatchVectorSearchBuilder<'a> {
self
}
- /// Attach a residual scalar predicate applied *after* vector recall on the
- /// primary-key vector path, shared across every query in the batch.
Mirrors
- /// the single [`VectorSearchBuilder::with_filter`]: only the primary-key
- /// vector path (via [`execute_read`](Self::execute_read)) consumes it,
and only
- /// when the table exposes physical rows directly (deletion vectors without
- /// merge-on-read); otherwise the query fails loud.
+ /// Attach one scalar predicate shared by every query in the batch and
applied
+ /// before vector Top-K. See [`VectorSearchBuilder::with_filter`] for the
+ /// primary-key and data-evolution execution semantics.
pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
self.filter = Some(filter);
+ self.include_row_ids = None;
+ self.prepared_filter = None;
+ self
+ }
+
+ /// Attach a prepared scalar pre-filter together with the exact table
+ /// snapshot against which its row-ID allow-list was evaluated.
+ pub fn with_prepared_filter(
+ &mut self,
+ prepared_filter: PreparedVectorSearchFilter,
+ ) -> &mut Self {
+ self.prepared_filter = Some(prepared_filter);
+ self.filter = None;
+ self.include_row_ids = None;
+ self
+ }
+
+ /// Attach a caller-managed row-ID allow-list.
+ ///
+ /// This low-level API does not bind the allow-list to a table snapshot.
+ /// Prefer [`Self::with_prepared_filter`] for scalar pre-filters.
+ pub fn with_include_row_ids(&mut self, include_row_ids: RoaringTreemap) ->
&mut Self {
+ self.include_row_ids = Some(Arc::new(include_row_ids));
+ self.filter = None;
+ self.prepared_filter = None;
self
}
@@ -1217,11 +1270,31 @@ impl<'a> BatchVectorSearchBuilder<'a> {
pub async fn execute(&self) -> crate::Result<Vec<SearchResult>> {
let timing_enabled = vector_search_timing_enabled();
let total_start = timing_enabled.then(Instant::now);
- // Fail closed: like `execute_read` and the single-query builder, this
- // returns data-derived row ids/scores outside `TableScan`/`TableRead`,
- // so it must refuse a `query-auth.enabled` table before any fast path
- // (an empty snapshot would otherwise return empty results and bypass
it).
- let core = CoreOptions::new(self.table.schema().options());
+ // The builder target is authoritative for current auth/type policy.
+ // A prepared filter only pins a snapshot and may carry older options.
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+ if let Some(prepared) = &self.prepared_filter {
+ if !same_vector_search_table(self.table, prepared.table()) {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Prepared vector search filter belongs to a different
table: builder target is '{}@{}', prepared filter target is '{}@{}'",
+ self.table.location(),
+ self.table.branch(),
+ prepared.table().location(),
+ prepared.table().branch(),
+ ),
+ source: None,
+ });
+ }
+ }
+ // Check the pinned execution view as defense in depth before any fast
+ // path returns data-derived row ids/scores outside
TableScan/TableRead.
+ let execution_table = self
+ .prepared_filter
+ .as_ref()
+ .map(PreparedVectorSearchFilter::table)
+ .unwrap_or(self.table);
+ let core = CoreOptions::new(execution_table.schema().options());
core.ensure_read_authorized()?;
let vector_column =
self.vector_column
@@ -1270,20 +1343,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
}
}
- // The data-evolution (global-index) fall-through path cannot honor a
- // residual filter — it never reads physical rows. Rather than silently
- // drop the predicate and return unfiltered results, fail loud when a
- // filter is set on a batch that does not resolve to the primary-key
- // vector path, mirroring the single-query builder.
- if self.filter.is_some() {
- return Err(crate::Error::DataInvalid {
- message: "vector search filter is only supported on the
primary-key vector path"
- .to_string(),
- source: None,
- });
- }
-
- let vector_searches = query_vectors
+ let mut vector_searches = query_vectors
.iter()
.map(|vector| {
VectorSearch::new(vector.clone(), limit,
vector_column.to_string())
@@ -1291,11 +1351,19 @@ impl<'a> BatchVectorSearchBuilder<'a> {
})
.collect::<crate::Result<Vec<_>>>()?;
- let snapshot_manager = self.table.snapshot_manager();
+ if self
+ .prepared_filter
+ .as_ref()
+ .is_some_and(|prepared| prepared.include_row_ids().is_empty())
+ {
+ return Ok(vec![SearchResult::empty(); vector_searches.len()]);
+ }
+
+ let snapshot_manager = execution_table.snapshot_manager();
let setup = total_start.map_or(Duration::ZERO, |start|
start.elapsed());
let snapshot_start = timing_enabled.then(Instant::now);
- let snapshot = match
crate::table::time_travel::resolve_snapshot(self.table).await? {
+ let snapshot = match
crate::table::time_travel::resolve_snapshot(execution_table).await? {
Some(s) => s,
None => {
let snapshot = snapshot_start.map_or(Duration::ZERO, |start|
start.elapsed());
@@ -1317,12 +1385,42 @@ impl<'a> BatchVectorSearchBuilder<'a> {
}
};
let snapshot_elapsed = snapshot_start.map_or(Duration::ZERO, |start|
start.elapsed());
+ let pinned_table = match &self.prepared_filter {
+ Some(prepared) => prepared.table().clone(),
+ None => {
+ execution_table
+ .copy_with_resolved_snapshot(&snapshot)
+ .await?
+ }
+ };
+
+ if let Some(prepared) = &self.prepared_filter {
+ for search in &mut vector_searches {
+
search.set_shared_include_row_ids(Arc::clone(prepared.include_row_ids()));
+ }
+ } else if let Some(include_row_ids) = &self.include_row_ids {
+ if include_row_ids.is_empty() {
+ return Ok(vec![SearchResult::empty(); vector_searches.len()]);
+ }
+ for search in &mut vector_searches {
+ search.set_shared_include_row_ids(Arc::clone(include_row_ids));
+ }
+ } else if let Some(filter) = &self.filter {
+ let include_row_ids = matching_row_ids_for_filter(&pinned_table,
filter).await?;
+ if include_row_ids.is_empty() {
+ return Ok(vec![SearchResult::empty(); vector_searches.len()]);
+ }
+ let include_row_ids = Arc::new(include_row_ids);
+ for search in &mut vector_searches {
+
search.set_shared_include_row_ids(Arc::clone(&include_row_ids));
+ }
+ }
let manifest_start = timing_enabled.then(Instant::now);
let index_entries = match snapshot.index_manifest() {
Some(index_manifest_name) => {
let manifest_path =
snapshot_manager.manifest_path(index_manifest_name);
- IndexManifest::read(self.table.file_io(),
&manifest_path).await?
+ IndexManifest::read(execution_table.file_io(),
&manifest_path).await?
}
None => Vec::new(),
};
@@ -1331,11 +1429,11 @@ impl<'a> BatchVectorSearchBuilder<'a> {
let evaluate_start = timing_enabled.then(Instant::now);
let results = evaluate_batch_vector_search(
VectorSearchEvaluation {
- table: Some(self.table),
- file_io: self.table.file_io(),
- table_path: self.table.location(),
- table_options: self.table.schema().options(),
- schema_fields: self.table.schema().fields(),
+ table: Some(&pinned_table),
+ file_io: pinned_table.file_io(),
+ table_path: pinned_table.location(),
+ table_options: pinned_table.schema().options(),
+ schema_fields: pinned_table.schema().fields(),
next_row_id: snapshot.next_row_id(),
},
&index_entries,
@@ -1522,6 +1620,84 @@ struct VectorSearchEvaluation<'a> {
next_row_id: Option<i64>,
}
+async fn matching_row_ids_for_filter(
+ table: &Table,
+ filter: &Predicate,
+) -> crate::Result<RoaringTreemap> {
+ let mut read_builder = table.new_read_builder();
+ read_builder
+ .with_projection(&[ROW_ID_FIELD_NAME])?
+ .with_filter(filter.clone());
+ let plan = read_builder.new_scan().plan().await?;
+ let read = read_builder.new_read()?;
+ let mut stream = read.to_arrow(plan.splits())?;
+ let mut row_ids = RoaringTreemap::new();
+ while let Some(batch) = stream.try_next().await? {
+ let index =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|_| crate::Error::DataInvalid {
+ message: format!(
+ "scalar vector pre-filter read is missing
{ROW_ID_FIELD_NAME}"
+ ),
+ source: None,
+ })?;
+ let values = batch
+ .column(index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "scalar vector pre-filter {ROW_ID_FIELD_NAME} column is
not Int64"
+ ),
+ source: None,
+ })?;
+ for row in 0..values.len() {
+ if values.is_null(row) {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "scalar vector pre-filter produced a null
{ROW_ID_FIELD_NAME}"
+ ),
+ source: None,
+ });
+ }
+ let row_id = values.value(row);
+ let row_id = u64::try_from(row_id).map_err(|_|
crate::Error::DataInvalid {
+ message: format!(
+ "scalar vector pre-filter produced a negative
{ROW_ID_FIELD_NAME}: {row_id}"
+ ),
+ source: None,
+ })?;
+ row_ids.insert(row_id);
+ }
+ }
+ Ok(row_ids)
+}
+
+impl Table {
+ /// Resolve a scalar predicate once and pin all later vector-search/read
+ /// stages to the same snapshot.
+ pub async fn prepare_vector_search_filter(
+ &self,
+ filter: Predicate,
+ ) -> crate::Result<PreparedVectorSearchFilter> {
+ CoreOptions::new(self.schema().options()).ensure_read_authorized()?;
+ let Some(snapshot) =
crate::table::time_travel::resolve_snapshot(self).await? else {
+ return Ok(PreparedVectorSearchFilter {
+ table: self.clone(),
+ include_row_ids: Arc::new(RoaringTreemap::new()),
+ });
+ };
+ let table = self.copy_with_resolved_snapshot(&snapshot).await?;
+ let include_row_ids = matching_row_ids_for_filter(&table,
&filter).await?;
+ Ok(PreparedVectorSearchFilter {
+ table,
+ include_row_ids: Arc::new(include_row_ids),
+ })
+ }
+}
+
#[derive(Default)]
struct IndexSearchTiming {
permit_wait: Duration,
@@ -1634,6 +1810,40 @@ async fn evaluate_batch_vector_search(
let index_search_limit = indexed_search_limit(max_limit, refine_factor)?;
let vector_entry_count = vector_entries.len();
+ let vector_search_plans = if let Some(include_row_ids) =
+ shared_batch_include_row_ids(vector_searches)
+ {
+ let ranges = vector_entries
+ .iter()
+ .map(|entry| {
+ let meta =
entry.index_file.global_index_meta.as_ref().ok_or_else(|| {
+ crate::Error::DataInvalid {
+ message: format!(
+ "Vector index '{}' is missing global index
metadata",
+ entry.index_file.file_name
+ ),
+ source: None,
+ }
+ })?;
+ Ok((meta.row_range_start, meta.row_range_end))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ vector_entries
+ .iter()
+ .copied()
+ .zip(localize_shared_include_row_ids(
+ include_row_ids.as_ref(),
+ &ranges,
+ )?)
+ .filter_map(|(entry, local_filter)| local_filter.map(|filter|
(entry, Some(filter))))
+ .collect::<Vec<_>>()
+ } else {
+ vector_entries
+ .iter()
+ .copied()
+ .map(|entry| (entry, None))
+ .collect::<Vec<_>>()
+ };
let mut permit_wait = Duration::ZERO;
let mut file_reader_open = Duration::ZERO;
let mut index_search = Duration::ZERO;
@@ -1668,9 +1878,9 @@ async fn evaluate_batch_vector_search(
Some(RangeReadLimiter::new(range_read_concurrency)),
)
};
- let futures: Vec<_> = vector_entries
+ let futures: Vec<_> = vector_search_plans
.into_iter()
- .map(|entry| {
+ .map(|(entry, shared_local_filter)| {
let range_read_limiter = range_read_limiter.clone();
let global_meta =
entry.index_file.global_index_meta.as_ref().unwrap();
let backend =
VectorIndexBackend::from_index_type(&entry.index_file.index_type)
@@ -1696,6 +1906,37 @@ async fn evaluate_batch_vector_search(
options.extend(search_options.clone());
let input = evaluation.file_io.new_input(&path);
async move {
+ if let Some(local_filter) = shared_local_filter {
+ let local_filter = Arc::new(local_filter);
+ for vector_search in &mut vector_searches {
+ vector_search
+
.set_shared_include_row_ids(Arc::clone(&local_filter));
+ }
+ } else {
+ for vector_search in &mut vector_searches {
+ if let Some(include_row_ids) =
+ vector_search.effective_include_row_ids()
+ {
+
vector_search.set_shared_include_row_ids(Arc::new(
+ localize_include_row_ids(
+ include_row_ids,
+ row_range_start,
+ row_range_end,
+ )?,
+ ));
+ }
+ }
+ }
+ if vector_searches.iter().all(|search| {
+ search
+ .effective_include_row_ids()
+ .is_some_and(|row_ids| row_ids.is_empty())
+ }) {
+ return Ok((
+ vec![SearchResult::empty(); vector_searches.len()],
+ IndexSearchTiming::default(),
+ ));
+ }
let permit_start = timing_enabled.then(Instant::now);
let permit =
acquire_process_global_search_permit(concurrency).await?;
let permit_wait =
@@ -2569,7 +2810,7 @@ async fn maybe_rerank_indexed_batch_results(
}
let mut candidate_search = vector_search.clone();
- candidate_search.include_row_ids = Some(include_row_ids);
+ candidate_search.set_shared_include_row_ids(Arc::new(include_row_ids));
candidate_searches.push(candidate_search);
candidate_results.push(candidates);
}
@@ -2653,6 +2894,113 @@ fn row_id_to_i64_for_range(row_id: u64) ->
crate::Result<i64> {
})
}
+fn shared_batch_include_row_ids(vector_searches: &[VectorSearch]) ->
Option<&Arc<RoaringTreemap>> {
+ let first = vector_searches.first()?.shared_include_row_ids.as_ref()?;
+ vector_searches
+ .iter()
+ .skip(1)
+ .all(|search| {
+ search
+ .shared_include_row_ids
+ .as_ref()
+ .is_some_and(|include_row_ids| Arc::ptr_eq(first,
include_row_ids))
+ })
+ .then_some(first)
+}
+
+fn prune_raw_ranges_by_include_row_ids(
+ raw_ranges: &[RowRange],
+ vector_searches: &[VectorSearch],
+) -> crate::Result<Vec<RowRange>> {
+ if vector_searches
+ .iter()
+ .any(|search| search.effective_include_row_ids().is_none())
+ {
+ return Ok(raw_ranges.to_vec());
+ }
+
+ let include_ranges =
+ if let Some(include_row_ids) =
shared_batch_include_row_ids(vector_searches) {
+ sorted_row_ids_to_row_ranges(include_row_ids.iter())?
+ } else {
+ let mut union = RoaringTreemap::new();
+ for include_row_ids in vector_searches
+ .iter()
+ .filter_map(VectorSearch::effective_include_row_ids)
+ {
+ for row_id in include_row_ids.iter() {
+ union.insert(row_id);
+ }
+ }
+ sorted_row_ids_to_row_ranges(union.iter())?
+ };
+ Ok(intersect_sorted_ranges(raw_ranges, &include_ranges))
+}
+
+fn localize_include_row_ids(
+ include_row_ids: &RoaringTreemap,
+ row_range_start: i64,
+ row_range_end: i64,
+) -> crate::Result<RoaringTreemap> {
+ let start = u64::try_from(row_range_start).map_err(|_|
crate::Error::DataInvalid {
+ message: format!("Negative vector index row range start:
{row_range_start}"),
+ source: None,
+ })?;
+ let end = u64::try_from(row_range_end).map_err(|_|
crate::Error::DataInvalid {
+ message: format!("Negative vector index row range end:
{row_range_end}"),
+ source: None,
+ })?;
+ let mut localized = RoaringTreemap::new();
+ for row_id in include_row_ids.iter() {
+ if row_id >= start && row_id <= end {
+ localized.insert(row_id - start);
+ }
+ }
+ Ok(localized)
+}
+
+fn localize_shared_include_row_ids(
+ include_row_ids: &RoaringTreemap,
+ ranges: &[(i64, i64)],
+) -> crate::Result<Vec<Option<RoaringTreemap>>> {
+ let mut validated_ranges = Vec::with_capacity(ranges.len());
+ for (index, &(start, end)) in ranges.iter().enumerate() {
+ if start < 0 || end < start {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Invalid vector index row range [{start},
{end}]"),
+ source: None,
+ });
+ }
+ validated_ranges.push((start as u64, end as u64, index));
+ }
+ validated_ranges.sort_unstable_by_key(|(start, _, _)| *start);
+
+ let mut localized = (0..ranges.len())
+ .map(|_| RoaringTreemap::new())
+ .collect::<Vec<_>>();
+ let mut active = Vec::<usize>::new();
+ let mut next_range = 0usize;
+ for row_id in include_row_ids.iter() {
+ while next_range < validated_ranges.len() &&
validated_ranges[next_range].0 <= row_id {
+ active.push(next_range);
+ next_range += 1;
+ }
+ active.retain(|range_index| validated_ranges[*range_index].1 >=
row_id);
+ for range_index in &active {
+ let (start, _, original_index) = validated_ranges[*range_index];
+ localized[original_index].insert(row_id - start);
+ }
+ if next_range == validated_ranges.len() && active.is_empty() {
+ break;
+ }
+ }
+
+ Ok(localized
+ .into_iter()
+ .map(|filter| (!filter.is_empty()).then_some(filter))
+ .collect())
+}
+
async fn detail_data_ranges_for_table(table: &Table) ->
crate::Result<Vec<RowRange>> {
let plan = table
.new_read_builder()
@@ -2937,6 +3285,10 @@ async fn read_raw_batch_vector_search(
if raw_ranges.is_empty() {
return Ok((vec![SearchResult::empty(); vector_searches.len()], None));
}
+ let raw_ranges = prune_raw_ranges_by_include_row_ids(raw_ranges,
vector_searches)?;
+ if raw_ranges.is_empty() {
+ return Ok((vec![SearchResult::empty(); vector_searches.len()], None));
+ }
let field_name = &vector_searches[0].field_name;
if vector_searches
@@ -2954,7 +3306,7 @@ async fn read_raw_batch_vector_search(
let mut read_builder = table.new_read_builder();
read_builder
.with_projection(&[field_name.as_str(), ROW_ID_FIELD_NAME])?
- .with_row_ranges(raw_ranges.to_vec());
+ .with_row_ranges(raw_ranges);
let plan = read_builder.new_scan().plan().await?;
let plan_elapsed = plan_start.map_or(Duration::ZERO, |start|
start.elapsed());
let split_count = plan.splits().len();
@@ -3023,15 +3375,22 @@ async fn read_raw_batch_vector_search(
struct RawScoringPlan {
all_query_indices: Vec<usize>,
+ shared_filter_groups: Vec<SharedRawFilterGroup>,
candidate_query_indices: HashMap<u64, Vec<usize>>,
query_l2_squared_norms: Vec<f32>,
dense_query_dimension: Option<usize>,
dense_query_matrix: Option<Vec<f32>>,
}
+struct SharedRawFilterGroup {
+ include_row_ids: Arc<RoaringTreemap>,
+ query_indices: Vec<usize>,
+}
+
impl RawScoringPlan {
fn new(vector_searches: &[VectorSearch], metric: RawVectorMetric) -> Self {
let mut all_query_indices = Vec::new();
+ let mut shared_filter_groups = Vec::new();
let mut candidate_query_indices: HashMap<u64, Vec<usize>> =
HashMap::new();
let query_l2_squared_norms = vector_searches
.iter()
@@ -3045,16 +3404,23 @@ impl RawScoringPlan {
})
.collect();
- for (query_index, vector_search) in vector_searches.iter().enumerate()
{
- if let Some(include_row_ids) = &vector_search.include_row_ids {
- for row_id in include_row_ids.iter() {
- candidate_query_indices
- .entry(row_id)
- .or_default()
- .push(query_index);
+ if let Some(include_row_ids) =
shared_batch_include_row_ids(vector_searches) {
+ shared_filter_groups.push(SharedRawFilterGroup {
+ include_row_ids: Arc::clone(include_row_ids),
+ query_indices: (0..vector_searches.len()).collect(),
+ });
+ } else {
+ for (query_index, vector_search) in
vector_searches.iter().enumerate() {
+ if let Some(include_row_ids) =
vector_search.effective_include_row_ids() {
+ for row_id in include_row_ids.iter() {
+ candidate_query_indices
+ .entry(row_id)
+ .or_default()
+ .push(query_index);
+ }
+ } else {
+ all_query_indices.push(query_index);
}
- } else {
- all_query_indices.push(query_index);
}
}
@@ -3077,6 +3443,7 @@ impl RawScoringPlan {
Self {
all_query_indices,
+ shared_filter_groups,
candidate_query_indices,
query_l2_squared_norms,
dense_query_dimension,
@@ -3335,6 +3702,20 @@ fn collect_raw_batch_vector_batch(
)?;
}
}
+ for group in &scoring_plan.shared_filter_groups {
+ if group.include_row_ids.contains(row_id) {
+ for &query_index in &group.query_indices {
+ offer_raw_vector_score(
+ raw_row,
+ query_index,
+ metric,
+ vector_searches,
+ scoring_plan,
+ top_k_out,
+ )?;
+ }
+ }
+ }
}
if !dense_row_ids.is_empty() {
@@ -3707,6 +4088,14 @@ mod tests {
}
fn vector_test_table() -> Table {
+ vector_test_table_at("memory:/vector_test")
+ }
+
+ fn vector_test_table_at(location: &str) -> Table {
+
vector_test_table_with_file_io(FileIOBuilder::new("memory").build().unwrap(),
location)
+ }
+
+ fn vector_test_table_with_file_io(file_io: FileIO, location: &str) ->
Table {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column(
@@ -3716,9 +4105,9 @@ mod tests {
.build()
.unwrap();
Table::new(
- FileIOBuilder::new("memory").build().unwrap(),
+ file_io,
Identifier::new("default", "vector_test"),
- "memory:/vector_test".to_string(),
+ location.to_string(),
TableSchema::new(0, &schema),
None,
)
@@ -3747,6 +4136,101 @@ mod tests {
assert_eq!(find_field_id_by_name(&fields, "nonexistent"), None);
}
+ #[test]
+ fn shared_include_filter_is_localized_once_per_index_shard() {
+ let include_row_ids = RoaringTreemap::from_iter([101, 205, 999]);
+ let localized = localize_shared_include_row_ids(
+ &include_row_ids,
+ &[(100, 109), (200, 209), (300, 309)],
+ )
+ .unwrap();
+
+ assert_eq!(
+ localized[0].as_ref().unwrap().iter().collect::<Vec<_>>(),
+ vec![1]
+ );
+ assert_eq!(
+ localized[1].as_ref().unwrap().iter().collect::<Vec<_>>(),
+ vec![5]
+ );
+ assert!(localized[2].is_none(), "an empty shard must be skipped");
+ }
+
+ #[test]
+ fn shared_batch_include_filter_requires_the_same_arc() {
+ let shared = Arc::new(RoaringTreemap::from_iter([1, 2, 3]));
+ let mut shared_searches = vec![
+ VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap(),
+ VectorSearch::new(vec![0.0, 1.0], 2,
"embedding".to_string()).unwrap(),
+ ];
+ for search in &mut shared_searches {
+ search.set_shared_include_row_ids(Arc::clone(&shared));
+ }
+ let detected = shared_batch_include_row_ids(&shared_searches).unwrap();
+ assert!(Arc::ptr_eq(detected, &shared));
+
+ let mut equal_but_distinct = shared_searches.clone();
+ equal_but_distinct[1]
+ .set_shared_include_row_ids(Arc::new(RoaringTreemap::from_iter([1,
2, 3])));
+ assert!(shared_batch_include_row_ids(&equal_but_distinct).is_none());
+
+ let mut owned = shared_searches;
+ owned[1] = owned[1]
+ .clone()
+ .with_include_row_ids(RoaringTreemap::from_iter([1, 2, 3]));
+ assert!(shared_batch_include_row_ids(&owned).is_none());
+ }
+
+ #[test]
+ fn shared_raw_filter_does_not_expand_row_query_associations() {
+ let shared = Arc::new(RoaringTreemap::from_iter(0..1_000));
+ let mut searches = (0..128)
+ .map(|_| VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap())
+ .collect::<Vec<_>>();
+ for search in &mut searches {
+ search.set_shared_include_row_ids(Arc::clone(&shared));
+ }
+
+ let plan = RawScoringPlan::new(&searches, RawVectorMetric::L2);
+ let expanded_associations = plan
+ .candidate_query_indices
+ .values()
+ .map(Vec::len)
+ .sum::<usize>();
+
+ assert_eq!(
+ expanded_associations, 0,
+ "one shared bitmap must stay O(B + Q), not expand to O(B * Q)"
+ );
+ assert_eq!(plan.shared_filter_groups.len(), 1);
+ assert!(Arc::ptr_eq(
+ &plan.shared_filter_groups[0].include_row_ids,
+ &shared
+ ));
+ assert_eq!(plan.shared_filter_groups[0].query_indices.len(), 128);
+ }
+
+ #[test]
+ fn shared_raw_filter_prunes_unindexed_ranges_before_reading() {
+ let shared = Arc::new(RoaringTreemap::from_iter([7, 1_000, 1_001,
900_000]));
+ let mut searches = (0..128)
+ .map(|_| VectorSearch::new(vec![1.0, 0.0], 2,
"embedding".to_string()).unwrap())
+ .collect::<Vec<_>>();
+ for search in &mut searches {
+ search.set_shared_include_row_ids(Arc::clone(&shared));
+ }
+
+ let raw_ranges = vec![RowRange::new(0, 999_999)];
+ assert_eq!(
+ prune_raw_ranges_by_include_row_ids(&raw_ranges,
&searches).unwrap(),
+ vec![
+ RowRange::new(7, 7),
+ RowRange::new(1_000, 1_001),
+ RowRange::new(900_000, 900_000),
+ ]
+ );
+ }
+
#[test]
fn test_raw_vector_score_matches_java_metric_semantics() {
let l2 = compute_raw_vector_score(&[1.0, 2.0], &[1.0, 4.0],
RawVectorMetric::L2);
@@ -4608,6 +5092,34 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn prepared_filter_cannot_bypass_builder_target_query_auth() {
+ let source = vector_test_table();
+ let prepared = source
+ .prepare_vector_search_filter(id_gt_filter(&source, 0))
+ .await
+ .unwrap();
+ let target = source.copy_with_options(HashMap::from([(
+ "query-auth.enabled".to_string(),
+ "true".to_string(),
+ )]));
+
+ let err = target
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0]])
+ .with_limit(1)
+ .with_prepared_filter(prepared)
+ .execute()
+ .await
+ .expect_err("a stale prepared filter must not bypass current
target authorization");
+
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains("query-auth.enabled")),
+ "builder target authorization must remain authoritative, got:
{err:?}"
+ );
+ }
+
fn pk_data_file(name: &str, row_count: i64, first_row_id: Option<i64>) ->
DataFileMeta {
DataFileMeta {
file_name: name.to_string(),
@@ -5529,6 +6041,14 @@ mod tests {
.await
.unwrap();
assert!(built > 0, "DE fixture must build a global vector index");
+ let built = table
+ .new_sorted_global_index_build_builder()
+ .with_index_column("id")
+ .with_index_type("btree")
+ .execute()
+ .await
+ .unwrap();
+ assert!(built > 0, "DE fixture must build a scalar BTree index");
table
}
@@ -6007,14 +6527,13 @@ mod tests {
}
#[tokio::test]
- async fn execute_scored_filter_on_non_pk_vector_path_fails_loud() {
- // No PK-vector index configured, so `execute_scored` would fall
through to
- // the data-evolution path, which never consumes the filter. Silently
- // returning unfiltered rows is a wrong-read; the query must fail loud
- // instead.
+ async fn execute_scored_filter_on_empty_de_path_returns_empty() {
+ // No PK-vector index and no snapshot: the request follows the
+ // data-evolution path. Scalar pre-filter support must not turn an
empty
+ // table into an error.
let table = pk_vector_table(&[]);
let filter = id_gt_filter(&table, 2);
- let err = table
+ let result = table
.new_vector_search_builder()
.with_vector_column("embedding")
.with_query_vector(vec![1.0])
@@ -6022,13 +6541,8 @@ mod tests {
.with_filter(filter)
.execute_scored()
.await
- .map(|_| ())
- .expect_err("filter on the non-PK-vector path must fail loud");
- assert!(
- matches!(err, crate::Error::DataInvalid { ref message, .. }
- if message.contains("only supported on the primary-key vector
path")),
- "unexpected error: {err:?}"
- );
+ .expect("an empty data-evolution search with a filter should
succeed");
+ assert!(result.is_empty());
}
#[tokio::test]
@@ -6894,27 +7408,207 @@ mod tests {
}
#[tokio::test]
- async fn de_execute_read_with_filter_fails_loud() {
- // A filter on the data-evolution path is unsupported (the DE path
never
- // reads physical rows), so execute_read must fail loud rather than
drop the
- // predicate. The guard lives in execute_scored.
+ async fn resolved_vector_snapshot_can_be_reused_by_all_read_stages() {
+ let table = de_vector_table().await;
+ let snapshot = crate::table::time_travel::resolve_snapshot(&table)
+ .await
+ .unwrap()
+ .unwrap();
+ let pinned =
table.copy_with_resolved_snapshot(&snapshot).await.unwrap();
+
+ assert_eq!(
+ pinned.travel_snapshot().map(|snapshot| snapshot.id()),
+ Some(snapshot.id())
+ );
+ let options = CoreOptions::new(pinned.schema().options());
+ let selector = options.try_time_travel_selector().unwrap().unwrap();
+ assert!(matches!(
+ selector,
+ crate::spec::TimeTravelSelector::SnapshotId {
+ value,
+ option_name: crate::spec::SCAN_SNAPSHOT_ID_OPTION,
+ } if value == snapshot.id().to_string()
+ ));
+ }
+
+ #[tokio::test]
+ async fn de_execute_read_applies_scalar_filter_before_top_k() {
+ // Row id=1 is the closest vector to [1, 0], but the scalar filter
excludes
+ // it. Filter-before-Top-K must return the best rows among ids > 1
instead
+ // of recalling id=1 first and filtering it after the search.
let table = de_vector_table().await;
let filter = id_gt_filter(&table, 1);
- let err = table
+ let mut stream = table
.new_vector_search_builder()
.with_vector_column("embedding")
.with_query_vector(vec![1.0, 0.0])
- .with_limit(3)
+ .with_limit(2)
.with_filter(filter)
.execute_read()
.await
- .map(|_| ())
- .expect_err("DE read with a filter must fail loud");
+ .expect("DE vector search should support a scalar pre-filter");
+
+ let mut ids = Vec::new();
+ while let Some(batch) = stream.try_next().await.unwrap() {
+ let id = batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ ids.extend((0..id.len()).map(|row| id.value(row)));
+ }
+
+ assert_eq!(ids, vec![3, 2]);
+ }
+
+ #[tokio::test]
+ async fn de_scalar_filter_with_no_matching_rows_returns_empty() {
+ let table = de_vector_table().await;
+ let filter = id_gt_filter(&table, 99);
+
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(2)
+ .with_filter(filter.clone())
+ .execute_scored()
+ .await
+ .unwrap();
+ assert!(result.is_empty());
+
+ let results = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0], vec![0.0, 1.0]])
+ .with_limit(2)
+ .with_filter(filter)
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(results.len(), 2);
+ assert!(results.iter().all(SearchResult::is_empty));
+ }
+
+ #[tokio::test]
+ async fn prepared_de_scalar_filter_can_be_reused_by_batch_search() {
+ let table = de_vector_table().await;
+ let prepared = table
+ .prepare_vector_search_filter(id_gt_filter(&table, 1))
+ .await
+ .unwrap();
+ let results = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0], vec![0.0, 1.0]])
+ .with_limit(2)
+ .with_prepared_filter(prepared)
+ .execute()
+ .await
+ .unwrap();
+
+ assert_eq!(results.len(), 2);
+ assert_eq!(results[0].row_ids, vec![2, 1]);
+ assert_eq!(results[1].row_ids, vec![1, 2]);
+ }
+
+ #[tokio::test]
+ async fn prepared_filter_from_different_table_is_rejected() {
+ let prepared = PreparedVectorSearchFilter {
+ table: vector_test_table_at("memory:/prepared_filter_source"),
+ include_row_ids: Arc::new(RoaringTreemap::from_iter([1])),
+ };
+ let target = vector_test_table_at("memory:/prepared_filter_target");
+
+ let error = target
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0]])
+ .with_limit(1)
+ .with_prepared_filter(prepared)
+ .execute()
+ .await
+ .expect_err("a prepared filter must not retarget the builder to
another table");
+
assert!(
- matches!(err, crate::Error::DataInvalid { .. }),
- "unexpected error: {err:?}"
+ error.to_string().contains("different table"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[tokio::test]
+ async fn
prepared_filter_from_same_location_but_different_file_io_is_rejected() {
+ let location = "memory:/prepared_filter_shared_location";
+ let source =
+
vector_test_table_with_file_io(FileIOBuilder::new("memory").build().unwrap(),
location);
+ let prepared = source
+ .prepare_vector_search_filter(id_gt_filter(&source, 0))
+ .await
+ .unwrap();
+ let target =
+
vector_test_table_with_file_io(FileIOBuilder::new("memory").build().unwrap(),
location);
+
+ let error = target
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0]])
+ .with_limit(1)
+ .with_prepared_filter(prepared)
+ .execute()
+ .await
+ .expect_err("a prepared filter must stay bound to its FileIO
lineage");
+
+ assert!(
+ error.to_string().contains("different table"),
+ "unexpected error: {error}"
);
}
+
+ #[tokio::test]
+ async fn de_scalar_filter_applies_to_unindexed_raw_fallback() {
+ let table = de_vector_table().await;
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut vector_builder =
+
ListBuilder::new(Float32Builder::new()).with_field(element_field.clone());
+ vector_builder.values().append_value(1.0);
+ vector_builder.values().append_value(0.0);
+ vector_builder.append(true);
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding",
ArrowDataType::List(element_field), true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from(vec![4])) as ArrayRef,
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap();
+ let mut writer = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ let table = table.copy_with_options(HashMap::from([
+ ("vector-index.search-mode".to_string(), "full".to_string()),
+ ("scalar-index.search-mode".to_string(), "full".to_string()),
+ ]));
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(1)
+ .with_filter(id_gt_filter(&table, 3))
+ .execute_scored()
+ .await
+ .unwrap();
+
+ assert_eq!(result.row_ids, vec![3]);
+ }
}
/// Tests for [`residual_positions_by_file`]: the residual predicate is
applied at
diff --git a/crates/paimon/src/vector_search.rs
b/crates/paimon/src/vector_search.rs
index 1625243e..e1a3de10 100644
--- a/crates/paimon/src/vector_search.rs
+++ b/crates/paimon/src/vector_search.rs
@@ -17,6 +17,7 @@
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap};
+use std::sync::Arc;
#[derive(Clone)]
pub struct VectorSearch {
@@ -25,6 +26,7 @@ pub struct VectorSearch {
pub field_name: String,
pub options: HashMap<String, String>,
pub include_row_ids: Option<roaring::RoaringTreemap>,
+ pub(crate) shared_include_row_ids: Option<Arc<roaring::RoaringTreemap>>,
}
impl VectorSearch {
@@ -53,6 +55,7 @@ impl VectorSearch {
field_name,
options: HashMap::new(),
include_row_ids: None,
+ shared_include_row_ids: None,
})
}
@@ -63,8 +66,23 @@ impl VectorSearch {
pub fn with_include_row_ids(mut self, include_row_ids:
roaring::RoaringTreemap) -> Self {
self.include_row_ids = Some(include_row_ids);
+ self.shared_include_row_ids = None;
self
}
+
+ pub(crate) fn set_shared_include_row_ids(
+ &mut self,
+ include_row_ids: Arc<roaring::RoaringTreemap>,
+ ) {
+ self.include_row_ids = None;
+ self.shared_include_row_ids = Some(include_row_ids);
+ }
+
+ pub(crate) fn effective_include_row_ids(&self) ->
Option<&roaring::RoaringTreemap> {
+ self.shared_include_row_ids
+ .as_deref()
+ .or(self.include_row_ids.as_ref())
+ }
}
impl std::fmt::Display for VectorSearch {
diff --git a/crates/paimon/src/vindex/reader.rs
b/crates/paimon/src/vindex/reader.rs
index 8f8eab35..418b4301 100644
--- a/crates/paimon/src/vindex/reader.rs
+++ b/crates/paimon/src/vindex/reader.rs
@@ -26,7 +26,7 @@ use paimon_vindex_core::io::{ReadRequest, SeekRead,
SeekReadCapabilities};
use std::collections::BinaryHeap;
use std::collections::HashMap;
use std::io;
-use std::sync::{Condvar, Mutex};
+use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const DEFAULT_NPROBE: usize = 16;
@@ -421,16 +421,39 @@ fn search_vindex(
Ok(Some(id_to_scores))
}
-#[derive(Clone, PartialEq, Eq)]
+#[derive(Clone)]
struct PreparedSearch {
params: VectorSearchParams,
- filter_bytes: Option<Vec<u8>>,
+ filter_bytes: Option<Arc<[u8]>>,
}
+impl PreparedSearch {
+ fn same_batch_group(&self, other: &Self) -> bool {
+ self.params == other.params
+ && match (&self.filter_bytes, &other.filter_bytes) {
+ (Some(left), Some(right)) => {
+ Arc::ptr_eq(left, right) || left.as_ref() == right.as_ref()
+ }
+ (None, None) => true,
+ _ => false,
+ }
+ }
+}
+
+#[cfg(test)]
fn prepare_search(
metadata: &VectorIndexMetadata,
options: &HashMap<String, String>,
vector_search: &VectorSearch,
+) -> crate::Result<Option<PreparedSearch>> {
+ prepare_search_with_shared_filter(metadata, options, vector_search, None)
+}
+
+fn prepare_search_with_shared_filter(
+ metadata: &VectorIndexMetadata,
+ options: &HashMap<String, String>,
+ vector_search: &VectorSearch,
+ shared_filter_bytes: Option<&Arc<[u8]>>,
) -> crate::Result<Option<PreparedSearch>> {
if vector_search.vector.len() != metadata.dimension {
return Err(crate::Error::DataInvalid {
@@ -472,19 +495,15 @@ fn prepare_search(
),
};
- let filter_bytes = if let Some(include_ids) =
&vector_search.include_row_ids {
+ let filter_bytes = if let Some(include_ids) =
vector_search.effective_include_row_ids() {
if include_ids.is_empty() {
return Ok(None);
}
top_k = top_k.min(include_ids.len() as usize);
- let mut bytes = Vec::new();
- include_ids
- .serialize_into(&mut bytes)
- .map_err(|e| crate::Error::DataInvalid {
- message: format!("Failed to serialize vector search row-id
filter: {}", e),
- source: Some(Box::new(e)),
- })?;
- Some(bytes)
+ Some(match shared_filter_bytes {
+ Some(filter_bytes) => Arc::clone(filter_bytes),
+ None => serialize_row_id_filter(include_ids)?,
+ })
} else {
None
};
@@ -496,6 +515,55 @@ fn prepare_search(
}))
}
+fn serialize_row_id_filter(include_ids: &roaring::RoaringTreemap) ->
crate::Result<Arc<[u8]>> {
+ let mut bytes = Vec::new();
+ include_ids
+ .serialize_into(&mut bytes)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("Failed to serialize vector search row-id filter:
{}", e),
+ source: Some(Box::new(e)),
+ })?;
+ Ok(Arc::from(bytes))
+}
+
+fn shared_batch_include_row_ids(
+ vector_searches: &[VectorSearch],
+) -> Option<&Arc<roaring::RoaringTreemap>> {
+ let first = vector_searches.first()?.shared_include_row_ids.as_ref()?;
+ vector_searches
+ .iter()
+ .skip(1)
+ .all(|search| {
+ search
+ .shared_include_row_ids
+ .as_ref()
+ .is_some_and(|include_row_ids| Arc::ptr_eq(first,
include_row_ids))
+ })
+ .then_some(first)
+}
+
+fn prepare_batch_searches(
+ metadata: &VectorIndexMetadata,
+ options: &HashMap<String, String>,
+ vector_searches: &[VectorSearch],
+) -> crate::Result<Vec<Option<PreparedSearch>>> {
+ let shared_filter_bytes = shared_batch_include_row_ids(vector_searches)
+ .filter(|include_row_ids| !include_row_ids.is_empty())
+ .map(|include_row_ids| serialize_row_id_filter(include_row_ids))
+ .transpose()?;
+ vector_searches
+ .iter()
+ .map(|search| {
+ prepare_search_with_shared_filter(
+ metadata,
+ options,
+ search,
+ shared_filter_bytes.as_ref(),
+ )
+ })
+ .collect()
+}
+
fn execute_scalar_search(
reader: &mut VIndexReader<impl SeekRead>,
vector_search: &VectorSearch,
@@ -537,11 +605,17 @@ fn search_batch_vindex(
..VindexBatchStats::default()
});
- for (index, search) in vector_searches.iter().enumerate() {
- let Some(prepared) = prepare_search(metadata, options, search)? else {
+ for (index, prepared) in prepare_batch_searches(metadata, options,
vector_searches)?
+ .into_iter()
+ .enumerate()
+ {
+ let Some(prepared) = prepared else {
continue;
};
- if let Some((_, indices)) = groups.iter_mut().find(|(key, _)| key ==
&prepared) {
+ if let Some((_, indices)) = groups
+ .iter_mut()
+ .find(|(key, _)| key.same_batch_group(&prepared))
+ {
indices.push(index);
} else {
groups.push((prepared, vec![index]));
@@ -1032,7 +1106,7 @@ mod tests {
};
let prepared = PreparedSearch {
params: VectorSearchParams::new(10, 16),
- filter_bytes: Some(vec![0; 128]),
+ filter_bytes: Some(Arc::from(vec![0; 128])),
};
let chunk_size = native_batch_chunk_size(&metadata, &prepared, 1);
let full_chunk = native_batch_chunk_working_set_bytes(&metadata,
&prepared, chunk_size);
@@ -1043,6 +1117,38 @@ mod tests {
assert!(final_chunk < full_chunk);
}
+ #[test]
+ fn batch_preparation_serializes_shared_filter_once() {
+ let metadata = VectorIndexMetadata {
+ index_type: paimon_vindex_core::index::IndexType::IvfFlat,
+ dimension: TEST_DIMENSION,
+ nlist: 16,
+ metric: MetricType::L2,
+ total_vectors: 1_000_000,
+ pq_m: None,
+ pq_bits: None,
+ rq_bits: None,
+ diskann: None,
+ };
+ let shared_filter =
Arc::new(roaring::RoaringTreemap::from_iter(0..100_000));
+ let mut searches = vec![query(); 128];
+ for search in &mut searches {
+ search.set_shared_include_row_ids(Arc::clone(&shared_filter));
+ }
+
+ let prepared = prepare_batch_searches(&metadata, &HashMap::new(),
&searches).unwrap();
+ let first = prepared[0]
+ .as_ref()
+ .and_then(|search| search.filter_bytes.as_ref())
+ .expect("shared filter should be serialized");
+ assert!(prepared.iter().all(|search| {
+ search
+ .as_ref()
+ .and_then(|search| search.filter_bytes.as_ref())
+ .is_some_and(|filter| Arc::ptr_eq(first, filter))
+ }));
+ }
+
#[test]
fn native_batch_memory_pool_admits_only_available_bytes() {
let pool = NativeBatchMemoryPool::new(64);
@@ -1149,7 +1255,7 @@ mod tests {
)
.unwrap()
.unwrap();
- assert!(automatic != explicit);
+ assert_ne!(automatic.params, explicit.params);
let explicit_params = explicit.params;
assert_eq!(
explicit_params.search_width,
diff --git a/crates/paimon/tests/pk_vector_batch_test.rs
b/crates/paimon/tests/pk_vector_batch_test.rs
index 87e4ca59..65e16c38 100644
--- a/crates/paimon/tests/pk_vector_batch_test.rs
+++ b/crates/paimon/tests/pk_vector_batch_test.rs
@@ -730,14 +730,13 @@ async fn empty_snapshot_still_rejects_zero_limit() {
);
}
-/// A filter set on a batch `execute()` (the scored / data-evolution path) must
-/// fail loud rather than silently drop the predicate: that path never reads
-/// physical rows, so it cannot honor a residual filter. Mirrors the
single-query
-/// `execute_scored` guard.
+/// A filter set on a batch `execute()` (the scored / data-evolution path) is
+/// accepted even when the snapshot is empty. The scalar pre-filter is
evaluated
+/// before vector Top-K, and batch result arity is preserved when no rows
match.
// Gated off Windows for the same `file://` tempdir reason as
`pk_vector_baseline_test`.
#[cfg(not(windows))]
#[tokio::test]
-async fn batch_execute_with_filter_on_non_pk_vector_table_fails_loud() {
+async fn
batch_execute_with_filter_on_empty_non_pk_vector_table_returns_empty() {
let tmp = tempfile::tempdir().expect("create temp dir");
let location = format!("file://{}", tmp.path().display());
let file_io = FileIOBuilder::new("file").build().unwrap();
@@ -770,18 +769,16 @@ async fn
batch_execute_with_filter_on_non_pk_vector_table_fails_loud() {
.greater_or_equal("id", Datum::Int(1))
.expect("build filter on id");
+ let queries = vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
let mut batch = table.new_batch_vector_search_builder();
- let err = batch
+ let results = batch
.with_vector_column(VECTOR_COLUMN)
- .with_query_vectors(vec![vec![1.0, 0.0, 0.0, 0.0]])
+ .with_query_vectors(queries.clone())
.with_limit(3)
.with_filter(filter)
.execute()
.await
- .expect_err("a filter on the data-evolution batch path must fail
loud");
- assert!(
- err.to_string()
- .contains("only supported on the primary-key vector path"),
- "expected a filter-unsupported error, got: {err}"
- );
+ .expect("the data-evolution batch path must accept scalar
pre-filters");
+ assert_eq!(results.len(), queries.len());
+ assert!(results.iter().all(|result| result.is_empty()));
}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index bac91b7d..5919c4b2 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1339,6 +1339,32 @@ The function performs ANN search across all matching
vector index files for the
target column, merges results, and returns the top-k rows ordered by relevance
score. If no matching index is found, an empty result is returned.
+### Scalar Pre-Filters
+
+Add a `WHERE` clause to restrict the rows considered by vector Top-K. The
+predicate is evaluated before the vector index selects its nearest neighbors:
+
+```sql
+SELECT id, event_time
+FROM vector_search(
+ 'paimon.my_db.items',
+ 'embedding',
+ '[1.0, 0.0, 0.0, 0.0]',
+ 10
+)
+WHERE event_time >= TIMESTAMP '2026-08-01 00:00:00';
+```
+
+On data-evolution tables, Paimon resolves the predicate to matching global row
+IDs using a snapshot-pinned table read. Scalar global indexes such as BTree can
+narrow this read. The matching global row IDs are intersected with each vector
+index shard and passed to the vector backend as its row filter, so an excluded
+nearest neighbor does not consume one of the requested Top-K positions.
+
+Only predicates that can be translated completely to Paimon predicates are
+pushed into vector search. DataFusion keeps its residual filter for ordinary
+`vector_search` queries as an additional correctness check.
+
### Refine / Rerank
Vector index search can optionally refine ANN results by reading the raw
vectors
@@ -1421,6 +1447,28 @@ ORDER BY query_id, result_id;
The query-vector column must have Arrow type `List<Float32>` or
`FixedSizeList<Float32>`. Null query-vector rows produce no joined results, and
null elements inside a vector are rejected. The lateral form returns the left
row joined with the top-k matching rows from the target Paimon table for that
row's query vector.
+Fully translatable target-table predicates are also applied before each lateral
+Top-K:
+
+```sql
+SELECT q.id AS query_id, r.id AS result_id
+FROM paimon.my_db.queries q
+CROSS JOIN LATERAL vector_search(
+ 'paimon.my_db.items',
+ 'embedding',
+ q.embedding,
+ 10
+) AS r
+WHERE r.event_time >= TIMESTAMP '2026-08-01 00:00:00'
+ORDER BY query_id, result_id;
+```
+
+For conjunctions, target-only predicates such as `r.event_time >= ...` are
+pushed into vector search. Predicates that reference the left relation or both
+sides remain normal join-result filters. Unsupported or inexact target
+predicates also remain post-Top-K residual filters, so they may return fewer
+than the requested number of rows.
+
### Supported Metrics
The distance metric is configured at index creation time via table options: