This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 885dd29c fix(datafusion): return vector_search rows in best-first
relevance order (#613)
885dd29c is described below
commit 885dd29ceee509024f75674761fe362842ced57c
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Jul 27 09:59:53 2026 +0800
fix(datafusion): return vector_search rows in best-first relevance order
(#613)
---
.../integrations/datafusion/src/vector_search.rs | 351 ++++++++++++++++++---
.../integrations/datafusion/tests/read_tables.rs | 232 +++++++++++++-
2 files changed, 541 insertions(+), 42 deletions(-)
diff --git a/crates/integrations/datafusion/src/vector_search.rs
b/crates/integrations/datafusion/src/vector_search.rs
index 897b385f..cdfed832 100644
--- a/crates/integrations/datafusion/src/vector_search.rs
+++ b/crates/integrations/datafusion/src/vector_search.rs
@@ -15,25 +15,42 @@
// specific language governing permissions and limitations
// under the License.
-use std::fmt::Debug;
+use std::collections::HashMap;
+use std::fmt::{self, Debug};
use std::sync::Arc;
use async_trait::async_trait;
+use datafusion::arrow::array::{
+ Array, ArrayRef, Int64Array, RecordBatch, RecordBatchOptions, UInt32Array,
+};
+use datafusion::arrow::compute::cast;
use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
use datafusion::catalog::Session;
use datafusion::catalog::TableFunctionImpl;
-use datafusion::common::project_schema;
+use datafusion::common::stats::Precision;
+use datafusion::common::{internal_err, project_schema, Statistics};
use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::execution::{SendableRecordBatchStream, TaskContext};
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
+use datafusion::physical_expr::EquivalenceProperties;
use datafusion::physical_plan::empty::EmptyExec;
-use datafusion::physical_plan::ExecutionPlan;
+use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
+use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+use datafusion::physical_plan::{
+ DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
+};
use datafusion::prelude::SessionContext;
+use futures::{stream, TryStreamExt};
use paimon::catalog::Catalog;
+use paimon::spec::{
+ BigIntType, CoreOptions, DataField, DataType, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
+};
+use paimon::table::Table;
use crate::error::to_datafusion_error;
use crate::runtime::{await_with_runtime, block_on_with_runtime};
-use crate::table::{PaimonScanBuilder, PaimonTableProvider};
+use crate::table::{datafusion_read_fields, PaimonTableProvider};
use crate::table_function_args::{
extract_int_literal, extract_string_literal, parse_table_identifier,
};
@@ -207,60 +224,314 @@ impl TableProvider for VectorSearchTableProvider {
async fn scan(
&self,
- state: &dyn Session,
+ _state: &dyn Session,
projection: Option<&Vec<usize>>,
_filters: &[Expr],
limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
- let table = self.inner.table();
+ let projected_schema = project_schema(&self.schema(), projection)?;
+
+ // An outer `LIMIT 0` needs no rows.
+ if limit == Some(0) {
+ return Ok(Arc::new(EmptyExec::new(projected_schema)));
+ }
- let row_ranges = await_with_runtime(async {
- let mut builder = table.new_vector_search_builder();
+ // The search runs with the table function's own top-k (`self.limit`)
so the ANN
+ // recall/search width is unchanged; the outer DataFusion `limit` only
truncates
+ // the already-ranked result before any rows are read (so a large
top-k with a
+ // small outer LIMIT doesn't read/materialize everything). All of this
— search,
+ // read and rank-order gather — runs at execution time in the exec's
stream, so
+ // planning / EXPLAIN stays cheap and the work is driven by the
TaskContext.
+ Ok(Arc::new(VectorSearchExec::new(
+ self.inner.table().clone(),
+ self.column_name.clone(),
+ self.query_vector.clone(),
+ self.limit,
+ limit,
+ projection.cloned(),
+ projected_schema,
+ )))
+ }
+
+ fn supports_filters_pushdown(
+ &self,
+ filters: &[&Expr],
+ ) -> DFResult<Vec<TableProviderFilterPushDown>> {
+ Ok(vec![
+ TableProviderFilterPushDown::Unsupported;
+ filters.len()
+ ])
+ }
+}
+
+/// Execution-time plan for `vector_search`: runs the ANN search, reads the
matching
+/// rows, and gathers them into best-first relevance order when its stream is
polled,
+/// so planning (and `EXPLAIN`) stays cheap and the work runs under
DataFusion's
+/// `TaskContext`.
+#[derive(Debug, Clone)]
+struct VectorSearchExec {
+ table: Table,
+ column_name: String,
+ query_vector: Vec<f32>,
+ /// The table function's own top-k — drives the ANN search width; never
reduced.
+ search_limit: usize,
+ /// The outer DataFusion `LIMIT`, applied by truncating the ranked result.
+ output_limit: Option<usize>,
+ projection: Option<Vec<usize>>,
+ output_schema: ArrowSchemaRef,
+ plan_properties: Arc<PlanProperties>,
+}
+
+impl VectorSearchExec {
+ fn new(
+ table: Table,
+ column_name: String,
+ query_vector: Vec<f32>,
+ search_limit: usize,
+ output_limit: Option<usize>,
+ projection: Option<Vec<usize>>,
+ output_schema: ArrowSchemaRef,
+ ) -> Self {
+ let plan_properties = Arc::new(PlanProperties::new(
+ EquivalenceProperties::new(output_schema.clone()),
+ Partitioning::UnknownPartitioning(1),
+ EmissionType::Incremental,
+ Boundedness::Bounded,
+ ));
+ Self {
+ table,
+ column_name,
+ query_vector,
+ search_limit,
+ output_limit,
+ projection,
+ output_schema,
+ plan_properties,
+ }
+ }
+
+ async fn compute_batch(&self) -> DFResult<RecordBatch> {
+ // Best-first row-ids from the index, searched at the full top-k so
the ANN
+ // recall is unchanged (data-evolution / global-index path; PK-vector
tables are
+ // unsupported here, as before).
+ let mut search_result = await_with_runtime(async {
+ let mut builder = self.table.new_vector_search_builder();
builder
.with_vector_column(&self.column_name)
.with_query_vector(self.query_vector.clone())
- .with_limit(self.limit);
- builder.execute().await.map_err(to_datafusion_error)
+ .with_limit(self.search_limit);
+ builder.execute_scored().await.map_err(to_datafusion_error)
})
.await?;
- if row_ranges.is_empty() {
- let schema = project_schema(&self.schema(), projection)?;
- return Ok(Arc::new(EmptyExec::new(schema)));
+ if search_result.is_empty() {
+ return Ok(RecordBatch::new_empty(self.output_schema.clone()));
}
- let mut read_builder = table.new_read_builder();
- if let Some(limit) = limit {
- read_builder.with_limit(limit);
+ // Apply the outer LIMIT by truncating the ranked result *before*
reading, so a
+ // large top-k with a small outer LIMIT only reads/materializes the
rows it can
+ // return (the search itself is unaffected).
+ if let Some(n) = self.output_limit {
+ if search_result.row_ids.len() > n {
+ search_result.row_ids.truncate(n);
+ search_result.scores.truncate(n);
+ }
}
- let scan = read_builder.new_scan().with_row_ranges(row_ranges);
- let plan = await_with_runtime(scan.plan())
- .await
- .map_err(to_datafusion_error)?;
- let target = state.config_options().execution.target_partitions;
- PaimonScanBuilder {
- table,
- schema: &self.schema(),
- plan: &plan,
- scan_trace: None,
- projection,
- pushed_predicate: None,
- limit,
- target_partitions: target,
- filter_exact: false,
- case_sensitive: true,
+ // Read the projected columns (+ internal `_ROW_ID`); the row-range
scan yields
+ // file order, realigned to relevance rank below.
+ let read_fields = projected_read_fields(&self.table,
self.projection.as_ref())?;
+ let row_ranges =
search_result.to_row_ranges().map_err(to_datafusion_error)?;
+ let batches = await_with_runtime(async {
+ let mut read_builder = self.table.new_read_builder();
+ read_builder
+ .with_read_type(read_fields)
+ .with_row_ranges(row_ranges);
+ let scan = read_builder.new_scan();
+ let plan = scan.plan().await.map_err(to_datafusion_error)?;
+ let table_read =
read_builder.new_read().map_err(to_datafusion_error)?;
+ let mut stream = table_read
+ .to_arrow(plan.splits())
+ .map_err(to_datafusion_error)?;
+ let mut batches: Vec<RecordBatch> = Vec::new();
+ while let Some(batch) =
stream.try_next().await.map_err(to_datafusion_error)? {
+ batches.push(batch);
+ }
+ Ok::<_, DataFusionError>(batches)
+ })
+ .await?;
+
+ // Realign file-order rows to best-first rank and drop `_ROW_ID`.
+ gather_rows_by_rank(&batches, &search_result.row_ids,
&self.output_schema)
+ }
+}
+
+impl DisplayAs for VectorSearchExec {
+ fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) ->
fmt::Result {
+ write!(
+ f,
+ "VectorSearchExec: column={}, search_limit={}, output_limit={:?}",
+ self.column_name, self.search_limit, self.output_limit
+ )
+ }
+}
+
+impl ExecutionPlan for VectorSearchExec {
+ fn name(&self) -> &str {
+ "VectorSearchExec"
+ }
+
+ fn properties(&self) -> &Arc<PlanProperties> {
+ &self.plan_properties
+ }
+
+ fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+ vec![]
+ }
+
+ fn with_new_children(
+ self: Arc<Self>,
+ children: Vec<Arc<dyn ExecutionPlan>>,
+ ) -> DFResult<Arc<dyn ExecutionPlan>> {
+ if !children.is_empty() {
+ return internal_err!("VectorSearchExec is a leaf and takes no
children");
}
- .build()
+ Ok(self)
}
- fn supports_filters_pushdown(
+ fn execute(
&self,
- filters: &[&Expr],
- ) -> DFResult<Vec<TableProviderFilterPushDown>> {
- Ok(vec![
- TableProviderFilterPushDown::Unsupported;
- filters.len()
- ])
+ partition: usize,
+ _context: Arc<TaskContext>,
+ ) -> DFResult<SendableRecordBatchStream> {
+ if partition != 0 {
+ return internal_err!(
+ "VectorSearchExec has a single partition, got partition
{partition}"
+ );
+ }
+ let exec = self.clone();
+ let stream = stream::once(async move { exec.compute_batch().await });
+ Ok(Box::pin(RecordBatchStreamAdapter::new(
+ self.output_schema.clone(),
+ Box::pin(stream),
+ )))
+ }
+
+ fn partition_statistics(&self, _partition: Option<usize>) ->
DFResult<Arc<Statistics>> {
+ Ok(Arc::new(Statistics {
+ num_rows: Precision::Absent,
+ total_byte_size: Precision::Absent,
+ column_statistics: Statistics::unknown_column(&self.output_schema),
+ }))
+ }
+}
+
+/// Projected user columns (+ internal `_ROW_ID`, needed to realign rows to
rank).
+/// Errors if the table has no row tracking, since results then can't be
ordered.
+fn projected_read_fields(
+ table: &paimon::table::Table,
+ projection: Option<&Vec<usize>>,
+) -> DFResult<Vec<DataField>> {
+ let base_fields = datafusion_read_fields(table);
+ let mut read_fields: Vec<DataField> = match projection {
+ Some(indices) => indices.iter().map(|&i|
base_fields[i].clone()).collect(),
+ None => base_fields,
+ };
+ if !read_fields
+ .iter()
+ .any(|field| field.name() == ROW_ID_FIELD_NAME)
+ {
+ if !CoreOptions::new(table.schema().options()).row_tracking_enabled() {
+ return Err(DataFusionError::Plan(
+ "vector_search: cannot order results by relevance because
_ROW_ID is not available"
+ .to_string(),
+ ));
+ }
+ read_fields.push(DataField::new(
+ ROW_ID_FIELD_ID,
+ ROW_ID_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::with_nullable(true)),
+ ));
+ }
+ Ok(read_fields)
+}
+
+/// Gather the file-order `batches` into `ranked_row_ids` order (rank == slice
index),
+/// producing `output_schema` (which excludes `_ROW_ID`). A permutation driven
by the
+/// index's existing ranking, not a re-sort.
+fn gather_rows_by_rank(
+ batches: &[RecordBatch],
+ ranked_row_ids: &[u64],
+ output_schema: &ArrowSchemaRef,
+) -> DFResult<RecordBatch> {
+ let input_schema = batches.first().map(|batch|
batch.schema()).ok_or_else(|| {
+ DataFusionError::Internal("vector_search: no rows
materialized".to_string())
+ })?;
+ let combined = arrow_select::concat::concat_batches(&input_schema, batches)
+ .map_err(DataFusionError::from)?;
+
+ let row_id_index =
combined.schema().index_of(ROW_ID_FIELD_NAME).map_err(|_| {
+ DataFusionError::Internal(format!(
+ "vector_search: materialized rows are missing the
{ROW_ID_FIELD_NAME} column"
+ ))
+ })?;
+ let row_ids = combined
+ .column(row_id_index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| {
+ DataFusionError::Internal(format!("vector_search:
{ROW_ID_FIELD_NAME} must be Int64"))
+ })?;
+
+ // Map global row id -> physical position in the materialized batch.
+ let mut position_of: HashMap<i64, u32> =
HashMap::with_capacity(combined.num_rows());
+ for row in 0..combined.num_rows() {
+ if !row_ids.is_null(row) {
+ position_of.insert(row_ids.value(row), row as u32);
+ }
+ }
+
+ // Emit in rank order; extra scanned rows are ignored, but a missing
scored id
+ // fails loud rather than silently shrinking the top-k.
+ let mut take_indices: Vec<u32> = Vec::with_capacity(ranked_row_ids.len());
+ for &row_id in ranked_row_ids {
+ let position = position_of.get(&(row_id as i64)).ok_or_else(|| {
+ DataFusionError::Internal(format!(
+ "vector_search: scored row id {row_id} was not materialized; \
+ cannot return the requested top-k"
+ ))
+ })?;
+ take_indices.push(*position);
}
+ let take_indices = UInt32Array::from(take_indices);
+ let row_count = take_indices.len();
+
+ let columns = output_schema
+ .fields()
+ .iter()
+ .map(|field| -> DFResult<ArrayRef> {
+ let index = combined.schema().index_of(field.name()).map_err(|_| {
+ DataFusionError::Internal(format!(
+ "vector_search: materialized rows are missing expected
column '{}'",
+ field.name()
+ ))
+ })?;
+ let taken =
+ arrow_select::take::take(combined.column(index).as_ref(),
&take_indices, None)
+ .map_err(DataFusionError::from)?;
+ // The Paimon read keeps its own arrow types (e.g. `Utf8`), but
the provider
+ // schema may differ (e.g. DataFusion's `Utf8View`); cast to
match, as the
+ // normal scan path does via `to_datafusion_batch`.
+ if taken.data_type() == field.data_type() {
+ Ok(taken)
+ } else {
+ cast(taken.as_ref(),
field.data_type()).map_err(DataFusionError::from)
+ }
+ })
+ .collect::<DFResult<Vec<_>>>()?;
+
+ // Preserve the row count for a zero-column projection (e.g. `COUNT(*)`).
+ let options = RecordBatchOptions::new().with_row_count(Some(row_count));
+ RecordBatch::try_new_with_options(Arc::clone(output_schema), columns,
&options)
+ .map_err(DataFusionError::from)
}
diff --git a/crates/integrations/datafusion/tests/read_tables.rs
b/crates/integrations/datafusion/tests/read_tables.rs
index b4637b5b..e4fc4721 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -1719,14 +1719,16 @@ mod fulltext_tests {
mod vector_search_tests {
use std::sync::Arc;
- use datafusion::arrow::array::{ArrayRef, Float32Builder, Int32Array,
ListBuilder};
+ use datafusion::arrow::array::{
+ ArrayRef, Float32Builder, Int32Array, ListBuilder, StringArray,
+ };
use datafusion::arrow::datatypes::{
DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema,
};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::datasource::MemTable;
use paimon::catalog::Identifier;
- use paimon::spec::{ArrayType, DataType, FloatType, IntType, Schema};
+ use paimon::spec::{ArrayType, DataType, FloatType, IntType, Schema,
VarCharType};
use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{register_vector_search, SQLContext};
@@ -1877,6 +1879,22 @@ mod vector_search_tests {
ids
}
+ /// Like [`extract_ids`] but preserves the row order returned by the query
(used to
+ /// assert relevance ordering).
+ fn extract_ids_in_order(batches:
&[datafusion::arrow::record_batch::RecordBatch]) -> Vec<i32> {
+ let mut ids = Vec::new();
+ for batch in batches {
+ let id_array = batch
+ .column_by_name("id")
+ .and_then(|c| c.as_any().downcast_ref::<Int32Array>())
+ .expect("Expected Int32Array for id");
+ for i in 0..batch.num_rows() {
+ ids.push(id_array.value(i));
+ }
+ }
+ ids
+ }
+
fn extract_query_result_ids(
batches: &[datafusion::arrow::record_batch::RecordBatch],
) -> Vec<(i32, i32)> {
@@ -1961,6 +1979,216 @@ mod vector_search_tests {
assert!(ids.contains(&0), "exact match [1,0,0,0] should be in top 3");
}
+ /// `vector_search` must return rows in relevance order, not file order.
Querying
+ /// id 2's vector ranks `[2, 5, 1]` by L2 distance, which differs from the
+ /// ascending-id file order `[1, 2, 5]` the pre-fix scan produced.
+ #[tokio::test]
+ async fn test_vector_search_orders_by_relevance() {
+ let (ctx, catalog, _tmp) = create_empty_vector_search_context().await;
+ let identifier = Identifier::new("default", "vindex_order_e2e");
+ catalog
+ .create_table(&identifier, build_vindex_table_schema(), false)
+ .await
+ .expect("Failed to create table");
+ let table = catalog
+ .get_table(&identifier)
+ .await
+ .expect("Failed to load table");
+
+ let write_builder = table
+ .new_write_builder()
+ .with_commit_user("test-user")
+ .expect("Failed to configure write builder");
+ let mut table_write = write_builder
+ .new_write()
+ .expect("Failed to create table write");
+ table_write
+ .write_arrow_batch(&build_vector_batch(
+ vec![0, 1, 2, 3, 4, 5],
+ vec![
+ vec![1.0, 0.0],
+ vec![0.9, 0.1],
+ vec![0.0, 1.0],
+ vec![-1.0, 0.0],
+ vec![0.0, -1.0],
+ vec![0.7, 0.3],
+ ],
+ ))
+ .await
+ .expect("Failed to write vector batch");
+ let messages = table_write
+ .prepare_commit()
+ .await
+ .expect("Failed to prepare commit");
+ write_builder
+ .new_commit()
+ .commit(messages)
+ .await
+ .expect("Failed to commit vector data");
+
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.vindex_order_e2e', \
+ index_column => 'embedding', \
+ index_type => 'ivf-flat', \
+ options =>
'ivf-flat.dimension=2,ivf-flat.nlist=1,ivf-flat.distance.metric=l2')",
+ )
+ .await
+ .expect("vindex index build SQL should parse")
+ .collect()
+ .await
+ .expect("vindex index build SQL should execute");
+
+ let batches = ctx
+ .sql("SELECT id FROM
vector_search('paimon.default.vindex_order_e2e', 'embedding', '[0.0, 1.0]', 3)")
+ .await
+ .expect("SQL should parse")
+ .collect()
+ .await
+ .expect("query should execute");
+
+ let ordered = extract_ids_in_order(&batches);
+ assert_eq!(
+ ordered,
+ vec![2, 5, 1],
+ "vector_search must return rows best-first by relevance, not in
file order"
+ );
+
+ // The full-projection path (`SELECT *`, i.e. projection = None) must
stay
+ // ordered too, and round-trip the other columns.
+ let star_batches = ctx
+ .sql("SELECT * FROM
vector_search('paimon.default.vindex_order_e2e', 'embedding', '[0.0, 1.0]', 3)")
+ .await
+ .expect("SQL should parse")
+ .collect()
+ .await
+ .expect("query should execute");
+ assert_eq!(
+ extract_ids_in_order(&star_batches),
+ vec![2, 5, 1],
+ "SELECT * must also be relevance-ordered"
+ );
+
+ // An outer LIMIT truncates the ranked result: top-2 of [2, 5, 1] is
[2, 5].
+ let limited = ctx
+ .sql("SELECT id FROM
vector_search('paimon.default.vindex_order_e2e', 'embedding', '[0.0, 1.0]', 3)
LIMIT 2")
+ .await
+ .expect("SQL should parse")
+ .collect()
+ .await
+ .expect("query should execute");
+ assert_eq!(extract_ids_in_order(&limited), vec![2, 5]);
+ }
+
+ /// Regression: a result containing a string column must not fail on the
provider's
+ /// `Utf8View` schema vs the Paimon read's `Utf8` — the gather casts to
the output
+ /// schema (the plain scan path did this via `to_datafusion_batch`).
+ #[tokio::test]
+ async fn test_vector_search_casts_string_column() {
+ let (ctx, catalog, _tmp) = create_empty_vector_search_context().await;
+ let identifier = Identifier::new("default", "vindex_string_e2e");
+
+ let mut options = std::collections::HashMap::new();
+ options.insert("row-tracking.enabled".to_string(), "true".to_string());
+ options.insert("data-evolution.enabled".to_string(),
"true".to_string());
+ options.insert("global-index.enabled".to_string(), "true".to_string());
+ options.insert(
+ "global-index.row-count-per-shard".to_string(),
+ "3".to_string(),
+ );
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::new(64).unwrap()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .options(options)
+ .build()
+ .expect("Failed to build table schema");
+ catalog
+ .create_table(&identifier, schema, false)
+ .await
+ .expect("Failed to create table");
+ let table = catalog
+ .get_table(&identifier)
+ .await
+ .expect("Failed to load table");
+
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut vector_builder =
+
ListBuilder::new(Float32Builder::new()).with_field(element_field.clone());
+ for vector in [vec![1.0, 0.0], vec![0.0, 1.0], vec![0.7, 0.3]] {
+ for value in vector {
+ vector_builder.values().append_value(value);
+ }
+ vector_builder.append(true);
+ }
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("name", ArrowDataType::Utf8, true),
+ ArrowField::new("embedding", ArrowDataType::List(element_field),
true),
+ ]));
+ let batch = RecordBatch::try_new(
+ arrow_schema,
+ vec![
+ Arc::new(Int32Array::from(vec![0, 1, 2])) as ArrayRef,
+ Arc::new(StringArray::from(vec!["zero", "one", "two"])) as
ArrayRef,
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ ],
+ )
+ .expect("Failed to build batch");
+
+ let write_builder = table
+ .new_write_builder()
+ .with_commit_user("test-user")
+ .expect("Failed to configure write builder");
+ let mut table_write = write_builder
+ .new_write()
+ .expect("Failed to create table write");
+ table_write
+ .write_arrow_batch(&batch)
+ .await
+ .expect("Failed to write batch");
+ let messages = table_write
+ .prepare_commit()
+ .await
+ .expect("Failed to prepare commit");
+ write_builder
+ .new_commit()
+ .commit(messages)
+ .await
+ .expect("Failed to commit data");
+
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.vindex_string_e2e', \
+ index_column => 'embedding', \
+ index_type => 'ivf-flat', \
+ options =>
'ivf-flat.dimension=2,ivf-flat.nlist=1,ivf-flat.distance.metric=l2')",
+ )
+ .await
+ .expect("vindex index build SQL should parse")
+ .collect()
+ .await
+ .expect("vindex index build SQL should execute");
+
+ // The point of this test is that the string `name` column round-trips
without
+ // a Utf8/Utf8View schema-cast error (guarded by the successful
collect below).
+ // Relevance ordering is covered by
test_vector_search_orders_by_relevance, so
+ // here we only assert the hit set, order-independently.
+ let batches = ctx
+ .sql("SELECT id, name FROM
vector_search('paimon.default.vindex_string_e2e', 'embedding', '[0.0, 1.0]',
2)")
+ .await
+ .expect("SQL should parse")
+ .collect()
+ .await
+ .expect("query with a string column should execute");
+ let mut ids = extract_ids_in_order(&batches);
+ ids.sort();
+ assert_eq!(ids, vec![1, 2]);
+ }
+
#[tokio::test]
async fn test_vector_search_branch() {
let (ctx, catalog, _tmp) =
create_java_vindex_vector_search_context().await;