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 ddd87921 [c] Support vector search materialized read in C FFI bindings
(#537)
ddd87921 is described below
commit ddd87921d268a420835d2137e6d3847a3db0aa76
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Jul 20 14:48:11 2026 +0800
[c] Support vector search materialized read in C FFI bindings (#537)
---
bindings/c/Cargo.toml | 6 +
bindings/c/src/lib.rs | 1 +
bindings/c/src/result.rs | 6 +
bindings/c/src/table.rs | 3 +-
bindings/c/src/tests.rs | 914 ++++++++++++++++++++-
bindings/c/src/types.rs | 18 +
bindings/c/src/vector_search.rs | 324 ++++++++
.../src/table/pk_vector_indexed_split_read.rs | 12 +-
crates/paimon/src/table/pk_vector_orchestrator.rs | 12 +-
crates/paimon/src/table/pk_vector_position_read.rs | 30 +-
crates/paimon/src/table/vector_search_builder.rs | 779 ++++++++++++++++--
crates/paimon/tests/pk_vector_baseline_test.rs | 16 +-
crates/paimon/tests/pk_vector_java_fixture_test.rs | 4 +-
13 files changed, 2030 insertions(+), 95 deletions(-)
diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml
index 6eb34c12..41bdd964 100644
--- a/bindings/c/Cargo.toml
+++ b/bindings/c/Cargo.toml
@@ -35,3 +35,9 @@ futures = "0.3"
arrow = { workspace = true }
arrow-array = { workspace = true }
arrow-schema = { workspace = true }
+
+[dev-dependencies]
+# Test-only: the vector-search integration tests build a real primary-key
vindex
+# IVF-flat ANN segment fixture in-process. Versions match crates/paimon.
+bytes = "1.7.1"
+paimon-vindex-core = "0.2.0"
diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs
index 3e1367ea..27972b5f 100644
--- a/bindings/c/src/lib.rs
+++ b/bindings/c/src/lib.rs
@@ -27,6 +27,7 @@ mod table;
#[cfg(test)]
mod tests;
mod types;
+mod vector_search;
mod write;
use std::sync::OnceLock;
diff --git a/bindings/c/src/result.rs b/bindings/c/src/result.rs
index 216509f5..a833de5e 100644
--- a/bindings/c/src/result.rs
+++ b/bindings/c/src/result.rs
@@ -78,6 +78,12 @@ pub struct paimon_result_next_batch {
pub error: *mut paimon_error,
}
+#[repr(C)]
+pub struct paimon_result_vector_search_builder {
+ pub builder: *mut paimon_vector_search_builder,
+ pub error: *mut paimon_error,
+}
+
// === Write/Commit result types ===
#[repr(C)]
diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index 4daf61ec..a17ba3fc 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -658,7 +658,8 @@ pub unsafe extern "C" fn paimon_record_batch_reader_next(
/// Free a paimon_record_batch_reader.
///
/// # Safety
-/// Only call with a reader returned from `paimon_table_read_to_arrow`.
+/// Only call with a reader returned from `paimon_table_read_to_arrow` or
+/// `paimon_vector_search_builder_execute_read`.
#[no_mangle]
pub unsafe extern "C" fn paimon_record_batch_reader_free(reader: *mut
paimon_record_batch_reader) {
if !reader.is_null() {
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index 72ba5829..2ced3245 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -19,7 +19,8 @@
//!
//! Covers: read path (table, scan, plan, predicates, record batch streaming),
//! write path (write builder, write_arrow_batch, prepare_commit, commit,
-//! overwrite, truncate, abort), and full write->read roundtrip.
+//! overwrite, truncate, abort), full write->read roundtrip, and vector search
+//! materialized reads across primary-key and data-evolution (append) tables.
//!
//! IMPORTANT: C FFI functions internally use `runtime().block_on()`. Tests
//! must NOT wrap C FFI calls inside another `block_on`. Use the global
@@ -43,6 +44,7 @@ use paimon::table::{SnapshotManager, Table};
use crate::error::*;
use crate::table::*;
use crate::types::*;
+use crate::vector_search::*;
use crate::write::*;
// =========================================================================
@@ -1506,3 +1508,913 @@ fn test_two_commits_same_builder() {
unwrap_table(handle);
}
}
+
+// =========================================================================
+// Vector search tests (materialized reads)
+// =========================================================================
+//
+// Two storage shapes are exercised end-to-end through the C `execute_read`
+// terminal, each compared against an independent core Rust
+// `VectorSearchBuilder::execute_read()` reference:
+//
+// * A primary-key vector table backed by a real vindex IVF-flat ANN segment
+// built in-process (bucket-local ANN search, residual filter supported).
+// * A data-evolution (append) vector table whose global index is produced by
+// the public `new_vindex_index_build_builder(...).execute()` path.
+//
+// Both fixtures live entirely on the in-memory FileIO, so no temp dirs or
+// on-disk schema files are needed: the written data file keeps `schema_id ==
0`,
+// matching the table, so the read path never reloads a schema from disk.
+//
+// The materialized stream carries the user table columns plus a unified
+// `__paimon_search_score` Float32 column; row order is best-first.
+
+use std::collections::HashMap;
+
+use arrow_array::builder::{FixedSizeListBuilder, Float32Builder, ListBuilder};
+use arrow_array::{ArrayRef, Float32Array};
+use bytes::Bytes;
+use futures::TryStreamExt;
+use paimon::io::FileIO;
+use paimon::spec::{
+ ArrayType, DataFileMeta, Datum, FloatType, GlobalIndexMeta, IndexFileMeta,
Predicate,
+ PredicateBuilder, VectorType,
+};
+use paimon::table::{CommitMessage, TableCommit};
+
+use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer,
VectorIndexWriter};
+use paimon_vindex_core::io::PosWriter;
+
+/// Unified score column materialized by `execute_read` (Float32).
+const SCORE_COLUMN: &str = "__paimon_search_score";
+/// Vector dimension for the primary-key fixtures.
+const PK_DIM: usize = 4;
+/// Primary-key vector column name (shared by both storage fixtures).
+const VECTOR_COLUMN: &str = "embedding";
+/// vindex index type used for both the PK ANN segment and the DE global index.
+const INDEX_TYPE: &str = "ivf-flat";
+
+// --- Primary-key vector fixture ------------------------------------------
+
+/// Table options routing searches into the primary-key vector branch. A single
+/// bucket keeps one data file; `deletion-vectors.enabled` satisfies the
residual
+/// guard (`with_filter`) without any row actually being deleted.
+fn pk_vector_options() -> Vec<(String, String)> {
+ vec![
+ ("bucket".to_string(), "1".to_string()),
+ ("deletion-vectors.enabled".to_string(), "true".to_string()),
+ (
+ "pk-vector.index.columns".to_string(),
+ VECTOR_COLUMN.to_string(),
+ ),
+ (
+ format!("fields.{VECTOR_COLUMN}.pk-vector.index.type"),
+ INDEX_TYPE.to_string(),
+ ),
+ (
+ format!("fields.{VECTOR_COLUMN}.pk-vector.distance.metric"),
+ "l2".to_string(),
+ ),
+ ]
+}
+
+/// Primary-key schema `(id INT PRIMARY KEY, embedding VECTOR<FLOAT>)`.
+fn pk_vector_schema() -> TableSchema {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ VECTOR_COLUMN,
+ DataType::Vector(
+ VectorType::try_new(true, PK_DIM as u32,
DataType::Float(FloatType::new()))
+ .unwrap(),
+ ),
+ )
+ .primary_key(["id"]);
+ for (k, v) in pk_vector_options() {
+ builder = builder.option(k, v);
+ }
+ TableSchema::new(0, &builder.build().unwrap())
+}
+
+/// Arrow batch matching the PK schema: `id` (== physical position) plus a
+/// `FixedSizeList<Float32>` vector column.
+fn pk_data_batch(vectors: &[[f32; PK_DIM]]) -> RecordBatch {
+ let ids: Vec<i32> = (0..vectors.len() as i32).collect();
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut vector_builder = FixedSizeListBuilder::new(Float32Builder::new(),
PK_DIM as i32)
+ .with_field(element_field.clone());
+ for vector in vectors {
+ for &value in vector {
+ vector_builder.values().append_value(value);
+ }
+ vector_builder.append(true);
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new(
+ VECTOR_COLUMN,
+ ArrowDataType::FixedSizeList(element_field, PK_DIM as i32),
+ true,
+ ),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids)) as ArrayRef,
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap()
+}
+
+/// Encode one Java `DataOutput#writeUTF` value (u16-BE length + modified
UTF-8),
+/// as `PkVectorSourceMeta` expects.
+fn java_write_utf(s: &str) -> Vec<u8> {
+ let mut body = Vec::new();
+ for c in s.encode_utf16() {
+ if (0x0001..=0x007F).contains(&c) {
+ body.push(c as u8);
+ } else if c > 0x07FF {
+ body.push(0xE0 | (c >> 12) as u8);
+ body.push(0x80 | ((c >> 6) & 0x3F) as u8);
+ body.push(0x80 | (c & 0x3F) as u8);
+ } else {
+ body.push(0xC0 | (c >> 6) as u8);
+ body.push(0x80 | (c & 0x3F) as u8);
+ }
+ }
+ let mut out = (body.len() as u16).to_be_bytes().to_vec();
+ out.extend_from_slice(&body);
+ out
+}
+
+/// Assemble the `_SOURCE_META` frame the way Java `PkVectorSourceMeta` writes
it:
+/// `i32-BE version=1`, `i32-BE data_level`, `i32-BE count`, then per source
file a
+/// `writeUTF` name and an `i64-BE` row count.
+fn source_meta_bytes(data_level: i32, files: &[(&str, i64)]) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&1i32.to_be_bytes());
+ out.extend_from_slice(&data_level.to_be_bytes());
+ out.extend_from_slice(&(files.len() as i32).to_be_bytes());
+ for (name, rows) in files {
+ out.extend_from_slice(&java_write_utf(name));
+ out.extend_from_slice(&rows.to_be_bytes());
+ }
+ out
+}
+
+/// Build a real vindex IVF-flat ANN segment over `vectors` (label == physical
+/// position) and write it into `{table}/index/{file_name}`. `nlist = 1` keeps
the
+/// single inverted list exhaustive, so the search is exact.
+async fn write_ann_segment(
+ file_io: &FileIO,
+ table_location: &str,
+ file_name: &str,
+ vectors: &[[f32; PK_DIM]],
+) -> u64 {
+ let n = vectors.len();
+ let flat: Vec<f32> = vectors.iter().flat_map(|v|
v.iter().copied()).collect();
+ let ids: Vec<i64> = (0..n as i64).collect();
+
+ let native_options = HashMap::from([
+ ("index.type".to_string(), "ivf_flat".to_string()),
+ ("dimension".to_string(), PK_DIM.to_string()),
+ ("nlist".to_string(), "1".to_string()),
+ ("metric".to_string(), "l2".to_string()),
+ ]);
+ let config = VectorIndexConfig::from_options(&native_options).unwrap();
+ let training = VectorIndexTrainer::train(config, &flat, n).unwrap();
+ let mut writer = VectorIndexWriter::new(training);
+ writer.add_vectors(&ids, &flat, n).unwrap();
+ let mut bytes = Vec::new();
+ {
+ let mut output = PosWriter::new(&mut bytes);
+ writer.write(&mut output).unwrap();
+ }
+
+ let index_dir = format!("{}/index", table_location.trim_end_matches('/'));
+ file_io.mkdirs(&index_dir).await.unwrap();
+ let index_path = format!("{index_dir}/{file_name}");
+ let file_size = bytes.len() as u64;
+ file_io
+ .new_output(&index_path)
+ .unwrap()
+ .write(Bytes::from(bytes))
+ .await
+ .unwrap();
+ file_size
+}
+
+/// Build a complete, self-contained primary-key vector table over `vectors` on
+/// the in-memory FileIO: write a real data file, apply the two PK-vector
+/// constraints to its meta (compacted, non-level-0; Java source-meta frame),
+/// build+commit a real vindex ANN segment, and return the opened table.
+fn build_pk_vector_table(path: &str, vectors: &[[f32; PK_DIM]]) -> Table {
+ let file_io = memory_file_io();
+ setup_table_dirs(&file_io, path);
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "pkvector"),
+ path.to_string(),
+ pk_vector_schema(),
+ None,
+ );
+
+ crate::runtime().block_on(async {
+ // Write a real data file via the public write path to obtain a genuine
+ // DataFileMeta (name, row count, stats, file size).
+ let write_builder = table.new_write_builder();
+ let mut writer = write_builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&pk_data_batch(vectors))
+ .await
+ .unwrap();
+ let write_messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(write_messages.len(), 1, "single bucket -> one message");
+ let written = &write_messages[0];
+ assert_eq!(written.new_files.len(), 1, "single data file expected");
+ let base_meta = written.new_files[0].clone();
+ let bucket = written.bucket;
+ let partition = written.partition.clone();
+ let data_file_name = base_meta.file_name.clone();
+ let row_count = base_meta.row_count;
+
+ // Constraint 1: only a compacted, non-level-0 file backs the PK-vector
+ // index. Pin first_row_id = 0 so global row id == physical position.
+ let indexed_meta = DataFileMeta {
+ level: 1,
+ file_source: Some(1),
+ first_row_id: Some(0),
+ ..base_meta
+ };
+
+ // Build and persist the real vindex ANN segment.
+ let index_file_name = "vector-ivf-flat-pk-c.index".to_string();
+ let index_file_size = write_ann_segment(&file_io, path,
&index_file_name, vectors).await;
+
+ // Constraint 2: GlobalIndexMeta.source_meta is the Java
PkVectorSourceMeta
+ // frame naming the backing data file in ordinal order.
+ let vector_field_id = table
+ .schema()
+ .fields()
+ .iter()
+ .find(|f| f.name() == VECTOR_COLUMN)
+ .expect("vector field present")
+ .id();
+ let index_file = IndexFileMeta {
+ index_type: INDEX_TYPE.to_string(),
+ file_name: index_file_name,
+ file_size: i32::try_from(index_file_size).unwrap(),
+ row_count: i32::try_from(row_count).unwrap(),
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: 0,
+ row_range_end: row_count - 1,
+ index_field_id: vector_field_id,
+ extra_field_ids: None,
+ source_meta: Some(source_meta_bytes(
+ indexed_meta.level,
+ &[(&data_file_name, row_count)],
+ )),
+ index_meta: None,
+ }),
+ };
+
+ // Commit the indexed data file together with the ANN segment.
+ let mut message = CommitMessage::new(partition, bucket,
vec![indexed_meta]);
+ message.new_index_files = vec![index_file];
+ TableCommit::new(table.clone(), "pkvector-c".to_string())
+ .commit(vec![message])
+ .await
+ .unwrap();
+ });
+
+ table
+}
+
+/// Empty primary-key vector table (options set, no data): the PK branch
resolves
+/// but the plan is empty, so a search returns an empty (EOF) stream.
+fn build_pk_vector_table_empty(path: &str) -> Table {
+ let file_io = memory_file_io();
+ setup_table_dirs(&file_io, path);
+ Table::new(
+ file_io,
+ Identifier::new("default", "pkvector_empty"),
+ path.to_string(),
+ pk_vector_schema(),
+ None,
+ )
+}
+
+/// Fixture: distances 1 < 41 < 67 < 181; top-3 = rows 0, 4, 5.
+fn pk_fixture_smoke() -> ([f32; PK_DIM], Vec<[f32; PK_DIM]>) {
+ let query = [9.0, 0.0, 0.0, 0.0];
+ let vectors = vec![
+ [10.0, 0.0, 0.0, 0.0],
+ [0.0, 10.0, 0.0, 0.0],
+ [0.0, 0.0, 10.0, 0.0],
+ [0.0, 0.0, 0.0, 10.0],
+ [5.0, 5.0, 0.0, 0.0],
+ [1.0, 1.0, 1.0, 1.0],
+ ];
+ (query, vectors)
+}
+
+/// Residual fixture: unfiltered top-3 = [0, 1, 2]; with `id >= 3` the top-3
+/// becomes [4, 5, 3], disjoint from the unfiltered set.
+fn pk_fixture_residual() -> ([f32; PK_DIM], Vec<[f32; PK_DIM]>) {
+ let query = [10.0, 0.0, 0.0, 0.0];
+ let vectors = vec![
+ [10.0, 0.0, 0.0, 0.0], // pos 0 -> 0
+ [9.0, 0.0, 0.0, 0.0], // pos 1 -> 1
+ [8.0, 0.0, 0.0, 0.0], // pos 2 -> 4
+ [5.0, 0.0, 0.0, 0.0], // pos 3 -> 25
+ [7.0, 0.0, 0.0, 0.0], // pos 4 -> 9
+ [6.0, 0.0, 0.0, 0.0], // pos 5 -> 16
+ ];
+ (query, vectors)
+}
+
+// --- Data-evolution (append) vector fixture ------------------------------
+
+/// Options enabling the data-evolution global-index vindex build/search path.
+fn append_vector_options() -> HashMap<String, String> {
+ HashMap::from([
+ ("row-tracking.enabled".to_string(), "true".to_string()),
+ ("data-evolution.enabled".to_string(), "true".to_string()),
+ ("global-index.enabled".to_string(), "true".to_string()),
+ (
+ "global-index.row-count-per-shard".to_string(),
+ "10".to_string(),
+ ),
+ ("ivf-flat.dimension".to_string(), "2".to_string()),
+ // Single inverted list keeps the ANN search exhaustive (exact, stable
+ // ordering), so the C result matches the Rust reference
deterministically.
+ ("ivf-flat.nlist".to_string(), "1".to_string()),
+ ])
+}
+
+/// Arrow batch for the DE table: `id` INT plus an `embedding` `List<Float32>`.
+fn append_vector_batch(ids: Vec<i32>, vectors: Vec<[f32; 2]>) -> RecordBatch {
+ 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 vectors {
+ for value in vector {
+ vector_builder.values().append_value(value);
+ }
+ vector_builder.append(true);
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding", ArrowDataType::List(element_field), true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids)) as ArrayRef,
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap()
+}
+
+/// Build a data-evolution vector table: write vectors via the public write
path,
+/// then build the global vindex index via `new_vindex_index_build_builder`.
+fn build_append_vector_table(path: &str) -> Table {
+ let file_io = memory_file_io();
+ setup_table_dirs(&file_io, path);
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .options(append_vector_options())
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "devector"),
+ path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ crate::runtime().block_on(async {
+ let write_builder = table.new_write_builder();
+ let mut writer = write_builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&append_vector_batch(
+ vec![0, 1, 2, 3, 4, 5],
+ vec![
+ [1.0, 0.0],
+ [0.0, 1.0],
+ [0.9, 0.1],
+ [0.1, 0.9],
+ [0.8, 0.2],
+ [0.2, 0.8],
+ ],
+ ))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ write_builder.new_commit().commit(messages).await.unwrap();
+
+ let built = table
+ .new_vindex_index_build_builder(INDEX_TYPE)
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .unwrap();
+ assert!(built > 0, "DE fixture must build at least one index shard");
+ });
+
+ table
+}
+
+// --- Shared harness: core Rust reference + C read bridges ----------------
+
+/// Import one `paimon_arrow_batch` (Arrow C Data Interface) into a
RecordBatch,
+/// mirroring `collect_rows`: take ownership of the FFI structs via
`ptr::read`,
+/// hand the array to `from_ffi`, then neutralize the originals so the caller's
+/// `paimon_arrow_batch_free` release is a no-op. The imported schema's memory
is
+/// released when the local `ffi_schema` drops at the end of this call.
+unsafe fn import_batch(batch: &paimon_arrow_batch) -> RecordBatch {
+ let ffi_array = ptr::read(batch.array as *const FFI_ArrowArray);
+ let ffi_schema = ptr::read(batch.schema as *const FFI_ArrowSchema);
+ let data = arrow_array::ffi::from_ffi(ffi_array, &ffi_schema).unwrap();
+ ptr::write(batch.array as *mut FFI_ArrowArray, FFI_ArrowArray::empty());
+ ptr::write(
+ batch.schema as *mut FFI_ArrowSchema,
+ FFI_ArrowSchema::empty(),
+ );
+ RecordBatch::from(StructArray::from(data))
+}
+
+/// `(id INT32, score FLOAT32)` pairs from a materialized search batch. Panics
if
+/// the unified score column is missing, pinning the read contract.
+fn batch_id_score_pairs(batch: &RecordBatch) -> Vec<(i32, f32)> {
+ let ids = batch
+ .column_by_name("id")
+ .expect("id column present")
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .expect("id is Int32");
+ let score_idx = batch
+ .schema()
+ .index_of(SCORE_COLUMN)
+ .expect("unified score column present");
+ let scores = batch
+ .column(score_idx)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .expect("score is Float32");
+ (0..batch.num_rows())
+ .map(|i| (ids.value(i), scores.value(i)))
+ .collect()
+}
+
+/// Core Rust reference: run `execute_read` and return (row count, score-column
+/// present) — the shape the C path is compared against. Runs on the global
+/// runtime.
+fn rust_execute_read_rows(
+ table: &Table,
+ column: &str,
+ query: Vec<f32>,
+ limit: usize,
+) -> (usize, bool) {
+ crate::runtime().block_on(async {
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(column)
+ .with_query_vector(query)
+ .with_limit(limit);
+ let mut stream = builder.execute_read().await.unwrap();
+ let (mut rows, mut has_score) = (0usize, false);
+ while let Some(b) = stream.try_next().await.unwrap() {
+ rows += b.num_rows();
+ has_score |= b.schema().index_of(SCORE_COLUMN).is_ok();
+ }
+ (rows, has_score)
+ })
+}
+
+/// Core Rust reference: sorted `(id, score)` pairs materialized by
`execute_read`.
+fn rust_execute_read_pairs(
+ table: &Table,
+ column: &str,
+ query: Vec<f32>,
+ limit: usize,
+ filter: Option<Predicate>,
+) -> Vec<(i32, f32)> {
+ crate::runtime().block_on(async {
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(column)
+ .with_query_vector(query)
+ .with_limit(limit);
+ if let Some(f) = filter {
+ builder.with_filter(f);
+ }
+ let mut stream = builder.execute_read().await.unwrap();
+ let mut pairs = Vec::new();
+ while let Some(b) = stream.try_next().await.unwrap() {
+ pairs.extend(batch_id_score_pairs(&b));
+ }
+ pairs.sort_by_key(|p| p.0);
+ pairs
+ })
+}
+
+/// Build a `>=` predicate on an integer column via the public C predicate API.
+unsafe fn build_predicate_ge(
+ table: *const paimon_table,
+ column: &str,
+ int_val: i32,
+) -> *mut paimon_predicate {
+ let col = CString::new(column).unwrap();
+ let datum = paimon_datum {
+ tag: 3,
+ int_val: int_val as i64,
+ double_val: 0.0,
+ str_data: ptr::null(),
+ str_len: 0,
+ int_val2: 0,
+ uint_val: 0,
+ uint_val2: 0,
+ };
+ let result = paimon_predicate_greater_or_equal(table, col.as_ptr(), datum);
+ assert!(result.error.is_null());
+ result.predicate
+}
+
+/// Construct + configure a C vector-search builder (column, query, limit, and
an
+/// optional filter that `with_filter` consumes on success). The caller drives
the
+/// terminal and frees the builder.
+unsafe fn c_vector_builder(
+ handle: *const paimon_table,
+ column: &str,
+ query: &[f32],
+ limit: usize,
+ filter: *mut paimon_predicate,
+) -> *mut paimon_vector_search_builder {
+ let builder_result = paimon_table_new_vector_search_builder(handle);
+ assert!(builder_result.error.is_null());
+ let builder = builder_result.builder;
+
+ let col = CString::new(column).unwrap();
+ assert!(paimon_vector_search_builder_with_vector_column(builder,
col.as_ptr()).is_null());
+ assert!(
+ paimon_vector_search_builder_with_query_vector(builder,
query.as_ptr(), query.len())
+ .is_null()
+ );
+ assert!(paimon_vector_search_builder_with_limit(builder, limit).is_null());
+ if !filter.is_null() {
+ assert!(paimon_vector_search_builder_with_filter(builder,
filter).is_null());
+ }
+ builder
+}
+
+/// C path: run `execute_read` on a configured builder, drain the reader, and
+/// return (row count, score-column present). Frees the builder and reader.
+unsafe fn c_execute_read_rows(builder: *mut paimon_vector_search_builder) ->
(usize, bool) {
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+ assert!(result.error.is_null(), "execute_read should not error");
+ assert!(!result.reader.is_null());
+
+ let mut rows = 0usize;
+ let mut has_score = false;
+ loop {
+ let next = paimon_record_batch_reader_next(result.reader);
+ assert!(next.error.is_null());
+ if next.batch.array.is_null() {
+ break; // EOF
+ }
+ let batch = import_batch(&next.batch);
+ rows += batch.num_rows();
+ has_score |= batch.schema().index_of(SCORE_COLUMN).is_ok();
+ paimon_arrow_batch_free(next.batch);
+ }
+ paimon_record_batch_reader_free(result.reader);
+ (rows, has_score)
+}
+
+/// C path: sorted `(id, score)` pairs materialized by a configured builder's
+/// `execute_read`. Frees the builder and reader.
+unsafe fn c_execute_read_pairs(builder: *mut paimon_vector_search_builder) ->
Vec<(i32, f32)> {
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+ assert!(result.error.is_null(), "execute_read should not error");
+ assert!(!result.reader.is_null());
+
+ let mut pairs = Vec::new();
+ loop {
+ let next = paimon_record_batch_reader_next(result.reader);
+ assert!(next.error.is_null());
+ if next.batch.array.is_null() {
+ break; // EOF
+ }
+ let batch = import_batch(&next.batch);
+ pairs.extend(batch_id_score_pairs(&batch));
+ paimon_arrow_batch_free(next.batch);
+ }
+ paimon_record_batch_reader_free(result.reader);
+ pairs.sort_by_key(|p| p.0);
+ pairs
+}
+
+/// Read a `paimon_error`'s UTF-8 message.
+unsafe fn error_message(err: *mut paimon_error) -> String {
+ let bytes = &(*err).message;
+ let slice = std::slice::from_raw_parts(bytes.data, bytes.len);
+ String::from_utf8_lossy(slice).to_string()
+}
+
+// --- Tests ----------------------------------------------------------------
+
+#[test]
+fn vector_search_pk_table_read_matches_rust() {
+ let path = "memory:/vsearch_pk_read";
+ let (query, vectors) = pk_fixture_smoke();
+ let table = build_pk_vector_table(path, &vectors);
+
+ // Independent core reference: row count + score column presence and the
+ // materialized (id, score) pairs.
+ let (rust_rows, rust_has_score) =
+ rust_execute_read_rows(&table, VECTOR_COLUMN, query.to_vec(), 3);
+ let rust_pairs = rust_execute_read_pairs(&table, VECTOR_COLUMN,
query.to_vec(), 3, None);
+ assert_eq!(rust_rows, 3, "PK fixture top-3 must materialize 3 rows");
+ assert!(
+ rust_has_score,
+ "reference must carry the unified score column"
+ );
+
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder = c_vector_builder(handle, VECTOR_COLUMN, &query, 3,
ptr::null_mut());
+ let (c_rows, c_has_score) = c_execute_read_rows(builder);
+ assert_eq!(
+ c_rows, rust_rows,
+ "C row count must match the Rust reference"
+ );
+ assert_eq!(
+ c_has_score, rust_has_score,
+ "score-column presence must match"
+ );
+
+ let builder = c_vector_builder(handle, VECTOR_COLUMN, &query, 3,
ptr::null_mut());
+ let c_pairs = c_execute_read_pairs(builder);
+ assert_eq!(c_pairs.len(), rust_pairs.len());
+ for ((c_id, c_score), (r_id, r_score)) in
c_pairs.iter().zip(&rust_pairs) {
+ assert_eq!(c_id, r_id, "C row ids must match the Rust reference");
+ assert!(
+ (c_score - r_score).abs() < 1e-6,
+ "score diverges: {c_score} vs {r_score}"
+ );
+ }
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_append_table_read_matches_rust() {
+ let path = "memory:/vsearch_append_read";
+ let table = build_append_vector_table(path);
+ let query = vec![1.0f32, 0.0];
+
+ let (rust_rows, rust_has_score) = rust_execute_read_rows(&table,
"embedding", query.clone(), 3);
+ let rust_pairs = rust_execute_read_pairs(&table, "embedding",
query.clone(), 3, None);
+ assert!(rust_rows > 0, "DE fixture must materialize hits");
+ assert!(
+ rust_has_score,
+ "reference must carry the unified score column"
+ );
+
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder = c_vector_builder(handle, "embedding", &query, 3,
ptr::null_mut());
+ let (c_rows, c_has_score) = c_execute_read_rows(builder);
+ assert_eq!(
+ c_rows, rust_rows,
+ "C row count must match the Rust reference"
+ );
+ assert_eq!(
+ c_has_score, rust_has_score,
+ "score-column presence must match"
+ );
+
+ // The data-evolution global-index path does not promise a stable order
+ // across two separate reads, so compare the (id, score) hits as an
+ // id-keyed set rather than over-asserting an order neither read
promises.
+ let builder = c_vector_builder(handle, "embedding", &query, 3,
ptr::null_mut());
+ let c_pairs = c_execute_read_pairs(builder);
+ assert_eq!(c_pairs.len(), rust_pairs.len());
+ for ((c_id, c_score), (r_id, r_score)) in
c_pairs.iter().zip(&rust_pairs) {
+ assert_eq!(c_id, r_id, "C row ids must match the Rust reference
set");
+ assert!(
+ (c_score - r_score).abs() < 1e-6,
+ "score diverges: {c_score} vs {r_score}"
+ );
+ }
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_pk_filter_excludes_neighbor() {
+ let path = "memory:/vsearch_pk_filter_read";
+ let (query, vectors) = pk_fixture_residual();
+ let table = build_pk_vector_table(path, &vectors);
+
+ // Guard: the nearest neighbor (id 0) is present in the unfiltered top-3,
so a
+ // working residual filter of `id >= 3` must exclude it.
+ let unfiltered = rust_execute_read_pairs(&table, VECTOR_COLUMN,
query.to_vec(), 3, None);
+ let unfiltered_ids: Vec<i32> = unfiltered.iter().map(|(id, _)|
*id).collect();
+ assert!(
+ unfiltered_ids.contains(&0),
+ "fixture guard: id 0 must be an unfiltered neighbor"
+ );
+
+ // Independent filtered reference via the core Rust path.
+ let rust_filter = PredicateBuilder::new(table.schema().fields())
+ .greater_or_equal("id", Datum::Int(3))
+ .unwrap();
+ let rust_pairs =
+ rust_execute_read_pairs(&table, VECTOR_COLUMN, query.to_vec(), 3,
Some(rust_filter));
+
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let predicate = build_predicate_ge(handle, "id", 3);
+ let builder = c_vector_builder(handle, VECTOR_COLUMN, &query, 3,
predicate);
+ let c_pairs = c_execute_read_pairs(builder);
+
+ let c_ids: Vec<i32> = c_pairs.iter().map(|(id, _)| *id).collect();
+ for excluded in [0i32, 1, 2] {
+ assert!(
+ !c_ids.contains(&excluded),
+ "filtered read must exclude neighbor {excluded}"
+ );
+ }
+ assert_eq!(
+ c_pairs, rust_pairs,
+ "filtered pairs must match the reference"
+ );
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_append_filter_returns_invalid_input() {
+ let path = "memory:/vsearch_append_filter_err";
+ let table = build_append_vector_table(path);
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let predicate = build_predicate_ge(handle, "id", 1);
+ let builder = c_vector_builder(handle, "embedding", &[1.0f32, 0.0], 3,
predicate);
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+
+ assert!(
+ result.reader.is_null(),
+ "errored read must not yield a reader"
+ );
+ assert!(!result.error.is_null(), "DE filter must fail loud");
+ assert_eq!(
+ (*result.error).code,
+ PaimonErrorCode::InvalidInput as i32,
+ "DE filter error must map to InvalidInput"
+ );
+ let message = error_message(result.error);
+ assert!(
+ message.contains("primary-key vector path"),
+ "unexpected error message: {message}"
+ );
+ paimon_error_free(result.error);
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_unknown_column_returns_invalid_input() {
+ // A typo'd vector column must surface as an input error through the C API,
+ // not a silent empty (EOF) reader.
+ let path = "memory:/vsearch_unknown_col_err";
+ let table = build_append_vector_table(path);
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder =
+ c_vector_builder(handle, "does_not_exist", &[1.0f32, 0.0], 3,
ptr::null_mut());
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+
+ assert!(
+ result.reader.is_null(),
+ "errored read must not yield a reader"
+ );
+ assert!(!result.error.is_null(), "unknown column must fail loud");
+ assert_eq!(
+ (*result.error).code,
+ PaimonErrorCode::InvalidInput as i32,
+ "unknown column error must map to InvalidInput"
+ );
+ let message = error_message(result.error);
+ assert!(
+ message.contains("does not exist"),
+ "unexpected error message: {message}"
+ );
+ paimon_error_free(result.error);
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_scalar_column_returns_invalid_input() {
+ // A scalar (non-vector) column must surface as an input error, not an
empty
+ // reader.
+ let path = "memory:/vsearch_scalar_col_err";
+ let table = build_append_vector_table(path);
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ // "id" is a scalar Int column on the append vector table.
+ let builder = c_vector_builder(handle, "id", &[1.0f32, 0.0], 3,
ptr::null_mut());
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+
+ assert!(
+ result.reader.is_null(),
+ "errored read must not yield a reader"
+ );
+ assert!(!result.error.is_null(), "scalar column must fail loud");
+ assert_eq!(
+ (*result.error).code,
+ PaimonErrorCode::InvalidInput as i32,
+ "scalar column error must map to InvalidInput"
+ );
+ let message = error_message(result.error);
+ assert!(
+ message.contains("must be a FLOAT vector column"),
+ "unexpected error message: {message}"
+ );
+ paimon_error_free(result.error);
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_rejects_invalid_query_vector() {
+ let path = "memory:/vsearch_setter_validation";
+ let table = build_pk_vector_table_empty(path);
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder_result = paimon_table_new_vector_search_builder(handle);
+ assert!(builder_result.error.is_null());
+ let builder = builder_result.builder;
+
+ // Null data with a non-zero length is rejected at the setter.
+ let err_null = paimon_vector_search_builder_with_query_vector(builder,
ptr::null(), 5);
+ assert!(!err_null.is_null());
+ assert_eq!((*err_null).code, PaimonErrorCode::InvalidInput as i32);
+ paimon_error_free(err_null);
+
+ // A zero-length query is rejected even with a valid pointer.
+ let data = [1.0f32];
+ let err_empty =
paimon_vector_search_builder_with_query_vector(builder, data.as_ptr(), 0);
+ assert!(!err_empty.is_null());
+ assert_eq!((*err_empty).code, PaimonErrorCode::InvalidInput as i32);
+ paimon_error_free(err_empty);
+
+ paimon_vector_search_builder_free(builder);
+ unwrap_table(handle);
+ }
+}
+
+#[test]
+fn vector_search_empty_result_is_eof_stream() {
+ let path = "memory:/vsearch_empty_read";
+ let table = build_pk_vector_table_empty(path);
+ let handle = unsafe { wrap_table(table) };
+ unsafe {
+ let builder = c_vector_builder(
+ handle,
+ VECTOR_COLUMN,
+ &[1.0f32, 2.0, 3.0, 4.0],
+ 5,
+ ptr::null_mut(),
+ );
+ let result = paimon_vector_search_builder_execute_read(builder);
+ paimon_vector_search_builder_free(builder);
+
+ // An empty table is a normal EOF stream, not an error: a reader is
+ // returned and the first `_next` yields a null batch with no error.
+ assert!(result.error.is_null());
+ assert!(!result.reader.is_null());
+ let next = paimon_record_batch_reader_next(result.reader);
+ assert!(next.error.is_null());
+ assert!(next.batch.array.is_null());
+ assert!(next.batch.schema.is_null());
+ paimon_record_batch_reader_free(result.reader);
+ unwrap_table(handle);
+ }
+}
diff --git a/bindings/c/src/types.rs b/bindings/c/src/types.rs
index 8d3a4666..067353fe 100644
--- a/bindings/c/src/types.rs
+++ b/bindings/c/src/types.rs
@@ -127,6 +127,24 @@ pub struct paimon_predicate {
pub inner: *mut c_void,
}
+/// Opaque wrapper around a vector-search builder.
+#[repr(C)]
+pub struct paimon_vector_search_builder {
+ pub inner: *mut c_void,
+}
+
+/// Internal state for a vector-search builder: the table plus the query
+/// parameters accumulated by the setters before the search is run.
+pub(crate) struct VectorSearchState {
+ // Read by the search terminal that runs the accumulated query.
+ pub table: Table,
+ pub vector_column: Option<String>,
+ pub query_vector: Option<Vec<f32>>,
+ pub limit: Option<usize>,
+ pub options: std::collections::HashMap<String, String>,
+ pub filter: Option<Predicate>,
+}
+
/// A typed literal value for predicate comparison, passed across FFI.
///
/// # Design
diff --git a/bindings/c/src/vector_search.rs b/bindings/c/src/vector_search.rs
new file mode 100644
index 00000000..3da28248
--- /dev/null
+++ b/bindings/c/src/vector_search.rs
@@ -0,0 +1,324 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! C FFI bindings for vector search.
+//!
+//! Wraps the Rust vector-search builder over the C ABI: a
+//! `paimon_vector_search_builder` is created from a table, then configured
with
+//! the query vector, target column, result limit, options, and an optional
+//! scalar filter. The builder is storage-agnostic — it targets both
+//! primary-key and append / data-evolution tables.
+//!
+//! This module provides the builder constructor, its setters, the terminal
+//! that runs the search and returns a streaming Arrow reader, and the free
+//! function.
+
+use std::collections::HashMap;
+use std::ffi::{c_char, c_void};
+
+use paimon::spec::Predicate;
+use paimon::table::Table;
+
+use crate::error::{check_non_null, paimon_error, validate_cstr,
PaimonErrorCode};
+use crate::result::{paimon_result_record_batch_reader,
paimon_result_vector_search_builder};
+use crate::runtime;
+use crate::types::*;
+
+/// Create a new vector-search builder from a Table.
+///
+/// # Safety
+/// `table` must be a valid pointer from `paimon_catalog_get_table`, or null
(returns error).
+#[no_mangle]
+pub unsafe extern "C" fn paimon_table_new_vector_search_builder(
+ table: *const paimon_table,
+) -> paimon_result_vector_search_builder {
+ if let Err(e) = check_non_null(table, "table") {
+ return paimon_result_vector_search_builder {
+ builder: std::ptr::null_mut(),
+ error: e,
+ };
+ }
+ let table_ref = &*((*table).inner as *const Table);
+ let state = VectorSearchState {
+ table: table_ref.clone(),
+ vector_column: None,
+ query_vector: None,
+ limit: None,
+ options: HashMap::new(),
+ filter: None,
+ };
+ let inner = Box::into_raw(Box::new(state)) as *mut c_void;
+ paimon_result_vector_search_builder {
+ builder: Box::into_raw(Box::new(paimon_vector_search_builder { inner
})),
+ error: std::ptr::null_mut(),
+ }
+}
+
+/// Set the target vector column for a vector-search builder.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns error). `column` must be a valid C string.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_with_vector_column(
+ b: *mut paimon_vector_search_builder,
+ column: *const c_char,
+) -> *mut paimon_error {
+ if let Err(e) = check_non_null(b, "b") {
+ return e;
+ }
+ let col = match validate_cstr(column, "vector column") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ let state = &mut *((*b).inner as *mut VectorSearchState);
+ state.vector_column = Some(col);
+ std::ptr::null_mut()
+}
+
+/// Set the query vector for a vector-search builder.
+///
+/// The `len` floats at `data` are copied into the builder; the caller retains
+/// ownership of `data`. An empty vector (`len == 0`) is rejected.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns error). `data` must point to `len` `f32` values when `len >
0`.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_with_query_vector(
+ b: *mut paimon_vector_search_builder,
+ data: *const f32,
+ len: usize,
+) -> *mut paimon_error {
+ if let Err(e) = check_non_null(b, "b") {
+ return e;
+ }
+ if len == 0 {
+ return paimon_error::new(
+ PaimonErrorCode::InvalidInput,
+ "query vector must not be empty".to_string(),
+ );
+ }
+ if data.is_null() {
+ return paimon_error::new(
+ PaimonErrorCode::InvalidInput,
+ "null query vector pointer with non-zero length".to_string(),
+ );
+ }
+ let state = &mut *((*b).inner as *mut VectorSearchState);
+ state.query_vector = Some(std::slice::from_raw_parts(data, len).to_vec());
+ std::ptr::null_mut()
+}
+
+/// Set the maximum number of results for a vector-search builder.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns error).
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_with_limit(
+ b: *mut paimon_vector_search_builder,
+ limit: usize,
+) -> *mut paimon_error {
+ if let Err(e) = check_non_null(b, "b") {
+ return e;
+ }
+ let state = &mut *((*b).inner as *mut VectorSearchState);
+ state.limit = Some(limit);
+ std::ptr::null_mut()
+}
+
+/// Set scan/search options for a vector-search builder.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns error). `options` must be a valid pointer to `len`
+/// `paimon_option` values, or null when `len` is 0.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_with_options(
+ b: *mut paimon_vector_search_builder,
+ options: *const paimon_option,
+ len: usize,
+) -> *mut paimon_error {
+ if let Err(e) = check_non_null(b, "b") {
+ return e;
+ }
+ if options.is_null() && len > 0 {
+ return paimon_error::new(
+ PaimonErrorCode::InvalidInput,
+ "null options pointer with non-zero length".to_string(),
+ );
+ }
+ let mut map = HashMap::with_capacity(len);
+ if len > 0 {
+ let slice = std::slice::from_raw_parts(options, len);
+ for opt in slice {
+ let key = match validate_cstr(opt.key, "option key") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ let value = match validate_cstr(opt.value, "option value") {
+ Ok(s) => s,
+ Err(e) => return e,
+ };
+ map.insert(key, value);
+ }
+ }
+ let state = &mut *((*b).inner as *mut VectorSearchState);
+ state.options = map;
+ std::ptr::null_mut()
+}
+
+/// Set an optional scalar residual filter for a vector-search builder.
+///
+/// The predicate is consumed (ownership transferred to the builder). Pass null
+/// to clear any previously set filter.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns error). `predicate` must be a valid pointer from a
+/// `paimon_predicate_*` function, or null.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_with_filter(
+ b: *mut paimon_vector_search_builder,
+ predicate: *mut paimon_predicate,
+) -> *mut paimon_error {
+ if let Err(e) = check_non_null(b, "b") {
+ return e;
+ }
+
+ let state = &mut *((*b).inner as *mut VectorSearchState);
+
+ if predicate.is_null() {
+ state.filter = None;
+ return std::ptr::null_mut();
+ }
+
+ let pred_wrapper = Box::from_raw(predicate);
+ let pred = Box::from_raw(pred_wrapper.inner as *mut Predicate);
+ state.filter = Some(*pred);
+ std::ptr::null_mut()
+}
+
+/// Free a paimon_vector_search_builder.
+///
+/// # Safety
+/// Only call with a builder returned from
`paimon_table_new_vector_search_builder`.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_free(b: *mut
paimon_vector_search_builder) {
+ if !b.is_null() {
+ let wrapper = Box::from_raw(b);
+ if !wrapper.inner.is_null() {
+ drop(Box::from_raw(wrapper.inner as *mut VectorSearchState));
+ }
+ }
+}
+
+/// Execute the vector search and return a streaming Arrow reader over the
+/// materialized rows (projected user columns plus `__paimon_search_score`).
+/// Works for both primary-key and data-evolution tables. Consume via
+/// `paimon_record_batch_reader_next` and free with
`paimon_record_batch_reader_free`.
+///
+/// # Safety
+/// `b` must be a valid pointer from `paimon_table_new_vector_search_builder`,
or
+/// null (returns an error result).
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vector_search_builder_execute_read(
+ b: *mut paimon_vector_search_builder,
+) -> paimon_result_record_batch_reader {
+ if let Err(e) = check_non_null(b, "b") {
+ return paimon_result_record_batch_reader {
+ reader: std::ptr::null_mut(),
+ error: e,
+ };
+ }
+ let state = &*((*b).inner as *const VectorSearchState);
+
+ let mut builder = state.table.new_vector_search_builder();
+ if let Some(col) = &state.vector_column {
+ builder.with_vector_column(col);
+ }
+ if let Some(v) = &state.query_vector {
+ builder.with_query_vector(v.clone());
+ }
+ if let Some(limit) = state.limit {
+ builder.with_limit(limit);
+ }
+ if !state.options.is_empty() {
+ builder.with_options(state.options.clone());
+ }
+ if let Some(f) = &state.filter {
+ builder.with_filter(f.clone());
+ }
+
+ match runtime().block_on(builder.execute_read()) {
+ Ok(stream) => {
+ let reader = Box::new(stream);
+ let wrapper = Box::new(paimon_record_batch_reader {
+ inner: Box::into_raw(reader) as *mut c_void,
+ });
+ paimon_result_record_batch_reader {
+ reader: Box::into_raw(wrapper),
+ error: std::ptr::null_mut(),
+ }
+ }
+ Err(e) => paimon_result_record_batch_reader {
+ reader: std::ptr::null_mut(),
+ error: paimon_error::from_paimon(e),
+ },
+ }
+}
+
+// --- C ABI signature guards -------------------------------------------------
+//
+// These symbols are called across the FFI boundary with fixed argument counts:
+// bindings prepare a libffi call interface (CIF) per symbol, and external
+// consumers link against the generated headers (e.g. Doris integrations).
+// Adding or reordering a parameter on one of these existing symbols silently
+// breaks every such caller — the extra argument is read from an undefined
+// register/stack slot at the ABI boundary.
+//
+// These compile-time assertions pin the existing signatures. To add behavior,
+// introduce a new symbol instead of changing one of these; touching a
signature
+// here will fail to compile.
+const _: unsafe extern "C" fn(*const paimon_table) ->
paimon_result_vector_search_builder =
+ paimon_table_new_vector_search_builder;
+const _: unsafe extern "C" fn(
+ *mut paimon_vector_search_builder,
+ *const c_char,
+) -> *mut paimon_error = paimon_vector_search_builder_with_vector_column;
+const _: unsafe extern "C" fn(
+ *mut paimon_vector_search_builder,
+ *const f32,
+ usize,
+) -> *mut paimon_error = paimon_vector_search_builder_with_query_vector;
+const _: unsafe extern "C" fn(*mut paimon_vector_search_builder, usize) ->
*mut paimon_error =
+ paimon_vector_search_builder_with_limit;
+const _: unsafe extern "C" fn(
+ *mut paimon_vector_search_builder,
+ *const paimon_option,
+ usize,
+) -> *mut paimon_error = paimon_vector_search_builder_with_options;
+const _: unsafe extern "C" fn(
+ *mut paimon_vector_search_builder,
+ *mut paimon_predicate,
+) -> *mut paimon_error = paimon_vector_search_builder_with_filter;
+const _: unsafe extern "C" fn(*mut paimon_vector_search_builder) =
+ paimon_vector_search_builder_free;
+const _: unsafe extern "C" fn(
+ *mut paimon_vector_search_builder,
+) -> paimon_result_record_batch_reader =
paimon_vector_search_builder_execute_read;
diff --git a/crates/paimon/src/table/pk_vector_indexed_split_read.rs
b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
index 668957a6..2dcb6e7f 100644
--- a/crates/paimon/src/table/pk_vector_indexed_split_read.rs
+++ b/crates/paimon/src/table/pk_vector_indexed_split_read.rs
@@ -367,9 +367,7 @@ mod e2e_tests {
use crate::spec::stats::BinaryTableStats;
use crate::spec::{DataField, DataFileMeta, DataType, IntType};
use crate::table::data_file_reader::DataFileReader;
- use crate::table::pk_vector_position_read::{
- PKEY_VECTOR_POSITION_COLUMN, PKEY_VECTOR_SCORE_COLUMN,
- };
+ use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
use crate::table::schema_manager::SchemaManager;
use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
use arrow_array::{Array, Float32Array, Int32Array, Int64Array,
RecordBatch};
@@ -632,7 +630,7 @@ mod e2e_tests {
column_by_name(batch, "_ROW_ID").is_none(),
"_ROW_ID must not leak"
);
- assert!(column_by_name(batch, PKEY_VECTOR_SCORE_COLUMN).is_none());
+ assert!(column_by_name(batch, SEARCH_SCORE_COLUMN).is_none());
}
}
@@ -659,7 +657,7 @@ mod e2e_tests {
vec![0, 2, 3]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.9, 0.5, 0.1]
);
}
@@ -689,7 +687,7 @@ mod e2e_tests {
vec![0, 2, 3]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.4, 0.2, 0.1]
);
}
@@ -729,7 +727,7 @@ mod e2e_tests {
vec![1, 2, 4]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.9, 0.5, 0.1]
);
}
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index e8da709e..0479781f 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -660,9 +660,7 @@ mod e2e_tests {
DataField, DataFileMeta, DataType, IntType, PkVectorSourceFile,
PkVectorSourceMeta,
};
use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplitRead;
- use crate::table::pk_vector_position_read::{
- PKEY_VECTOR_POSITION_COLUMN, PKEY_VECTOR_SCORE_COLUMN,
- };
+ use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
use crate::table::schema_manager::SchemaManager;
use crate::table::source::DeletionFile;
use crate::vindex::pkvector::reader::test_support::ArrayReader;
@@ -1099,7 +1097,7 @@ mod e2e_tests {
vec![1, 0, 1]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![l2_score(0.25), l2_score(1.0), l2_score(4.0)]
);
for batch in &batches {
@@ -1191,7 +1189,7 @@ mod e2e_tests {
vec![0, 0, 1]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![l2_score(1.0), l2_score(4.0), l2_score(9.0)]
);
}
@@ -1257,7 +1255,7 @@ mod e2e_tests {
vec![0, 2, 3]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![l2_score(1.0), l2_score(9.0), l2_score(0.0)]
);
}
@@ -1320,7 +1318,7 @@ mod e2e_tests {
);
// Scores aligned to ascending position: d=9,1,4.
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![l2_score(9.0), l2_score(1.0), l2_score(4.0)]
);
}
diff --git a/crates/paimon/src/table/pk_vector_position_read.rs
b/crates/paimon/src/table/pk_vector_position_read.rs
index 0c838979..5cb1f2b1 100644
--- a/crates/paimon/src/table/pk_vector_position_read.rs
+++ b/crates/paimon/src/table/pk_vector_position_read.rs
@@ -19,7 +19,7 @@
//! `PrimaryKeyVectorPositionReader`).
//!
//! Materializes the selected physical rows of one data file and appends
-//! `_PKEY_VECTOR_POSITION` (+ optional `_PKEY_VECTOR_SCORE`) metadata columns.
+//! `_PKEY_VECTOR_POSITION` (+ optional `__paimon_search_score`) metadata
columns.
//! This is the lowest layer of the PK-vector read kernel; the sibling
//! `pk_vector_indexed_split_read` and `pk_vector_orchestrator` modules build
the
//! indexed-split contract and cross-bucket merge on top of it.
@@ -38,7 +38,9 @@ use crate::table::source::DataSplit;
use crate::table::ArrowRecordBatchStream;
pub(crate) const PKEY_VECTOR_POSITION_COLUMN: &str = "_PKEY_VECTOR_POSITION";
-pub(crate) const PKEY_VECTOR_SCORE_COLUMN: &str = "_PKEY_VECTOR_SCORE";
+// Unified user-visible vector-search score column (matches the engine metadata
+// column name used by Spark and the DataFusion table function).
+pub(crate) const SEARCH_SCORE_COLUMN: &str = "__paimon_search_score";
fn data_invalid(message: impl Into<String>) -> crate::Error {
crate::Error::DataInvalid {
@@ -104,9 +106,7 @@ impl<'a> PkVectorPositionRead<'a> {
// (3) reserved-column-name check against the requested output fields
for field in self.reader.read_type() {
- if field.name() == PKEY_VECTOR_POSITION_COLUMN
- || field.name() == PKEY_VECTOR_SCORE_COLUMN
- {
+ if field.name() == PKEY_VECTOR_POSITION_COLUMN || field.name() ==
SEARCH_SCORE_COLUMN {
return Err(data_invalid(format!(
"Reserved metadata column name conflicts with a table
column: {}",
field.name()
@@ -185,7 +185,7 @@ impl<'a> PkVectorPositionRead<'a> {
}
}
-/// Append `_PKEY_VECTOR_POSITION` (and, when `want_score_col`,
`_PKEY_VECTOR_SCORE`)
+/// Append `_PKEY_VECTOR_POSITION` (and, when `want_score_col`,
`__paimon_search_score`)
/// to `batch`. `positions` are the file-LOCAL physical positions of the
batch's
/// rows, supplied by the caller's cursor into the effective (DV-filtered)
/// selection, so they align 1:1 with the batch rows in order. Scores are
looked
@@ -232,7 +232,7 @@ fn append_metadata_columns(
columns.push(Arc::new(Int64Array::from(positions.to_vec())));
if let Some(sv) = score_vals {
fields.push(ArrowField::new(
- PKEY_VECTOR_SCORE_COLUMN,
+ SEARCH_SCORE_COLUMN,
ArrowDataType::Float32,
false,
));
@@ -557,7 +557,7 @@ mod tests {
async fn test_reads_selected_positions_with_position_column() {
// rows id=[10,11,12,13,14], first_row_id=0, select positions [0,2,4]
// -> output ids [10,12,14], _PKEY_VECTOR_POSITION [0,2,4], ascending;
- // no _ROW_ID leak and no _PKEY_VECTOR_SCORE column.
+ // no _ROW_ID leak and no __paimon_search_score column.
let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13,
14]));
let (reader, split, _dv) = build_reader_and_split(
"memory:/pkvpr_basic",
@@ -594,7 +594,7 @@ mod tests {
"_ROW_ID must not leak into output"
);
assert!(
- column_by_name(batch, PKEY_VECTOR_SCORE_COLUMN).is_none(),
+ column_by_name(batch, SEARCH_SCORE_COLUMN).is_none(),
"_PKEY_VECTOR_SCORE must be absent when no scores are supplied"
);
}
@@ -603,7 +603,7 @@ mod tests {
#[tokio::test]
async fn test_score_alignment_non_contiguous() {
// select [0,2,5] with scores {0:0.9, 2:0.5, 5:0.1}
- // -> _PKEY_VECTOR_SCORE aligned by returned position: [0.9,0.5,0.1].
+ // -> __paimon_search_score aligned by returned position:
[0.9,0.5,0.1].
let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12, 13,
14, 15]));
let (reader, split, _dv) = build_reader_and_split(
"memory:/pkvpr_scores",
@@ -636,7 +636,7 @@ mod tests {
vec![0, 2, 5]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.9, 0.5, 0.1]
);
}
@@ -669,7 +669,7 @@ mod tests {
.unwrap();
for batch in &batches {
- assert!(column_by_name(batch, PKEY_VECTOR_SCORE_COLUMN).is_none());
+ assert!(column_by_name(batch, SEARCH_SCORE_COLUMN).is_none());
}
}
@@ -704,7 +704,7 @@ mod tests {
vec![0, 2, 3]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.4, 0.2, 0.1]
);
}
@@ -755,7 +755,7 @@ mod tests {
vec![1, 2, 4]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.9, 0.5, 0.1]
);
}
@@ -814,7 +814,7 @@ mod tests {
vec![1, 3, 4, 5]
);
assert_eq!(
- collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ collect_f32(&batches, SEARCH_SCORE_COLUMN),
vec![0.9, 0.5, 0.3, 0.1]
);
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index e8f1abc9..67579f1f 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -21,8 +21,8 @@ use crate::io::FileIO;
use crate::lumina::reader::LuminaVectorGlobalIndexReader;
use crate::lumina::{is_lumina_index_type, LuminaIndexMeta, LuminaVectorMetric};
use crate::spec::{
- CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta,
IndexManifest,
- IndexManifestEntry, Predicate, ROW_ID_FIELD_NAME,
+ BigIntType, CoreOptions, DataField, DataType, FileKind,
GlobalIndexSearchMode, IndexFileMeta,
+ IndexManifest, IndexManifestEntry, Predicate, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
};
use crate::table::data_file_reader::DataFileReader;
use crate::table::global_index_scanner::{
@@ -35,9 +35,7 @@ use crate::table::pk_vector_orchestrator::{
as_split_exact_reader_factory, build_indexed_splits, PkVectorCandidate,
PkVectorOrchestrator,
PkVectorSearchSplit,
};
-use crate::table::pk_vector_position_read::{
- PKEY_VECTOR_POSITION_COLUMN, PKEY_VECTOR_SCORE_COLUMN,
-};
+use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
use crate::table::pk_vector_scan::{PkVectorScan, PkVectorScanPlan};
use crate::table::read_builder::resolve_projected_fields;
use crate::table::source::DataSplit;
@@ -59,6 +57,7 @@ use roaring::RoaringTreemap;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::io::Cursor;
+use std::sync::Arc;
const INDEX_DIR: &str = "index";
@@ -159,7 +158,7 @@ impl<'a> VectorSearchBuilder<'a> {
}
/// Restrict the columns materialized by
[`execute_read`](Self::execute_read)
- /// to `cols` (plus the always-appended `_PKEY_VECTOR_SCORE`). Without this
+ /// to `cols` (plus the always-appended `__paimon_search_score`). Without
this
/// call `execute_read` materializes every user table column. Only affects
/// `execute_read`; the search-only paths ignore it.
pub fn with_projection(&mut self, cols: &[&str]) -> &mut Self {
@@ -241,11 +240,11 @@ impl<'a> VectorSearchBuilder<'a> {
}
/// Run the vector search and materialize the matching rows as Arrow
batches,
- /// ordered best-first. Only supported for primary-key vector indexes; a
- /// data-evolution table or a query targeting a non-PK-vector column fails
- /// loud. Output columns are the projected user table columns (all user
- /// columns by default, or those set via
- /// [`with_projection`](Self::with_projection)) plus `_PKEY_VECTOR_SCORE`;
+ /// ordered best-first. Supported for both primary-key vector indexes and
+ /// data-evolution (global-index) vector search; a query targeting a column
+ /// that is neither fails loud. Output columns are the projected user table
+ /// columns (all user columns by default, or those set via
+ /// [`with_projection`](Self::with_projection)) plus
`__paimon_search_score`;
/// `_ROW_ID` and `_PKEY_VECTOR_POSITION` are always hidden.
pub async fn execute_read(&self) -> crate::Result<ArrowRecordBatchStream> {
// Fail closed: returns data outside `TableScan`/`TableRead`.
@@ -283,10 +282,99 @@ impl<'a> VectorSearchBuilder<'a> {
}
}
- Err(crate::Error::DataInvalid {
- message: "vector search read is only supported for primary-key
vector indexes".into(),
- source: None,
- })
+ // Data-evolution (global-index) vector search: materialize rows from
the
+ // scored global row-ids and attach the unified score column. A
non-vector
+ // column or a set filter fails loud inside execute_scored below.
+ self.execute_de_vector_read().await
+ }
+
+ /// Materialize the best-first data-evolution vector search hits into Arrow
+ /// rows. The global-index search returns global `_ROW_ID`s and their
scores; a
+ /// subsequent row-range read materializes those rows, and each row's
score is
+ /// joined back by `_ROW_ID`. Output columns are the projected user table
+ /// columns (all user columns by default) plus `__paimon_search_score`;
`_ROW_ID`
+ /// is always hidden. A filter is unsupported here and fails loud inside
+ /// `execute_scored`.
+ async fn execute_de_vector_read(&self) ->
crate::Result<ArrowRecordBatchStream> {
+ // Validate the target column exists and is a vector-bearing type
before any
+ // work. The data-evolution search returns an empty result for an
unknown
+ // field (its scored-path behavior), which would make a typo'd or
scalar
+ // column look like a normal empty read here — violating
`execute_read`'s
+ // fail-loud contract (a C/Doris caller would see EOF, not an input
error).
+ // Reject it up front instead.
+ let vector_column =
+ self.vector_column
+ .as_deref()
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: "Vector column must be set via
with_vector_column()".to_string(),
+ })?;
+ let field = self
+ .table
+ .schema()
+ .fields()
+ .iter()
+ .find(|f| f.name() == vector_column)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("vector search column '{vector_column}' does
not exist"),
+ source: None,
+ })?;
+ // Require a FLOAT-element vector column: `ARRAY<FLOAT>` or
`VECTOR<FLOAT>`,
+ // matching the element type the vector index/search operates on. An
+ // `ARRAY<INT>` (or any non-float element) is not a searchable vector
column.
+ let is_float_vector = match field.data_type() {
+ DataType::Vector(t) => matches!(t.element_type(),
DataType::Float(_)),
+ DataType::Array(t) => matches!(t.element_type(),
DataType::Float(_)),
+ _ => false,
+ };
+ if !is_float_vector {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "vector search column '{vector_column}' must be a FLOAT
vector column \
+ (ARRAY<FLOAT> or VECTOR<FLOAT>), got {:?}",
+ field.data_type()
+ ),
+ source: None,
+ });
+ }
+
+ let sr = self.execute_scored().await?;
+
+ // Resolve the projected user columns up front so an invalid projection
+ // fails loud even when the result is empty.
+ let mut read_type = self.resolve_materialize_read_type()?;
+
+ if sr.is_empty() {
+ return Ok(Box::pin(stream::empty()));
+ }
+
+ // rank = ordinal in the best-first scored result; score = the aligned
score.
+ // Build ranges first (validates ids fit in i64::MAX) before
constructing the map.
+ let ranges = sr.to_row_ranges()?;
+ let mut rank_score_of: HashMap<i64, (usize, f32)> = HashMap::new();
+ for (rank, (&id, &score)) in
sr.row_ids.iter().zip(sr.scores.iter()).enumerate() {
+ rank_score_of.insert(id as i64, (rank, score));
+ }
+
+ // Add _ROW_ID as the join key for score alignment; it is stripped
before output.
+ if !read_type.iter().any(|f| f.name() == ROW_ID_FIELD_NAME) {
+ read_type.push(row_id_data_field());
+ }
+
+ let mut read_builder = self.table.new_read_builder();
+ read_builder
+ .with_read_type(read_type)
+ .with_row_ranges(ranges);
+ let scan = read_builder.new_scan();
+ let plan = scan.plan().await?;
+ let table_read = read_builder.new_read()?;
+ let mut stream = table_read.to_arrow(plan.splits())?;
+
+ let mut batches: Vec<RecordBatch> = Vec::new();
+ while let Some(batch) = stream.try_next().await? {
+ batches.push(batch);
+ }
+ let output = attach_scores_by_row_id(&batches, &rank_score_of,
sr.len())?;
+ Ok(Box::pin(stream::iter(output.into_iter().map(Ok))))
}
/// Shared PK-vector search core for both the search-only and
search-and-read
@@ -493,7 +581,7 @@ impl<'a> VectorSearchBuilder<'a> {
///
/// Output columns are the projected user table columns (all user columns
when
/// [`with_projection`](Self::with_projection) was not called) plus
- /// `_PKEY_VECTOR_SCORE`; `_ROW_ID` and `_PKEY_VECTOR_POSITION` are always
+ /// `__paimon_search_score`; `_ROW_ID` and `_PKEY_VECTOR_POSITION` are
always
/// hidden. Rows are emitted best-first (the candidate order), which
differs
/// from the file/position order the orchestrator materializes in.
async fn execute_primary_key_vector_read(
@@ -584,20 +672,27 @@ impl<'a> VectorSearchBuilder<'a> {
/// names resolved via `resolve_projected_fields`. Rejects reserved
metadata
/// names and `_ROW_ID` so a user cannot request a hidden column.
fn resolve_materialize_read_type(&self) -> crate::Result<Vec<DataField>> {
+ let is_reserved = |name: &str| {
+ name == PKEY_VECTOR_POSITION_COLUMN
+ || name == SEARCH_SCORE_COLUMN
+ || name == ROW_ID_FIELD_NAME
+ || name == "_PKEY_VECTOR_SCORE"
+ };
+ let reserved_err = |name: &str| crate::Error::DataInvalid {
+ message: format!(
+ "vector search read projection must not request reserved
column '{name}'"
+ ),
+ source: None,
+ };
let fields = match &self.projection {
None => self.table.schema().fields().to_vec(),
Some(names) => {
+ // Reject a requested reserved name on the raw list first:
these
+ // names are not real table columns, so
`resolve_projected_fields`
+ // would otherwise fail with a confusing "not found" error.
for name in names {
- if name == PKEY_VECTOR_POSITION_COLUMN
- || name == PKEY_VECTOR_SCORE_COLUMN
- || name == ROW_ID_FIELD_NAME
- {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "vector search read projection must not
request reserved column '{name}'"
- ),
- source: None,
- });
+ if is_reserved(name) {
+ return Err(reserved_err(name));
}
}
resolve_projected_fields(
@@ -608,6 +703,17 @@ impl<'a> VectorSearchBuilder<'a> {
)?
}
};
+ // Reject reserved output-column names on the RESOLVED field list too
— this
+ // is what catches the default (no `with_projection`) case where a
user table
+ // column is literally named `__paimon_search_score` (or the legacy
+ // `_PKEY_VECTOR_SCORE` alias): it would otherwise survive and collide
with
+ // the score column appended during materialization, producing two
+ // identically named output columns.
+ for field in &fields {
+ if is_reserved(field.name()) {
+ return Err(reserved_err(field.name()));
+ }
+ }
Ok(fields)
}
}
@@ -1199,7 +1305,7 @@ fn collect_ranked_rows(
/// Reorder the materialized rows into best-first order and drop the internal
/// `_PKEY_VECTOR_POSITION` column, yielding a single output batch (empty input
-/// yields no batches). The projected user columns and `_PKEY_VECTOR_SCORE` are
+/// yields no batches). The projected user columns and `__paimon_search_score`
are
/// retained.
fn reorder_and_strip_position(
batches: &[RecordBatch],
@@ -1221,7 +1327,7 @@ fn reorder_and_strip_position(
})?;
// Drop the internal position column; keep every other column (projected
user
- // columns + _PKEY_VECTOR_SCORE) in order.
+ // columns + __paimon_search_score) in order.
let position_idx = reordered
.schema()
.index_of(PKEY_VECTOR_POSITION_COLUMN)
@@ -1241,6 +1347,132 @@ fn reorder_and_strip_position(
Ok(vec![projected])
}
+/// The `_ROW_ID` field to append to a data-evolution read type so the reader
+/// fills each row's global id. Mirrors the field the `DataEvolutionReader`
+/// recognizes (Int64 / `BigInt`, nullable): a data file lacking `first_row_id`
+/// yields nulls here, which `attach_scores_by_row_id` then fails loud on
rather
+/// than mis-aligning scores.
+fn row_id_data_field() -> DataField {
+ DataField::new(
+ ROW_ID_FIELD_ID,
+ ROW_ID_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::with_nullable(true)),
+ )
+}
+
+/// Collect materialized DE rows, join each row's `(rank, score)` by its global
+/// `_ROW_ID`, reorder to the search rank order, append the
`__paimon_search_score`
+/// column, and drop `_ROW_ID`. Every row must map to a search candidate and
the
+/// total materialized count must equal `expected_len`; a miss or count
mismatch
+/// fails loud rather than silently dropping or NaN-scoring a row. Empty input
+/// yields no batches.
+fn attach_scores_by_row_id(
+ batches: &[RecordBatch],
+ rank_score_of: &HashMap<i64, (usize, f32)>,
+ expected_len: usize,
+) -> crate::Result<Vec<RecordBatch>> {
+ // (rank, batch_index, row_index, score) per materialized row.
+ let mut ranked: Vec<(usize, usize, usize, f32)> = Vec::new();
+ for (batch_index, batch) in batches.iter().enumerate() {
+ let row_id_idx =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|_| crate::Error::DataInvalid {
+ message: format!("materialized batch missing
{ROW_ID_FIELD_NAME} column"),
+ source: None,
+ })?;
+ let col = batch.column(row_id_idx);
+ let ids =
+ col.as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("{ROW_ID_FIELD_NAME} column is not
Int64"),
+ source: None,
+ })?;
+ for row_index in 0..batch.num_rows() {
+ if ids.is_null(row_index) {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "materialized DE vector row has null
{ROW_ID_FIELD_NAME}; cannot align score"
+ ),
+ source: None,
+ });
+ }
+ let id = ids.value(row_index);
+ let (rank, score) =
+ *rank_score_of
+ .get(&id)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "materialized DE vector row (row id {id}) has no
matching search candidate"
+ ),
+ source: None,
+ })?;
+ ranked.push((rank, batch_index, row_index, score));
+ }
+ }
+
+ if ranked.len() != expected_len {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "DE vector materialization produced {} rows but search
returned {expected_len}",
+ ranked.len()
+ ),
+ source: None,
+ });
+ }
+ if ranked.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ ranked.sort_by_key(|r| r.0);
+ let indices: Vec<(usize, usize)> = ranked.iter().map(|r| (r.1,
r.2)).collect();
+ let refs: Vec<&RecordBatch> = batches.iter().collect();
+ let reordered =
+ interleave_record_batch(&refs, &indices).map_err(|e|
crate::Error::DataInvalid {
+ message: format!("failed to reorder DE vector search rows: {e}"),
+ source: None,
+ })?;
+
+ // Drop _ROW_ID.
+ let row_id_idx = reordered
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|_| crate::Error::DataInvalid {
+ message: format!("reordered batch missing {ROW_ID_FIELD_NAME}
column"),
+ source: None,
+ })?;
+ let keep: Vec<usize> = (0..reordered.num_columns())
+ .filter(|i| *i != row_id_idx)
+ .collect();
+ let stripped = reordered
+ .project(&keep)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to drop {ROW_ID_FIELD_NAME} column: {e}"),
+ source: None,
+ })?;
+
+ // Append the score column in rank order.
+ let scores: Vec<f32> = ranked.iter().map(|r| r.3).collect();
+ let score_array: Arc<dyn Array> = Arc::new(Float32Array::from(scores));
+ let mut fields: Vec<Arc<arrow_schema::Field>> =
+ stripped.schema().fields().iter().cloned().collect();
+ fields.push(Arc::new(arrow_schema::Field::new(
+ SEARCH_SCORE_COLUMN,
+ arrow_schema::DataType::Float32,
+ false,
+ )));
+ let out_schema = Arc::new(arrow_schema::Schema::new(fields));
+ let mut columns = stripped.columns().to_vec();
+ columns.push(score_array);
+ let out = RecordBatch::try_new(out_schema, columns).map_err(|e|
crate::Error::DataInvalid {
+ message: format!("failed to append DE vector score column: {e}"),
+ source: None,
+ })?;
+ Ok(vec![out])
+}
+
fn indexed_search_limit(limit: usize, refine_factor: usize) ->
crate::Result<usize> {
if refine_factor == 0 {
return Ok(limit);
@@ -2052,8 +2284,9 @@ mod tests {
IndexFileMeta, IndexManifestEntry, IntType, PredicateBuilder, Schema,
TableSchema,
};
use crate::table::source::DataSplitBuilder;
+ use crate::table::{TableCommit, TableWrite};
use crate::vindex::IVF_FLAT_IDENTIFIER;
- use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
+ use arrow_array::builder::{FixedSizeListBuilder, Float32Builder,
ListBuilder};
use arrow_array::ArrayRef;
use arrow_array::Int32Array;
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
@@ -2786,6 +3019,87 @@ mod tests {
)
}
+ /// A data-evolution (global-index) vector table with a committed IVF-flat
+ /// index over the `embedding` column: row-tracking + data-evolution +
+ /// global-index enabled so committed data files carry `first_row_id` and
the
+ /// search returns global row-ids that `execute_read` can materialize. The
+ /// returned table has one committed batch of `(id, embedding)` rows and a
real
+ /// vindex index built end-to-end.
+ async fn de_vector_table() -> Table {
+ let table_path = "memory:/de_vector_search_test";
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("global-index.enabled", "true")
+ .option("global-index.row-count-per-shard", "10")
+ .option("ivf-flat.dimension", "2")
+ .option("ivf-flat.nlist", "2")
+ .build()
+ .unwrap();
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "de_vector_test"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ file_io
+ .mkdirs(&format!("{table_path}/snapshot/"))
+ .await
+ .unwrap();
+ file_io
+ .mkdirs(&format!("{table_path}/manifest/"))
+ .await
+ .unwrap();
+
+ let ids = vec![1, 2, 3];
+ let vectors = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
+ 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 vectors {
+ 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("embedding", ArrowDataType::List(element_field),
true),
+ ]));
+ let batch = RecordBatch::try_new(
+ arrow_schema,
+ vec![
+ Arc::new(Int32Array::from(ids)) as ArrayRef,
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap();
+
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ table_write.write_arrow_batch(&batch).await.unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ let built = table
+ .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding")
+ .execute()
+ .await
+ .unwrap();
+ assert!(built > 0, "DE fixture must build a global vector index");
+ table
+ }
+
#[tokio::test]
async fn pk_branch_disabled_falls_through_to_de_path() {
// No pk-vector.index.columns: behaves exactly as the DE path. With no
@@ -3031,13 +3345,13 @@ mod tests {
// ---- Task B: search-and-read (`execute_read`) tests ----
/// Build a small materialization batch: user column `id: Int32`, the
internal
- /// `_PKEY_VECTOR_POSITION: Int64`, and `_PKEY_VECTOR_SCORE: Float32`
(mirroring
+ /// `_PKEY_VECTOR_POSITION: Int64`, and `__paimon_search_score: Float32`
(mirroring
/// what `PkVectorIndexedSplitRead` emits for a single file).
fn materialized_batch(rows: &[(i32, i64, f32)]) -> RecordBatch {
let schema = Arc::new(ArrowSchema::new(vec![
ArrowField::new("id", ArrowDataType::Int32, false),
ArrowField::new(PKEY_VECTOR_POSITION_COLUMN, ArrowDataType::Int64,
false),
- ArrowField::new(PKEY_VECTOR_SCORE_COLUMN, ArrowDataType::Float32,
false),
+ ArrowField::new(SEARCH_SCORE_COLUMN, ArrowDataType::Float32,
false),
]));
let ids = Int32Array::from(rows.iter().map(|(id, _, _)|
*id).collect::<Vec<_>>());
let positions = Int64Array::from(rows.iter().map(|(_, pos, _)|
*pos).collect::<Vec<_>>());
@@ -3099,7 +3413,7 @@ mod tests {
assert_eq!(i32_col(out, "id"), vec![41, 42, 40]);
// Score column preserved and aligned to the reordered rows.
assert_eq!(
- f32_col(out, PKEY_VECTOR_SCORE_COLUMN),
+ f32_col(out, SEARCH_SCORE_COLUMN),
vec![l2_score(1.0), l2_score(4.0), l2_score(9.0)]
);
// Position column dropped; _ROW_ID never present.
@@ -3127,7 +3441,7 @@ mod tests {
let out = reorder_and_strip_position(&batches, ranked).unwrap();
assert_eq!(i32_col(&out[0], "id"), vec![20, 11, 10]);
assert_eq!(
- f32_col(&out[0], PKEY_VECTOR_SCORE_COLUMN),
+ f32_col(&out[0], SEARCH_SCORE_COLUMN),
vec![l2_score(0.5), l2_score(1.0), l2_score(9.0)]
);
}
@@ -3154,49 +3468,337 @@ mod tests {
);
}
+ #[test]
+ fn attach_scores_reorders_by_rank_not_score() {
+ use arrow_array::{Int32Array, Int64Array, RecordBatch};
+ use arrow_schema::{DataType, Field, Schema};
+ use std::sync::Arc;
+
+ // Two rows materialized in row-id order [10, 20]; ranks say 20 is
best (rank 0),
+ // 10 is rank 1. Scores tie at 0.5 to prove ordering follows rank, not
score.
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new(ROW_ID_FIELD_NAME, DataType::Int64, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![100, 200])),
+ Arc::new(Int64Array::from(vec![10, 20])),
+ ],
+ )
+ .unwrap();
+ let mut map = HashMap::new();
+ map.insert(20i64, (0usize, 0.5f32));
+ map.insert(10i64, (1usize, 0.5f32));
+
+ let out = attach_scores_by_row_id(&[batch], &map, 2).unwrap();
+ assert_eq!(out.len(), 1);
+ let b = &out[0];
+ // _ROW_ID stripped, score appended.
+ assert!(b.schema().index_of(ROW_ID_FIELD_NAME).is_err());
+ let score_idx = b.schema().index_of("__paimon_search_score").unwrap();
+ assert_eq!(
+ b.schema().field(score_idx).data_type(),
+ &arrow_schema::DataType::Float32
+ );
+ // Row order is rank order: id 200 (rank 0) first, then id 100 (rank
1).
+ let ids = b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
+ assert_eq!(ids.values(), &[200, 100]);
+ }
+
+ #[test]
+ fn attach_scores_fails_on_unknown_row_id() {
+ use arrow_array::{Int32Array, Int64Array, RecordBatch};
+ use arrow_schema::{DataType, Field, Schema};
+ use std::sync::Arc;
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new(ROW_ID_FIELD_NAME, DataType::Int64, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![99])),
+ ],
+ )
+ .unwrap();
+ let map: HashMap<i64, (usize, f32)> = HashMap::new(); // no entry for
99
+ let err = attach_scores_by_row_id(&[batch], &map, 1).unwrap_err();
+ assert!(matches!(err, crate::Error::DataInvalid { .. }));
+ }
+
+ #[test]
+ fn attach_scores_fails_on_count_mismatch() {
+ use arrow_array::{Int32Array, Int64Array, RecordBatch};
+ use arrow_schema::{DataType, Field, Schema};
+ use std::sync::Arc;
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new(ROW_ID_FIELD_NAME, DataType::Int64, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![10])),
+ ],
+ )
+ .unwrap();
+ let mut map = HashMap::new();
+ map.insert(10i64, (0usize, 0.5f32));
+ // expected_len 2 but only 1 row materialized.
+ let err = attach_scores_by_row_id(&[batch], &map, 2).unwrap_err();
+ assert!(matches!(err, crate::Error::DataInvalid { .. }));
+ }
+
+ #[test]
+ fn attach_scores_fails_on_null_row_id() {
+ use arrow_array::{Int32Array, Int64Array, RecordBatch};
+ use arrow_schema::{DataType, Field, Schema};
+ use std::sync::Arc;
+ // _ROW_ID column has a NULL at row 1; the map contains the non-null
id, so
+ // the failure is specifically the null (not an unknown id).
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new(ROW_ID_FIELD_NAME, DataType::Int64, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![1, 2])),
+ Arc::new(Int64Array::from(vec![Some(10i64), None])),
+ ],
+ )
+ .unwrap();
+ let mut map = HashMap::new();
+ map.insert(10i64, (0usize, 0.5f32));
+ let err = attach_scores_by_row_id(&[batch], &map, 2).unwrap_err();
+ assert!(matches!(err, crate::Error::DataInvalid { .. }));
+ }
+
+ #[test]
+ fn attach_scores_fails_on_wrong_type_row_id() {
+ use arrow_array::{Int32Array, RecordBatch};
+ use arrow_schema::{DataType, Field, Schema};
+ use std::sync::Arc;
+ // _ROW_ID column is Int32, not Int64: the downcast fails loud.
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("id", DataType::Int32, false),
+ Field::new(ROW_ID_FIELD_NAME, DataType::Int32, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int32Array::from(vec![10])),
+ ],
+ )
+ .unwrap();
+ let mut map = HashMap::new();
+ map.insert(10i64, (0usize, 0.5f32));
+ let err = attach_scores_by_row_id(&[batch], &map, 1).unwrap_err();
+ assert!(matches!(err, crate::Error::DataInvalid { .. }));
+ }
+
#[tokio::test]
- async fn execute_read_de_table_fails_loud() {
- // No pk-vector index configured: execute_read must fail loud (the DE
path
- // has no row materialization).
+ async fn execute_read_de_table_empty_snapshot_yields_empty_stream() {
+ // No pk-vector index configured and no snapshot: execute_read routes
to the
+ // data-evolution path, whose search finds nothing and returns an empty
+ // stream (not an error).
let table = pk_vector_table(&[]);
- let err = table
+ let mut stream = table
.new_vector_search_builder()
.with_vector_column("embedding")
.with_query_vector(vec![1.0])
.with_limit(5)
.execute_read()
.await
- .map(|_| ())
- .expect_err("DE read must fail loud");
- assert!(
- matches!(err, crate::Error::DataInvalid { ref message, .. }
- if message.contains("only supported for primary-key")),
- "unexpected error: {err:?}"
- );
+ .expect("DE read over an empty table must succeed with no rows");
+ let mut rows = 0usize;
+ while let Some(batch) = stream.try_next().await.unwrap() {
+ rows += batch.num_rows();
+ }
+ assert_eq!(rows, 0, "empty DE table must yield no rows");
}
#[tokio::test]
- async fn execute_read_non_pk_column_fails_loud() {
+ async fn execute_read_unknown_column_fails_loud() {
// pk-vector index configured for "embedding", but the query targets a
- // different column -> read is unsupported.
+ // column that does not exist. The read path must fail loud rather than
+ // fall through to the data-evolution path and return an empty stream
(a
+ // typo must not look like a normal empty read through the C API).
let table = pk_vector_table(&[
("pk-vector.index.columns", "embedding"),
("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
("fields.embedding.pk-vector.distance.metric", "l2"),
]);
- let err = table
+ let err = match table
.new_vector_search_builder()
.with_vector_column("other")
.with_query_vector(vec![1.0])
.with_limit(5)
.execute_read()
.await
- .map(|_| ())
- .expect_err("non-PK column read must fail loud");
+ {
+ Ok(_) => panic!("unknown vector column must fail loud on
execute_read"),
+ Err(e) => e,
+ };
assert!(
- matches!(err, crate::Error::DataInvalid { ref message, .. }
- if message.contains("only supported for primary-key")),
- "unexpected error: {err:?}"
+ matches!(&err, crate::Error::DataInvalid { message, .. } if
message.contains("does not exist")),
+ "expected a does-not-exist error, got: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn execute_read_scalar_column_fails_loud() {
+ // A scalar (non-vector) column targeted by a vector read must fail
loud,
+ // not return an empty data-evolution stream.
+ let table = pk_vector_table(&[
+ ("pk-vector.index.columns", "embedding"),
+ ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
+ ("fields.embedding.pk-vector.distance.metric", "l2"),
+ ]);
+ let err = match table
+ .new_vector_search_builder()
+ .with_vector_column("id") // scalar Int column
+ .with_query_vector(vec![1.0])
+ .with_limit(5)
+ .execute_read()
+ .await
+ {
+ Ok(_) => panic!("scalar vector column must fail loud on
execute_read"),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(&err, crate::Error::DataInvalid { message, .. } if
message.contains("must be a FLOAT vector column")),
+ "expected a not-a-vector-column error, got: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn execute_read_non_float_vector_column_fails_loud() {
+ // An ARRAY<INT> column is not a searchable vector column (the
index/search
+ // operates on FLOAT elements). It must fail loud rather than fall
through
+ // to the DE path and return an empty stream.
+ use crate::spec::{ArrayType, IntType, Schema, TableSchema};
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+ DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ )
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "de_non_float_vector"),
+ "memory:/de_non_float_vector".to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let err = match table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0])
+ .with_limit(5)
+ .execute_read()
+ .await
+ {
+ Ok(_) => panic!("ARRAY<INT> vector column must fail loud on
execute_read"),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(&err, crate::Error::DataInvalid { message, .. } if
message.contains("must be a FLOAT vector column")),
+ "expected a FLOAT-vector-column error, got: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn
execute_read_default_projection_rejects_reserved_score_column_name() {
+ // A user table column literally named `__paimon_search_score` must be
+ // rejected even under the default (no `with_projection`) projection —
+ // otherwise it survives and collides with the score column appended
during
+ // materialization, producing two identically named output columns.
+ use crate::spec::{FloatType, IntType, Schema, TableSchema, VectorType};
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+ DataType::Vector(
+ VectorType::try_new(true, 2,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ )
+ .column(SEARCH_SCORE_COLUMN, DataType::Float(FloatType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("pk-vector.index.columns", "embedding")
+ .option("fields.embedding.pk-vector.index.type",
IVF_FLAT_IDENTIFIER)
+ .option("fields.embedding.pk-vector.distance.metric", "l2")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "pk_vector_collision"),
+ "memory:/pk_vector_collision".to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let err = match table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(5)
+ .execute_read()
+ .await
+ {
+ Ok(_) => panic!("default projection over a reserved-named column
must fail loud"),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(&err, crate::Error::DataInvalid { message, .. } if
message.contains("reserved column")),
+ "expected a reserved-column error, got: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn
execute_read_de_default_projection_rejects_reserved_score_column_name() {
+ // Same collision, on the data-evolution path: a table with no
PK-vector
+ // index (so the query falls through to DE materialization) but a user
+ // column named `__paimon_search_score` must fail loud under the
default
+ // projection rather than emit two identically named columns.
+ use crate::spec::{ArrayType, FloatType, IntType, Schema, TableSchema};
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .column(SEARCH_SCORE_COLUMN, DataType::Float(FloatType::new()))
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "de_vector_collision"),
+ "memory:/de_vector_collision".to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let err = match table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0])
+ .with_limit(5)
+ .execute_read()
+ .await
+ {
+ Ok(_) => panic!("DE default projection over a reserved-named
column must fail loud"),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(&err, crate::Error::DataInvalid { message, .. } if
message.contains("reserved column")),
+ "expected a reserved-column error, got: {err}"
);
}
@@ -3215,7 +3817,8 @@ mod tests {
for reserved in [
ROW_ID_FIELD_NAME,
PKEY_VECTOR_POSITION_COLUMN,
- PKEY_VECTOR_SCORE_COLUMN,
+ SEARCH_SCORE_COLUMN,
+ "_PKEY_VECTOR_SCORE",
] {
let mut builder = table.new_vector_search_builder();
builder
@@ -3249,7 +3852,8 @@ mod tests {
for reserved in [
ROW_ID_FIELD_NAME,
PKEY_VECTOR_POSITION_COLUMN,
- PKEY_VECTOR_SCORE_COLUMN,
+ SEARCH_SCORE_COLUMN,
+ "_PKEY_VECTOR_SCORE",
] {
let mut builder = table.new_vector_search_builder();
builder
@@ -3295,6 +3899,73 @@ mod tests {
let names: Vec<&str> = fields.iter().map(|f| f.name()).collect();
assert_eq!(names, vec!["id"]);
}
+
+ #[tokio::test]
+ async fn de_execute_read_materializes_rows_with_score() {
+ // A data-evolution vector table with a committed global index:
execute_read
+ // must materialize one row per scored hit and carry the unified score
+ // column, in best-first rank order.
+ let table = de_vector_table().await;
+ let query = vec![1.0, 0.0];
+
+ let scored = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(query.clone())
+ .with_limit(3)
+ .execute_scored()
+ .await
+ .unwrap();
+ assert!(!scored.is_empty(), "DE search must return hits");
+
+ let mut stream = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(query)
+ .with_limit(3)
+ .execute_read()
+ .await
+ .unwrap();
+
+ let mut rows = 0usize;
+ let mut saw_score = false;
+ while let Some(batch) = stream.try_next().await.unwrap() {
+ rows += batch.num_rows();
+ saw_score |= batch.schema().index_of(SEARCH_SCORE_COLUMN).is_ok();
+ }
+ assert_eq!(
+ rows,
+ scored.len(),
+ "DE read must emit exactly the scored result count"
+ );
+ assert!(
+ saw_score,
+ "DE read output must carry the search score column"
+ );
+ }
+
+ #[tokio::test]
+ async fn de_execute_read_with_filter_fails_loud() {
+ // A filter on the data-evolution path is unsupported (the DE path
never
+ // reads physical rows), so execute_read must fail loud rather than
drop the
+ // predicate. The guard lives in execute_scored.
+ let table = de_vector_table().await;
+ let filter = id_gt_filter(&table, 1);
+ let err = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(3)
+ .with_filter(filter)
+ .execute_read()
+ .await
+ .map(|_| ())
+ .expect_err("DE read with a filter must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { .. }),
+ "unexpected error: {err:?}"
+ );
+ }
}
/// Tests for [`residual_positions_by_file`]: the residual predicate is
applied at
diff --git a/crates/paimon/tests/pk_vector_baseline_test.rs
b/crates/paimon/tests/pk_vector_baseline_test.rs
index 18629315..8ec2fa62 100644
--- a/crates/paimon/tests/pk_vector_baseline_test.rs
+++ b/crates/paimon/tests/pk_vector_baseline_test.rs
@@ -22,7 +22,7 @@
//! the snapshot/manifest/index-manifest metadata — then reads it back through
the
//! public `new_vector_search_builder()` API and asserts both the search result
//! (`execute_scored()` -> `row_ids`/`scores`) and the materialized rows
-//! (`execute_read()` -> Arrow batches, best-first order,
`_PKEY_VECTOR_SCORE`).
+//! (`execute_read()` -> Arrow batches, best-first order,
`__paimon_search_score`).
//!
//! Why Rust-built rather than a committed cross-language fixture: the Java
//! primary-key vector ANN segment is an opaque native Lumina format that
cannot
@@ -491,7 +491,7 @@ async fn read_id_and_scores(
let scores: Vec<f32> = batches
.iter()
.flat_map(|b| {
- let idx = b.schema().index_of("_PKEY_VECTOR_SCORE").unwrap();
+ let idx = b.schema().index_of("__paimon_search_score").unwrap();
b.column(idx)
.as_any()
.downcast_ref::<Float32Array>()
@@ -601,7 +601,7 @@ async fn
pk_vector_end_to_end_returns_expected_row_ids_and_scores() {
);
// Search-and-read: execute_read() materializes the matching rows
best-first
- // with a `_PKEY_VECTOR_SCORE` column, hiding
`_ROW_ID`/`_PKEY_VECTOR_POSITION`.
+ // with a `__paimon_search_score` column, hiding
`_ROW_ID`/`_PKEY_VECTOR_POSITION`.
// Projection ['id'] excludes the vector column.
let (ids, scores, batches) = read_id_and_scores(&table, query.to_vec(), 3,
Some(&["id"])).await;
@@ -634,7 +634,7 @@ async fn
pk_vector_end_to_end_returns_expected_row_ids_and_scores() {
/// back a fixture whose nearest neighbours are at physical positions 5, 1, 3
(in
/// that order) and asserts the materialized output is emitted best-first
/// [5, 1, 3], not in ascending physical position [1, 3, 5]. Also asserts the
full
-/// row content (id + vector values) and the aligned `_PKEY_VECTOR_SCORE`,
with no
+/// row content (id + vector values) and the aligned `__paimon_search_score`,
with no
/// `_ROW_ID`/`_PKEY_VECTOR_POSITION` leaking.
// Gated off Windows for the same `file://` tempdir reason as the test above.
#[cfg(not(windows))]
@@ -680,7 +680,7 @@ async fn
pk_vector_read_orders_rows_best_first_not_by_position() {
);
}
- // Score alignment: `_PKEY_VECTOR_SCORE` matches metric.distance_to_score
for
+ // Score alignment: `__paimon_search_score` matches
metric.distance_to_score for
// each emitted row, in best-first order.
assert_eq!(scores.len(), 3);
for (got, (_, distance)) in scores.iter().zip(&expected) {
@@ -712,7 +712,7 @@ async fn
pk_vector_read_orders_rows_best_first_not_by_position() {
/// Shared body for the two physical-coordinate contract tests below. Builds
the
/// discriminating fixture with the caller's `first_row_id`, reads it back
with the
/// default projection, and asserts the materialized rows are the file-LOCAL
-/// best-first top-k (id column, vector content, aligned `_PKEY_VECTOR_SCORE`),
+/// best-first top-k (id column, vector content, aligned
`__paimon_search_score`),
/// invariant to `first_row_id`. The discriminating fixture makes best-first
order
/// [5, 1, 3] distinct from ascending physical position [1, 3, 5], so a
/// position/global-id confusion cannot pass by coincidence.
@@ -862,7 +862,7 @@ async fn read_id_and_scores_filtered(
let scores: Vec<f32> = batches
.iter()
.flat_map(|b| {
- let idx = b.schema().index_of("_PKEY_VECTOR_SCORE").unwrap();
+ let idx = b.schema().index_of("__paimon_search_score").unwrap();
b.column(idx)
.as_any()
.downcast_ref::<Float32Array>()
@@ -954,7 +954,7 @@ async fn
pk_vector_residual_filter_excludes_non_matching_rows() {
);
// Search-and-read with the residual: default projection materializes id +
- // vector column, best-first, with an aligned `_PKEY_VECTOR_SCORE`.
+ // vector column, best-first, with an aligned `__paimon_search_score`.
let (ids, scores, batches) =
read_id_and_scores_filtered(&table, query.to_vec(), 3, residual).await;
diff --git a/crates/paimon/tests/pk_vector_java_fixture_test.rs
b/crates/paimon/tests/pk_vector_java_fixture_test.rs
index 20384ebd..579a58eb 100644
--- a/crates/paimon/tests/pk_vector_java_fixture_test.rs
+++ b/crates/paimon/tests/pk_vector_java_fixture_test.rs
@@ -200,7 +200,7 @@ async fn reads_back_java_written_pk_vector_table() {
// execute_read() materializes rows by physical position, so it MUST
succeed
// and emit the top-k best-first. The `id` column cross-checks the
- // position->id mapping (the fixture pins them equal) and
`_PKEY_VECTOR_SCORE`
+ // position->id mapping (the fixture pins them equal) and
`__paimon_search_score`
// carries the metric score.
let mut builder = table.new_vector_search_builder();
builder
@@ -222,7 +222,7 @@ async fn reads_back_java_written_pk_vector_table() {
"materialized `id` column must be best-first and match the analytic
top-k"
);
- let scores = batch_f32(&batches, "_PKEY_VECTOR_SCORE");
+ let scores = batch_f32(&batches, "__paimon_search_score");
assert_eq!(scores.len(), k);
for (got, want) in scores.iter().zip(&expected_scores) {
assert!(