1818use std:: fmt:: Debug ;
1919use std:: sync:: Arc ;
2020
21+ use std:: collections:: HashMap ;
22+
2123use async_trait:: async_trait;
24+ use datafusion:: arrow:: array:: {
25+ Array , ArrayRef , Int64Array , RecordBatch , RecordBatchOptions , UInt32Array ,
26+ } ;
2227use datafusion:: arrow:: datatypes:: SchemaRef as ArrowSchemaRef ;
2328use datafusion:: catalog:: Session ;
2429use datafusion:: catalog:: TableFunctionImpl ;
2530use datafusion:: common:: project_schema;
31+ use datafusion:: datasource:: memory:: MemorySourceConfig ;
2632use datafusion:: datasource:: { TableProvider , TableType } ;
2733use datafusion:: error:: { DataFusionError , Result as DFResult } ;
2834use datafusion:: logical_expr:: { Expr , TableProviderFilterPushDown } ;
2935use datafusion:: physical_plan:: empty:: EmptyExec ;
3036use datafusion:: physical_plan:: ExecutionPlan ;
3137use datafusion:: prelude:: SessionContext ;
38+ use futures:: TryStreamExt ;
3239use paimon:: catalog:: Catalog ;
40+ use paimon:: spec:: {
41+ BigIntType , CoreOptions , DataField , DataType , ROW_ID_FIELD_ID , ROW_ID_FIELD_NAME ,
42+ } ;
3343
3444use crate :: error:: to_datafusion_error;
3545use crate :: runtime:: { await_with_runtime, block_on_with_runtime} ;
36- use crate :: table:: { PaimonScanBuilder , PaimonTableProvider } ;
46+ use crate :: table:: { datafusion_read_fields , PaimonTableProvider } ;
3747use 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+ }
0 commit comments