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,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+ }
0 commit comments