|
15 | 15 | // specific language governing permissions and limitations |
16 | 16 | // under the License. |
17 | 17 |
|
18 | | -use std::fmt::Debug; |
| 18 | +use std::collections::HashMap; |
| 19 | +use std::fmt::{self, Debug}; |
19 | 20 | use std::sync::Arc; |
20 | 21 |
|
21 | 22 | use async_trait::async_trait; |
| 23 | +use datafusion::arrow::array::{ |
| 24 | + Array, ArrayRef, Int64Array, RecordBatch, RecordBatchOptions, UInt32Array, |
| 25 | +}; |
| 26 | +use datafusion::arrow::compute::cast; |
22 | 27 | use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef; |
23 | 28 | use datafusion::catalog::Session; |
24 | 29 | 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}; |
26 | 32 | use datafusion::datasource::{TableProvider, TableType}; |
27 | 33 | use datafusion::error::{DataFusionError, Result as DFResult}; |
| 34 | +use datafusion::execution::{SendableRecordBatchStream, TaskContext}; |
28 | 35 | use datafusion::logical_expr::{Expr, TableProviderFilterPushDown}; |
| 36 | +use datafusion::physical_expr::EquivalenceProperties; |
29 | 37 | 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 | +}; |
31 | 43 | use datafusion::prelude::SessionContext; |
| 44 | +use futures::{stream, TryStreamExt}; |
32 | 45 | 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; |
33 | 50 |
|
34 | 51 | use crate::error::to_datafusion_error; |
35 | 52 | use crate::runtime::{await_with_runtime, block_on_with_runtime}; |
36 | | -use crate::table::{PaimonScanBuilder, PaimonTableProvider}; |
| 53 | +use crate::table::{datafusion_read_fields, PaimonTableProvider}; |
37 | 54 | use crate::table_function_args::{ |
38 | 55 | extract_int_literal, extract_string_literal, parse_table_identifier, |
39 | 56 | }; |
@@ -207,60 +224,314 @@ impl TableProvider for VectorSearchTableProvider { |
207 | 224 |
|
208 | 225 | async fn scan( |
209 | 226 | &self, |
210 | | - state: &dyn Session, |
| 227 | + _state: &dyn Session, |
211 | 228 | projection: Option<&Vec<usize>>, |
212 | 229 | _filters: &[Expr], |
213 | 230 | limit: Option<usize>, |
214 | 231 | ) -> 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 | + } |
216 | 238 |
|
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(); |
219 | 319 | builder |
220 | 320 | .with_vector_column(&self.column_name) |
221 | 321 | .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) |
224 | 324 | }) |
225 | 325 | .await?; |
226 | 326 |
|
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())); |
230 | 329 | } |
231 | 330 |
|
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 | + } |
235 | 339 | } |
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 | 340 |
|
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"); |
253 | 398 | } |
254 | | - .build() |
| 399 | + Ok(self) |
255 | 400 | } |
256 | 401 |
|
257 | | - fn supports_filters_pushdown( |
| 402 | + fn execute( |
258 | 403 | &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); |
265 | 505 | } |
| 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) |
266 | 537 | } |
0 commit comments