Skip to content

Commit 11afe92

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 11afe92

2 files changed

Lines changed: 272 additions & 31 deletions

File tree

crates/integrations/datafusion/src/vector_search.rs

Lines changed: 163 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,67 @@ 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+
// Run the ANN search. `execute_scored` returns the matching global row-ids in
229+
// best-first relevance order together with their scores. (This is the
230+
// data-evolution / global-index path, matching the previous behavior of this
231+
// table function; primary-key vector tables are not supported here.)
232+
let search_result = await_with_runtime(async {
218233
let mut builder = table.new_vector_search_builder();
219234
builder
220235
.with_vector_column(&self.column_name)
221236
.with_query_vector(self.query_vector.clone())
222237
.with_limit(self.limit);
223-
builder.execute().await.map_err(to_datafusion_error)
238+
builder.execute_scored().await.map_err(to_datafusion_error)
224239
})
225240
.await?;
226241

227-
if row_ranges.is_empty() {
228-
let schema = project_schema(&self.schema(), projection)?;
229-
return Ok(Arc::new(EmptyExec::new(schema)));
242+
if search_result.is_empty() {
243+
return Ok(Arc::new(EmptyExec::new(projected_schema)));
230244
}
231245

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()
246+
// Read only the projected columns (projection pushdown), plus the internal
247+
// `_ROW_ID` column. A raw row-range scan returns rows in physical file order,
248+
// so `_ROW_ID` is used to realign each row with its relevance rank below.
249+
let read_fields = projected_read_fields(table, projection)?;
250+
let row_ranges = search_result.to_row_ranges().map_err(to_datafusion_error)?;
251+
let batches = await_with_runtime(async {
252+
let mut read_builder = table.new_read_builder();
253+
read_builder
254+
.with_read_type(read_fields)
255+
.with_row_ranges(row_ranges);
256+
let scan = read_builder.new_scan();
257+
let plan = scan.plan().await.map_err(to_datafusion_error)?;
258+
let table_read = read_builder.new_read().map_err(to_datafusion_error)?;
259+
let mut stream = table_read
260+
.to_arrow(plan.splits())
261+
.map_err(to_datafusion_error)?;
262+
let mut batches: Vec<RecordBatch> = Vec::new();
263+
while let Some(batch) = stream.try_next().await.map_err(to_datafusion_error)? {
264+
batches.push(batch);
265+
}
266+
Ok::<_, DataFusionError>(batches)
267+
})
268+
.await?;
269+
270+
// Realign the file-order rows to the index's best-first relevance rank and drop
271+
// the internal `_ROW_ID` column, producing exactly the projected output schema.
272+
// This is what makes `SELECT * FROM vector_search(...)` honor the documented
273+
// "top-k rows ordered by relevance score" contract.
274+
let ordered = gather_rows_by_rank(&batches, &search_result.row_ids, &projected_schema)?;
275+
276+
Ok(MemorySourceConfig::try_new_exec(
277+
&[vec![ordered]],
278+
projected_schema,
279+
None,
280+
)?)
255281
}
256282

257283
fn supports_filters_pushdown(
@@ -264,3 +290,109 @@ impl TableProvider for VectorSearchTableProvider {
264290
])
265291
}
266292
}
293+
294+
/// The projected user columns to read (projection pushdown), in projection order,
295+
/// plus the internal `_ROW_ID` column used to realign rows with their relevance rank.
296+
/// Fails loud if the table does not track row ids (then results cannot be ordered by
297+
/// relevance).
298+
fn projected_read_fields(
299+
table: &paimon::table::Table,
300+
projection: Option<&Vec<usize>>,
301+
) -> DFResult<Vec<DataField>> {
302+
let base_fields = datafusion_read_fields(table);
303+
let mut read_fields: Vec<DataField> = match projection {
304+
Some(indices) => indices.iter().map(|&i| base_fields[i].clone()).collect(),
305+
None => base_fields,
306+
};
307+
if !read_fields.iter().any(|field| field.name() == ROW_ID_FIELD_NAME) {
308+
if !CoreOptions::new(table.schema().options()).row_tracking_enabled() {
309+
return Err(DataFusionError::Plan(
310+
"vector_search: cannot order results by relevance because _ROW_ID is not available"
311+
.to_string(),
312+
));
313+
}
314+
read_fields.push(DataField::new(
315+
ROW_ID_FIELD_ID,
316+
ROW_ID_FIELD_NAME.to_string(),
317+
DataType::BigInt(BigIntType::with_nullable(true)),
318+
));
319+
}
320+
Ok(read_fields)
321+
}
322+
323+
/// Realign the file-order rows in `batches` to the best-first relevance order given by
324+
/// `ranked_row_ids` (rank == index in the slice), producing exactly `output_schema`
325+
/// (which excludes the internal `_ROW_ID` column). This is a gather driven by the
326+
/// index's existing ranking, not a re-sort.
327+
fn gather_rows_by_rank(
328+
batches: &[RecordBatch],
329+
ranked_row_ids: &[u64],
330+
output_schema: &ArrowSchemaRef,
331+
) -> DFResult<RecordBatch> {
332+
let input_schema = batches
333+
.first()
334+
.map(|batch| batch.schema())
335+
.ok_or_else(|| DataFusionError::Internal("vector_search: no rows materialized".to_string()))?;
336+
let combined = arrow_select::concat::concat_batches(&input_schema, batches)
337+
.map_err(DataFusionError::from)?;
338+
339+
let row_id_index = combined.schema().index_of(ROW_ID_FIELD_NAME).map_err(|_| {
340+
DataFusionError::Internal(format!(
341+
"vector_search: materialized rows are missing the {ROW_ID_FIELD_NAME} column"
342+
))
343+
})?;
344+
let row_ids = combined
345+
.column(row_id_index)
346+
.as_any()
347+
.downcast_ref::<Int64Array>()
348+
.ok_or_else(|| {
349+
DataFusionError::Internal(format!("vector_search: {ROW_ID_FIELD_NAME} must be Int64"))
350+
})?;
351+
352+
// Map global row id -> physical position in the materialized batch.
353+
let mut position_of: HashMap<i64, u32> = HashMap::with_capacity(combined.num_rows());
354+
for row in 0..combined.num_rows() {
355+
if !row_ids.is_null(row) {
356+
position_of.insert(row_ids.value(row), row as u32);
357+
}
358+
}
359+
360+
// Emit rows strictly in relevance-rank order, driven by the scored row ids. A raw
361+
// row-range scan may conservatively return extra rows outside the scored set;
362+
// those are simply never referenced. But every scored id must be materialized —
363+
// a missing one would silently shrink the top-k result, so fail loud instead
364+
// (matching the Paimon DE vector read path).
365+
let mut take_indices: Vec<u32> = Vec::with_capacity(ranked_row_ids.len());
366+
for &row_id in ranked_row_ids {
367+
let position = position_of.get(&(row_id as i64)).ok_or_else(|| {
368+
DataFusionError::Internal(format!(
369+
"vector_search: scored row id {row_id} was not materialized; \
370+
cannot return the requested top-k"
371+
))
372+
})?;
373+
take_indices.push(*position);
374+
}
375+
let take_indices = UInt32Array::from(take_indices);
376+
let row_count = take_indices.len();
377+
378+
let columns = output_schema
379+
.fields()
380+
.iter()
381+
.map(|field| -> DFResult<ArrayRef> {
382+
let index = combined.schema().index_of(field.name()).map_err(|_| {
383+
DataFusionError::Internal(format!(
384+
"vector_search: materialized rows are missing expected column '{}'",
385+
field.name()
386+
))
387+
})?;
388+
arrow_select::take::take(combined.column(index).as_ref(), &take_indices, None)
389+
.map_err(DataFusionError::from)
390+
})
391+
.collect::<DFResult<Vec<_>>>()?;
392+
393+
// `with_row_count` keeps the row count correct even for a zero-column projection
394+
// (e.g. `COUNT(*)`), where `columns` is empty.
395+
let options = RecordBatchOptions::new().with_row_count(Some(row_count));
396+
RecordBatch::try_new_with_options(Arc::clone(output_schema), columns, &options)
397+
.map_err(DataFusionError::from)
398+
}

crates/integrations/datafusion/tests/read_tables.rs

Lines changed: 109 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,99 @@ mod vector_search_tests {
19611977
assert!(ids.contains(&0), "exact match [1,0,0,0] should be in top 3");
19621978
}
19631979

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

0 commit comments

Comments
 (0)