Skip to content

Commit 885dd29

Browse files
fix(datafusion): return vector_search rows in best-first relevance order (#613)
1 parent 3cb51e8 commit 885dd29

2 files changed

Lines changed: 541 additions & 42 deletions

File tree

crates/integrations/datafusion/src/vector_search.rs

Lines changed: 311 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,42 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::fmt::Debug;
18+
use std::collections::HashMap;
19+
use std::fmt::{self, Debug};
1920
use std::sync::Arc;
2021

2122
use async_trait::async_trait;
23+
use datafusion::arrow::array::{
24+
Array, ArrayRef, Int64Array, RecordBatch, RecordBatchOptions, UInt32Array,
25+
};
26+
use datafusion::arrow::compute::cast;
2227
use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
2328
use datafusion::catalog::Session;
2429
use datafusion::catalog::TableFunctionImpl;
25-
use datafusion::common::project_schema;
30+
use datafusion::common::stats::Precision;
31+
use datafusion::common::{internal_err, project_schema, Statistics};
2632
use datafusion::datasource::{TableProvider, TableType};
2733
use datafusion::error::{DataFusionError, Result as DFResult};
34+
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
2835
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
36+
use datafusion::physical_expr::EquivalenceProperties;
2937
use datafusion::physical_plan::empty::EmptyExec;
30-
use datafusion::physical_plan::ExecutionPlan;
38+
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
39+
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
40+
use datafusion::physical_plan::{
41+
DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
42+
};
3143
use datafusion::prelude::SessionContext;
44+
use futures::{stream, TryStreamExt};
3245
use paimon::catalog::Catalog;
46+
use paimon::spec::{
47+
BigIntType, CoreOptions, DataField, DataType, ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME,
48+
};
49+
use paimon::table::Table;
3350

3451
use crate::error::to_datafusion_error;
3552
use crate::runtime::{await_with_runtime, block_on_with_runtime};
36-
use crate::table::{PaimonScanBuilder, PaimonTableProvider};
53+
use crate::table::{datafusion_read_fields, PaimonTableProvider};
3754
use crate::table_function_args::{
3855
extract_int_literal, extract_string_literal, parse_table_identifier,
3956
};
@@ -207,60 +224,314 @@ impl TableProvider for VectorSearchTableProvider {
207224

208225
async fn scan(
209226
&self,
210-
state: &dyn Session,
227+
_state: &dyn Session,
211228
projection: Option<&Vec<usize>>,
212229
_filters: &[Expr],
213230
limit: Option<usize>,
214231
) -> DFResult<Arc<dyn ExecutionPlan>> {
215-
let table = self.inner.table();
232+
let projected_schema = project_schema(&self.schema(), projection)?;
233+
234+
// An outer `LIMIT 0` needs no rows.
235+
if limit == Some(0) {
236+
return Ok(Arc::new(EmptyExec::new(projected_schema)));
237+
}
216238

217-
let row_ranges = await_with_runtime(async {
218-
let mut builder = table.new_vector_search_builder();
239+
// The search runs with the table function's own top-k (`self.limit`) so the ANN
240+
// recall/search width is unchanged; the outer DataFusion `limit` only truncates
241+
// the already-ranked result before any rows are read (so a large top-k with a
242+
// small outer LIMIT doesn't read/materialize everything). All of this — search,
243+
// read and rank-order gather — runs at execution time in the exec's stream, so
244+
// planning / EXPLAIN stays cheap and the work is driven by the TaskContext.
245+
Ok(Arc::new(VectorSearchExec::new(
246+
self.inner.table().clone(),
247+
self.column_name.clone(),
248+
self.query_vector.clone(),
249+
self.limit,
250+
limit,
251+
projection.cloned(),
252+
projected_schema,
253+
)))
254+
}
255+
256+
fn supports_filters_pushdown(
257+
&self,
258+
filters: &[&Expr],
259+
) -> DFResult<Vec<TableProviderFilterPushDown>> {
260+
Ok(vec![
261+
TableProviderFilterPushDown::Unsupported;
262+
filters.len()
263+
])
264+
}
265+
}
266+
267+
/// Execution-time plan for `vector_search`: runs the ANN search, reads the matching
268+
/// rows, and gathers them into best-first relevance order when its stream is polled,
269+
/// so planning (and `EXPLAIN`) stays cheap and the work runs under DataFusion's
270+
/// `TaskContext`.
271+
#[derive(Debug, Clone)]
272+
struct VectorSearchExec {
273+
table: Table,
274+
column_name: String,
275+
query_vector: Vec<f32>,
276+
/// The table function's own top-k — drives the ANN search width; never reduced.
277+
search_limit: usize,
278+
/// The outer DataFusion `LIMIT`, applied by truncating the ranked result.
279+
output_limit: Option<usize>,
280+
projection: Option<Vec<usize>>,
281+
output_schema: ArrowSchemaRef,
282+
plan_properties: Arc<PlanProperties>,
283+
}
284+
285+
impl VectorSearchExec {
286+
fn new(
287+
table: Table,
288+
column_name: String,
289+
query_vector: Vec<f32>,
290+
search_limit: usize,
291+
output_limit: Option<usize>,
292+
projection: Option<Vec<usize>>,
293+
output_schema: ArrowSchemaRef,
294+
) -> Self {
295+
let plan_properties = Arc::new(PlanProperties::new(
296+
EquivalenceProperties::new(output_schema.clone()),
297+
Partitioning::UnknownPartitioning(1),
298+
EmissionType::Incremental,
299+
Boundedness::Bounded,
300+
));
301+
Self {
302+
table,
303+
column_name,
304+
query_vector,
305+
search_limit,
306+
output_limit,
307+
projection,
308+
output_schema,
309+
plan_properties,
310+
}
311+
}
312+
313+
async fn compute_batch(&self) -> DFResult<RecordBatch> {
314+
// Best-first row-ids from the index, searched at the full top-k so the ANN
315+
// recall is unchanged (data-evolution / global-index path; PK-vector tables are
316+
// unsupported here, as before).
317+
let mut search_result = await_with_runtime(async {
318+
let mut builder = self.table.new_vector_search_builder();
219319
builder
220320
.with_vector_column(&self.column_name)
221321
.with_query_vector(self.query_vector.clone())
222-
.with_limit(self.limit);
223-
builder.execute().await.map_err(to_datafusion_error)
322+
.with_limit(self.search_limit);
323+
builder.execute_scored().await.map_err(to_datafusion_error)
224324
})
225325
.await?;
226326

227-
if row_ranges.is_empty() {
228-
let schema = project_schema(&self.schema(), projection)?;
229-
return Ok(Arc::new(EmptyExec::new(schema)));
327+
if search_result.is_empty() {
328+
return Ok(RecordBatch::new_empty(self.output_schema.clone()));
230329
}
231330

232-
let mut read_builder = table.new_read_builder();
233-
if let Some(limit) = limit {
234-
read_builder.with_limit(limit);
331+
// Apply the outer LIMIT by truncating the ranked result *before* reading, so a
332+
// large top-k with a small outer LIMIT only reads/materializes the rows it can
333+
// return (the search itself is unaffected).
334+
if let Some(n) = self.output_limit {
335+
if search_result.row_ids.len() > n {
336+
search_result.row_ids.truncate(n);
337+
search_result.scores.truncate(n);
338+
}
235339
}
236-
let scan = read_builder.new_scan().with_row_ranges(row_ranges);
237-
let plan = await_with_runtime(scan.plan())
238-
.await
239-
.map_err(to_datafusion_error)?;
240340

241-
let target = state.config_options().execution.target_partitions;
242-
PaimonScanBuilder {
243-
table,
244-
schema: &self.schema(),
245-
plan: &plan,
246-
scan_trace: None,
247-
projection,
248-
pushed_predicate: None,
249-
limit,
250-
target_partitions: target,
251-
filter_exact: false,
252-
case_sensitive: true,
341+
// Read the projected columns (+ internal `_ROW_ID`); the row-range scan yields
342+
// file order, realigned to relevance rank below.
343+
let read_fields = projected_read_fields(&self.table, self.projection.as_ref())?;
344+
let row_ranges = search_result.to_row_ranges().map_err(to_datafusion_error)?;
345+
let batches = await_with_runtime(async {
346+
let mut read_builder = self.table.new_read_builder();
347+
read_builder
348+
.with_read_type(read_fields)
349+
.with_row_ranges(row_ranges);
350+
let scan = read_builder.new_scan();
351+
let plan = scan.plan().await.map_err(to_datafusion_error)?;
352+
let table_read = read_builder.new_read().map_err(to_datafusion_error)?;
353+
let mut stream = table_read
354+
.to_arrow(plan.splits())
355+
.map_err(to_datafusion_error)?;
356+
let mut batches: Vec<RecordBatch> = Vec::new();
357+
while let Some(batch) = stream.try_next().await.map_err(to_datafusion_error)? {
358+
batches.push(batch);
359+
}
360+
Ok::<_, DataFusionError>(batches)
361+
})
362+
.await?;
363+
364+
// Realign file-order rows to best-first rank and drop `_ROW_ID`.
365+
gather_rows_by_rank(&batches, &search_result.row_ids, &self.output_schema)
366+
}
367+
}
368+
369+
impl DisplayAs for VectorSearchExec {
370+
fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
371+
write!(
372+
f,
373+
"VectorSearchExec: column={}, search_limit={}, output_limit={:?}",
374+
self.column_name, self.search_limit, self.output_limit
375+
)
376+
}
377+
}
378+
379+
impl ExecutionPlan for VectorSearchExec {
380+
fn name(&self) -> &str {
381+
"VectorSearchExec"
382+
}
383+
384+
fn properties(&self) -> &Arc<PlanProperties> {
385+
&self.plan_properties
386+
}
387+
388+
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
389+
vec![]
390+
}
391+
392+
fn with_new_children(
393+
self: Arc<Self>,
394+
children: Vec<Arc<dyn ExecutionPlan>>,
395+
) -> DFResult<Arc<dyn ExecutionPlan>> {
396+
if !children.is_empty() {
397+
return internal_err!("VectorSearchExec is a leaf and takes no children");
253398
}
254-
.build()
399+
Ok(self)
255400
}
256401

257-
fn supports_filters_pushdown(
402+
fn execute(
258403
&self,
259-
filters: &[&Expr],
260-
) -> DFResult<Vec<TableProviderFilterPushDown>> {
261-
Ok(vec![
262-
TableProviderFilterPushDown::Unsupported;
263-
filters.len()
264-
])
404+
partition: usize,
405+
_context: Arc<TaskContext>,
406+
) -> DFResult<SendableRecordBatchStream> {
407+
if partition != 0 {
408+
return internal_err!(
409+
"VectorSearchExec has a single partition, got partition {partition}"
410+
);
411+
}
412+
let exec = self.clone();
413+
let stream = stream::once(async move { exec.compute_batch().await });
414+
Ok(Box::pin(RecordBatchStreamAdapter::new(
415+
self.output_schema.clone(),
416+
Box::pin(stream),
417+
)))
418+
}
419+
420+
fn partition_statistics(&self, _partition: Option<usize>) -> DFResult<Arc<Statistics>> {
421+
Ok(Arc::new(Statistics {
422+
num_rows: Precision::Absent,
423+
total_byte_size: Precision::Absent,
424+
column_statistics: Statistics::unknown_column(&self.output_schema),
425+
}))
426+
}
427+
}
428+
429+
/// Projected user columns (+ internal `_ROW_ID`, needed to realign rows to rank).
430+
/// Errors if the table has no row tracking, since results then can't be ordered.
431+
fn projected_read_fields(
432+
table: &paimon::table::Table,
433+
projection: Option<&Vec<usize>>,
434+
) -> DFResult<Vec<DataField>> {
435+
let base_fields = datafusion_read_fields(table);
436+
let mut read_fields: Vec<DataField> = match projection {
437+
Some(indices) => indices.iter().map(|&i| base_fields[i].clone()).collect(),
438+
None => base_fields,
439+
};
440+
if !read_fields
441+
.iter()
442+
.any(|field| field.name() == ROW_ID_FIELD_NAME)
443+
{
444+
if !CoreOptions::new(table.schema().options()).row_tracking_enabled() {
445+
return Err(DataFusionError::Plan(
446+
"vector_search: cannot order results by relevance because _ROW_ID is not available"
447+
.to_string(),
448+
));
449+
}
450+
read_fields.push(DataField::new(
451+
ROW_ID_FIELD_ID,
452+
ROW_ID_FIELD_NAME.to_string(),
453+
DataType::BigInt(BigIntType::with_nullable(true)),
454+
));
455+
}
456+
Ok(read_fields)
457+
}
458+
459+
/// Gather the file-order `batches` into `ranked_row_ids` order (rank == slice index),
460+
/// producing `output_schema` (which excludes `_ROW_ID`). A permutation driven by the
461+
/// index's existing ranking, not a re-sort.
462+
fn gather_rows_by_rank(
463+
batches: &[RecordBatch],
464+
ranked_row_ids: &[u64],
465+
output_schema: &ArrowSchemaRef,
466+
) -> DFResult<RecordBatch> {
467+
let input_schema = batches.first().map(|batch| batch.schema()).ok_or_else(|| {
468+
DataFusionError::Internal("vector_search: no rows materialized".to_string())
469+
})?;
470+
let combined = arrow_select::concat::concat_batches(&input_schema, batches)
471+
.map_err(DataFusionError::from)?;
472+
473+
let row_id_index = combined.schema().index_of(ROW_ID_FIELD_NAME).map_err(|_| {
474+
DataFusionError::Internal(format!(
475+
"vector_search: materialized rows are missing the {ROW_ID_FIELD_NAME} column"
476+
))
477+
})?;
478+
let row_ids = combined
479+
.column(row_id_index)
480+
.as_any()
481+
.downcast_ref::<Int64Array>()
482+
.ok_or_else(|| {
483+
DataFusionError::Internal(format!("vector_search: {ROW_ID_FIELD_NAME} must be Int64"))
484+
})?;
485+
486+
// Map global row id -> physical position in the materialized batch.
487+
let mut position_of: HashMap<i64, u32> = HashMap::with_capacity(combined.num_rows());
488+
for row in 0..combined.num_rows() {
489+
if !row_ids.is_null(row) {
490+
position_of.insert(row_ids.value(row), row as u32);
491+
}
492+
}
493+
494+
// Emit in rank order; extra scanned rows are ignored, but a missing scored id
495+
// fails loud rather than silently shrinking the top-k.
496+
let mut take_indices: Vec<u32> = Vec::with_capacity(ranked_row_ids.len());
497+
for &row_id in ranked_row_ids {
498+
let position = position_of.get(&(row_id as i64)).ok_or_else(|| {
499+
DataFusionError::Internal(format!(
500+
"vector_search: scored row id {row_id} was not materialized; \
501+
cannot return the requested top-k"
502+
))
503+
})?;
504+
take_indices.push(*position);
265505
}
506+
let take_indices = UInt32Array::from(take_indices);
507+
let row_count = take_indices.len();
508+
509+
let columns = output_schema
510+
.fields()
511+
.iter()
512+
.map(|field| -> DFResult<ArrayRef> {
513+
let index = combined.schema().index_of(field.name()).map_err(|_| {
514+
DataFusionError::Internal(format!(
515+
"vector_search: materialized rows are missing expected column '{}'",
516+
field.name()
517+
))
518+
})?;
519+
let taken =
520+
arrow_select::take::take(combined.column(index).as_ref(), &take_indices, None)
521+
.map_err(DataFusionError::from)?;
522+
// The Paimon read keeps its own arrow types (e.g. `Utf8`), but the provider
523+
// schema may differ (e.g. DataFusion's `Utf8View`); cast to match, as the
524+
// normal scan path does via `to_datafusion_batch`.
525+
if taken.data_type() == field.data_type() {
526+
Ok(taken)
527+
} else {
528+
cast(taken.as_ref(), field.data_type()).map_err(DataFusionError::from)
529+
}
530+
})
531+
.collect::<DFResult<Vec<_>>>()?;
532+
533+
// Preserve the row count for a zero-column projection (e.g. `COUNT(*)`).
534+
let options = RecordBatchOptions::new().with_row_count(Some(row_count));
535+
RecordBatch::try_new_with_options(Arc::clone(output_schema), columns, &options)
536+
.map_err(DataFusionError::from)
266537
}

0 commit comments

Comments
 (0)