Skip to content

Commit 78913fd

Browse files
fix(datafusion): return vector_search rows in best-first relevance order
The `vector_search` table-valued function materialized its results with a plain row-range scan, so `SELECT * FROM vector_search(...)` returned the matching rows in physical file order instead of by relevance. This contradicts the documented contract ("returns the top-k rows ordered by relevance score") and means the first returned row is often not the most similar one, especially at larger `limit`s. Materialize the scored row-ids (best-first) via `execute_scored`, read the matching rows (with projection pushdown, plus the internal `_ROW_ID` column), then realign them to the index's relevance rank via a gather and drop `_ROW_ID`. A scored row-id that is not materialized fails loud rather than silently shrinking the top-k. The output schema is unchanged (no score column exposed). `execute_read` is intentionally not reused: it is statically `!Send` due to the primary-key-vector path, which the async `TableProvider::scan` cannot hold. Add a regression test that builds an ivf-flat vindex table and asserts the returned order is `[2, 5, 1]` (relevance order), which differs from the ascending-id file order `[1, 2, 5]` the old code produced, covering both the projected (`SELECT id`) and full (`SELECT *`) paths.
1 parent 27dea79 commit 78913fd

2 files changed

Lines changed: 260 additions & 31 deletions

File tree

crates/integrations/datafusion/src/vector_search.rs

Lines changed: 153 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,32 @@
1818
use std::fmt::Debug;
1919
use std::sync::Arc;
2020

21+
use std::collections::HashMap;
22+
2123
use async_trait::async_trait;
24+
use datafusion::arrow::array::{
25+
Array, ArrayRef, Int64Array, RecordBatch, RecordBatchOptions, UInt32Array,
26+
};
2227
use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
2328
use datafusion::catalog::Session;
2429
use datafusion::catalog::TableFunctionImpl;
2530
use datafusion::common::project_schema;
31+
use datafusion::datasource::memory::MemorySourceConfig;
2632
use datafusion::datasource::{TableProvider, TableType};
2733
use datafusion::error::{DataFusionError, Result as DFResult};
2834
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
2935
use datafusion::physical_plan::empty::EmptyExec;
3036
use datafusion::physical_plan::ExecutionPlan;
3137
use datafusion::prelude::SessionContext;
38+
use futures::TryStreamExt;
3239
use paimon::catalog::Catalog;
40+
use paimon::spec::{
41+
BigIntType, CoreOptions, DataField, DataType, ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME,
42+
};
3343

3444
use crate::error::to_datafusion_error;
3545
use crate::runtime::{await_with_runtime, block_on_with_runtime};
36-
use crate::table::{PaimonScanBuilder, PaimonTableProvider};
46+
use crate::table::{datafusion_read_fields, PaimonTableProvider};
3747
use crate::table_function_args::{
3848
extract_int_literal, extract_string_literal, parse_table_identifier,
3949
};
@@ -207,51 +217,62 @@ impl TableProvider for VectorSearchTableProvider {
207217

208218
async fn scan(
209219
&self,
210-
state: &dyn Session,
220+
_state: &dyn Session,
211221
projection: Option<&Vec<usize>>,
212222
_filters: &[Expr],
213-
limit: Option<usize>,
223+
_limit: Option<usize>,
214224
) -> DFResult<Arc<dyn ExecutionPlan>> {
215225
let table = self.inner.table();
226+
let projected_schema = project_schema(&self.schema(), projection)?;
216227

217-
let row_ranges = await_with_runtime(async {
228+
// Best-first row-ids from the index (data-evolution / global-index path;
229+
// PK-vector tables are unsupported here, as before).
230+
let search_result = await_with_runtime(async {
218231
let mut builder = table.new_vector_search_builder();
219232
builder
220233
.with_vector_column(&self.column_name)
221234
.with_query_vector(self.query_vector.clone())
222235
.with_limit(self.limit);
223-
builder.execute().await.map_err(to_datafusion_error)
236+
builder.execute_scored().await.map_err(to_datafusion_error)
224237
})
225238
.await?;
226239

227-
if row_ranges.is_empty() {
228-
let schema = project_schema(&self.schema(), projection)?;
229-
return Ok(Arc::new(EmptyExec::new(schema)));
240+
if search_result.is_empty() {
241+
return Ok(Arc::new(EmptyExec::new(projected_schema)));
230242
}
231243

232-
let mut read_builder = table.new_read_builder();
233-
if let Some(limit) = limit {
234-
read_builder.with_limit(limit);
235-
}
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)?;
240-
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,
253-
}
254-
.build()
244+
// Read the projected columns (+ internal `_ROW_ID`); the row-range scan yields
245+
// file order, realigned to relevance rank below.
246+
let read_fields = projected_read_fields(table, projection)?;
247+
let row_ranges = search_result.to_row_ranges().map_err(to_datafusion_error)?;
248+
let batches = await_with_runtime(async {
249+
let mut read_builder = table.new_read_builder();
250+
read_builder
251+
.with_read_type(read_fields)
252+
.with_row_ranges(row_ranges);
253+
let scan = read_builder.new_scan();
254+
let plan = scan.plan().await.map_err(to_datafusion_error)?;
255+
let table_read = read_builder.new_read().map_err(to_datafusion_error)?;
256+
let mut stream = table_read
257+
.to_arrow(plan.splits())
258+
.map_err(to_datafusion_error)?;
259+
let mut batches: Vec<RecordBatch> = Vec::new();
260+
while let Some(batch) = stream.try_next().await.map_err(to_datafusion_error)? {
261+
batches.push(batch);
262+
}
263+
Ok::<_, DataFusionError>(batches)
264+
})
265+
.await?;
266+
267+
// Realign file-order rows to best-first rank and drop `_ROW_ID` — this is what
268+
// honors the documented "top-k rows ordered by relevance score" contract.
269+
let ordered = gather_rows_by_rank(&batches, &search_result.row_ids, &projected_schema)?;
270+
271+
Ok(MemorySourceConfig::try_new_exec(
272+
&[vec![ordered]],
273+
projected_schema,
274+
None,
275+
)?)
255276
}
256277

257278
fn supports_filters_pushdown(
@@ -264,3 +285,104 @@ impl TableProvider for VectorSearchTableProvider {
264285
])
265286
}
266287
}
288+
289+
/// Projected user columns (+ internal `_ROW_ID`, needed to realign rows to rank).
290+
/// Errors if the table has no row tracking, since results then can't be ordered.
291+
fn projected_read_fields(
292+
table: &paimon::table::Table,
293+
projection: Option<&Vec<usize>>,
294+
) -> DFResult<Vec<DataField>> {
295+
let base_fields = datafusion_read_fields(table);
296+
let mut read_fields: Vec<DataField> = match projection {
297+
Some(indices) => indices.iter().map(|&i| base_fields[i].clone()).collect(),
298+
None => base_fields,
299+
};
300+
if !read_fields
301+
.iter()
302+
.any(|field| field.name() == ROW_ID_FIELD_NAME)
303+
{
304+
if !CoreOptions::new(table.schema().options()).row_tracking_enabled() {
305+
return Err(DataFusionError::Plan(
306+
"vector_search: cannot order results by relevance because _ROW_ID is not available"
307+
.to_string(),
308+
));
309+
}
310+
read_fields.push(DataField::new(
311+
ROW_ID_FIELD_ID,
312+
ROW_ID_FIELD_NAME.to_string(),
313+
DataType::BigInt(BigIntType::with_nullable(true)),
314+
));
315+
}
316+
Ok(read_fields)
317+
}
318+
319+
/// Gather the file-order `batches` into `ranked_row_ids` order (rank == slice index),
320+
/// producing `output_schema` (which excludes `_ROW_ID`). A permutation driven by the
321+
/// index's existing ranking, not a re-sort.
322+
fn gather_rows_by_rank(
323+
batches: &[RecordBatch],
324+
ranked_row_ids: &[u64],
325+
output_schema: &ArrowSchemaRef,
326+
) -> DFResult<RecordBatch> {
327+
let input_schema = batches.first().map(|batch| batch.schema()).ok_or_else(|| {
328+
DataFusionError::Internal("vector_search: no rows materialized".to_string())
329+
})?;
330+
let combined = arrow_select::concat::concat_batches(&input_schema, batches)
331+
.map_err(DataFusionError::from)?;
332+
333+
let row_id_index = combined.schema().index_of(ROW_ID_FIELD_NAME).map_err(|_| {
334+
DataFusionError::Internal(format!(
335+
"vector_search: materialized rows are missing the {ROW_ID_FIELD_NAME} column"
336+
))
337+
})?;
338+
let row_ids = combined
339+
.column(row_id_index)
340+
.as_any()
341+
.downcast_ref::<Int64Array>()
342+
.ok_or_else(|| {
343+
DataFusionError::Internal(format!("vector_search: {ROW_ID_FIELD_NAME} must be Int64"))
344+
})?;
345+
346+
// Map global row id -> physical position in the materialized batch.
347+
let mut position_of: HashMap<i64, u32> = HashMap::with_capacity(combined.num_rows());
348+
for row in 0..combined.num_rows() {
349+
if !row_ids.is_null(row) {
350+
position_of.insert(row_ids.value(row), row as u32);
351+
}
352+
}
353+
354+
// Emit in rank order; extra scanned rows are ignored, but a missing scored id
355+
// fails loud rather than silently shrinking the top-k.
356+
let mut take_indices: Vec<u32> = Vec::with_capacity(ranked_row_ids.len());
357+
for &row_id in ranked_row_ids {
358+
let position = position_of.get(&(row_id as i64)).ok_or_else(|| {
359+
DataFusionError::Internal(format!(
360+
"vector_search: scored row id {row_id} was not materialized; \
361+
cannot return the requested top-k"
362+
))
363+
})?;
364+
take_indices.push(*position);
365+
}
366+
let take_indices = UInt32Array::from(take_indices);
367+
let row_count = take_indices.len();
368+
369+
let columns = output_schema
370+
.fields()
371+
.iter()
372+
.map(|field| -> DFResult<ArrayRef> {
373+
let index = combined.schema().index_of(field.name()).map_err(|_| {
374+
DataFusionError::Internal(format!(
375+
"vector_search: materialized rows are missing expected column '{}'",
376+
field.name()
377+
))
378+
})?;
379+
arrow_select::take::take(combined.column(index).as_ref(), &take_indices, None)
380+
.map_err(DataFusionError::from)
381+
})
382+
.collect::<DFResult<Vec<_>>>()?;
383+
384+
// Preserve the row count for a zero-column projection (e.g. `COUNT(*)`).
385+
let options = RecordBatchOptions::new().with_row_count(Some(row_count));
386+
RecordBatch::try_new_with_options(Arc::clone(output_schema), columns, &options)
387+
.map_err(DataFusionError::from)
388+
}

crates/integrations/datafusion/tests/read_tables.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,6 +1877,22 @@ mod vector_search_tests {
18771877
ids
18781878
}
18791879

1880+
/// Like [`extract_ids`] but preserves the row order returned by the query (used to
1881+
/// assert relevance ordering).
1882+
fn extract_ids_in_order(batches: &[datafusion::arrow::record_batch::RecordBatch]) -> Vec<i32> {
1883+
let mut ids = Vec::new();
1884+
for batch in batches {
1885+
let id_array = batch
1886+
.column_by_name("id")
1887+
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
1888+
.expect("Expected Int32Array for id");
1889+
for i in 0..batch.num_rows() {
1890+
ids.push(id_array.value(i));
1891+
}
1892+
}
1893+
ids
1894+
}
1895+
18801896
fn extract_query_result_ids(
18811897
batches: &[datafusion::arrow::record_batch::RecordBatch],
18821898
) -> Vec<(i32, i32)> {
@@ -1961,6 +1977,97 @@ mod vector_search_tests {
19611977
assert!(ids.contains(&0), "exact match [1,0,0,0] should be in top 3");
19621978
}
19631979

1980+
/// `vector_search` must return rows in relevance order, not file order. Querying
1981+
/// id 2's vector ranks `[2, 5, 1]` by L2 distance, which differs from the
1982+
/// ascending-id file order `[1, 2, 5]` the pre-fix scan produced.
1983+
#[tokio::test]
1984+
async fn test_vector_search_orders_by_relevance() {
1985+
let (ctx, catalog, _tmp) = create_empty_vector_search_context().await;
1986+
let identifier = Identifier::new("default", "vindex_order_e2e");
1987+
catalog
1988+
.create_table(&identifier, build_vindex_table_schema(), false)
1989+
.await
1990+
.expect("Failed to create table");
1991+
let table = catalog
1992+
.get_table(&identifier)
1993+
.await
1994+
.expect("Failed to load table");
1995+
1996+
let write_builder = table
1997+
.new_write_builder()
1998+
.with_commit_user("test-user")
1999+
.expect("Failed to configure write builder");
2000+
let mut table_write = write_builder
2001+
.new_write()
2002+
.expect("Failed to create table write");
2003+
table_write
2004+
.write_arrow_batch(&build_vector_batch(
2005+
vec![0, 1, 2, 3, 4, 5],
2006+
vec![
2007+
vec![1.0, 0.0],
2008+
vec![0.9, 0.1],
2009+
vec![0.0, 1.0],
2010+
vec![-1.0, 0.0],
2011+
vec![0.0, -1.0],
2012+
vec![0.7, 0.3],
2013+
],
2014+
))
2015+
.await
2016+
.expect("Failed to write vector batch");
2017+
let messages = table_write
2018+
.prepare_commit()
2019+
.await
2020+
.expect("Failed to prepare commit");
2021+
write_builder
2022+
.new_commit()
2023+
.commit(messages)
2024+
.await
2025+
.expect("Failed to commit vector data");
2026+
2027+
ctx.sql(
2028+
"CALL sys.create_global_index( \
2029+
table => 'default.vindex_order_e2e', \
2030+
index_column => 'embedding', \
2031+
index_type => 'ivf-flat', \
2032+
options => 'ivf-flat.dimension=2,ivf-flat.nlist=1,ivf-flat.distance.metric=l2')",
2033+
)
2034+
.await
2035+
.expect("vindex index build SQL should parse")
2036+
.collect()
2037+
.await
2038+
.expect("vindex index build SQL should execute");
2039+
2040+
let batches = ctx
2041+
.sql("SELECT id FROM vector_search('paimon.default.vindex_order_e2e', 'embedding', '[0.0, 1.0]', 3)")
2042+
.await
2043+
.expect("SQL should parse")
2044+
.collect()
2045+
.await
2046+
.expect("query should execute");
2047+
2048+
let ordered = extract_ids_in_order(&batches);
2049+
assert_eq!(
2050+
ordered,
2051+
vec![2, 5, 1],
2052+
"vector_search must return rows best-first by relevance, not in file order"
2053+
);
2054+
2055+
// The full-projection path (`SELECT *`, i.e. projection = None) must stay
2056+
// ordered too, and round-trip the other columns.
2057+
let star_batches = ctx
2058+
.sql("SELECT * FROM vector_search('paimon.default.vindex_order_e2e', 'embedding', '[0.0, 1.0]', 3)")
2059+
.await
2060+
.expect("SQL should parse")
2061+
.collect()
2062+
.await
2063+
.expect("query should execute");
2064+
assert_eq!(
2065+
extract_ids_in_order(&star_batches),
2066+
vec![2, 5, 1],
2067+
"SELECT * must also be relevance-ordered"
2068+
);
2069+
}
2070+
19642071
#[tokio::test]
19652072
async fn test_vector_search_branch() {
19662073
let (ctx, catalog, _tmp) = create_java_vindex_vector_search_context().await;

0 commit comments

Comments
 (0)