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 80d7575 Add batch vector search core (#450)
80d7575 is described below
commit 80d7575c8f24a12da94b1710d66c57b4ba6a8c2f
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jul 4 21:31:11 2026 +0800
Add batch vector search core (#450)
---
crates/paimon/src/lumina/reader.rs | 130 +++++++
crates/paimon/src/table/mod.rs | 6 +-
crates/paimon/src/table/vector_search_builder.rs | 476 +++++++++++++++++++----
crates/paimon/src/vindex/reader.rs | 12 +
4 files changed, 547 insertions(+), 77 deletions(-)
diff --git a/crates/paimon/src/lumina/reader.rs
b/crates/paimon/src/lumina/reader.rs
index 331318b..9c365f4 100644
--- a/crates/paimon/src/lumina/reader.rs
+++ b/crates/paimon/src/lumina/reader.rs
@@ -122,6 +122,15 @@ impl LuminaVectorGlobalIndexReader {
self.search(vector_search)
}
+ pub fn visit_batch_vector_search<S: Read + Seek + Send + 'static>(
+ &mut self,
+ vector_searches: &[VectorSearch],
+ stream_fn: impl FnOnce(&str) -> crate::Result<S>,
+ ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+ self.ensure_loaded(stream_fn)?;
+ self.search_batch(vector_searches)
+ }
+
fn search(&self, vector_search: &VectorSearch) ->
crate::Result<Option<HashMap<u64, f32>>> {
let index_meta = self
.index_meta
@@ -148,6 +157,35 @@ impl LuminaVectorGlobalIndexReader {
search_lumina(searcher, index_meta, search_options_base, vector_search)
}
+ fn search_batch(
+ &self,
+ vector_searches: &[VectorSearch],
+ ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+ let index_meta = self
+ .index_meta
+ .as_ref()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "index_meta not initialized".to_string(),
+ source: None,
+ })?;
+ let searcher = self
+ .searcher
+ .as_ref()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "searcher not initialized".to_string(),
+ source: None,
+ })?;
+ let search_options_base =
+ self.search_options
+ .as_ref()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "search_options not initialized".to_string(),
+ source: None,
+ })?;
+
+ search_lumina_batch(searcher, index_meta, search_options_base,
vector_searches)
+ }
+
fn ensure_loaded<S: Read + Seek + Send + 'static>(
&mut self,
stream_fn: impl FnOnce(&str) -> crate::Result<S>,
@@ -272,6 +310,98 @@ fn search_lumina(
Ok(Some(id_to_scores))
}
+fn search_lumina_batch(
+ searcher: &LuminaSearcher,
+ index_meta: &LuminaIndexMeta,
+ search_options_base: &HashMap<String, String>,
+ vector_searches: &[VectorSearch],
+) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+ if vector_searches.is_empty() {
+ return Ok(Vec::new());
+ }
+ if vector_searches
+ .iter()
+ .any(|vector_search| vector_search.include_row_ids.is_some())
+ {
+ return vector_searches
+ .iter()
+ .map(|vector_search| {
+ search_lumina(searcher, index_meta, search_options_base,
vector_search)
+ })
+ .collect();
+ }
+
+ let limit = vector_searches[0].limit;
+ if vector_searches
+ .iter()
+ .any(|vector_search| vector_search.limit != limit)
+ {
+ return vector_searches
+ .iter()
+ .map(|vector_search| {
+ search_lumina(searcher, index_meta, search_options_base,
vector_search)
+ })
+ .collect();
+ }
+
+ let expected_dim = index_meta.dim()? as usize;
+ for vector_search in vector_searches {
+ if vector_search.vector.len() != expected_dim {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Query vector dimension mismatch: index expects {}, but
got {}",
+ expected_dim,
+ vector_search.vector.len()
+ ),
+ source: None,
+ });
+ }
+ }
+
+ let index_metric = index_meta.metric()?;
+ let count = searcher.get_count()? as usize;
+ let effective_k = std::cmp::min(limit, count);
+ if effective_k == 0 {
+ return Ok(vec![None; vector_searches.len()]);
+ }
+
+ let mut query = Vec::with_capacity(vector_searches.len() * expected_dim);
+ for vector_search in vector_searches {
+ query.extend_from_slice(&vector_search.vector);
+ }
+
+ let mut distances = vec![0.0f32; vector_searches.len() * effective_k];
+ let mut labels = vec![0u64; vector_searches.len() * effective_k];
+ let mut search_opts: HashMap<String, String> = search_options_base.clone();
+ ensure_search_list_size(&mut search_opts, effective_k);
+ searcher.search(
+ &query,
+ vector_searches.len() as i32,
+ effective_k as i32,
+ &mut distances,
+ &mut labels,
+ &search_opts,
+ )?;
+
+ let mut results = Vec::with_capacity(vector_searches.len());
+ for query_index in 0..vector_searches.len() {
+ let start = query_index * effective_k;
+ let end = start + effective_k;
+ let id_to_scores = collect_results(
+ &labels[start..end],
+ &distances[start..end],
+ effective_k,
+ index_metric,
+ );
+ if id_to_scores.is_empty() {
+ results.push(None);
+ } else {
+ results.push(Some(id_to_scores));
+ }
+ }
+ Ok(results)
+}
+
fn write_temp_index_file<S: Read + Seek>(stream: &mut S) ->
crate::Result<PathBuf> {
stream
.seek(SeekFrom::Start(0))
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index b961f76..1e1a18d 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -90,7 +90,7 @@ pub use table_scan::TableScan;
pub use table_update::TableUpdate;
pub use table_write::TableWrite;
pub use tag_manager::TagManager;
-pub use vector_search_builder::VectorSearchBuilder;
+pub use vector_search_builder::{BatchVectorSearchBuilder, VectorSearchBuilder};
pub use write_builder::WriteBuilder;
use crate::catalog::Identifier;
@@ -187,6 +187,10 @@ impl Table {
VectorSearchBuilder::new(self)
}
+ pub fn new_batch_vector_search_builder(&self) ->
BatchVectorSearchBuilder<'_> {
+ BatchVectorSearchBuilder::new(self)
+ }
+
pub fn new_lumina_index_build_builder(&self) ->
LuminaIndexBuildBuilder<'_> {
LuminaIndexBuildBuilder::new(self)
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 1ebd422..f7df42e 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -69,6 +69,13 @@ pub struct VectorSearchBuilder<'a> {
limit: Option<usize>,
}
+pub struct BatchVectorSearchBuilder<'a> {
+ table: &'a Table,
+ vector_column: Option<String>,
+ query_vectors: Option<Vec<Vec<f32>>>,
+ limit: Option<usize>,
+}
+
impl<'a> VectorSearchBuilder<'a> {
pub(crate) fn new(table: &'a Table) -> Self {
Self {
@@ -111,8 +118,77 @@ impl<'a> VectorSearchBuilder<'a> {
message: "Limit must be set via with_limit()".to_string(),
})?;
- let vector_search =
- VectorSearch::new(query_vector.clone(), limit,
vector_column.to_string())?;
+ let mut batch_builder = BatchVectorSearchBuilder::new(self.table);
+ let mut results = batch_builder
+ .with_vector_column(vector_column)
+ .with_query_vectors(vec![query_vector.clone()])
+ .with_limit(limit)
+ .execute()
+ .await?;
+
+ debug_assert_eq!(results.len(), 1);
+ results.remove(0).to_row_ranges()
+ }
+}
+
+impl<'a> BatchVectorSearchBuilder<'a> {
+ pub(crate) fn new(table: &'a Table) -> Self {
+ Self {
+ table,
+ vector_column: None,
+ query_vectors: None,
+ limit: None,
+ }
+ }
+
+ pub fn with_vector_column(&mut self, name: &str) -> &mut Self {
+ self.vector_column = Some(name.to_string());
+ self
+ }
+
+ pub fn with_query_vectors(&mut self, vectors: Vec<Vec<f32>>) -> &mut Self {
+ self.query_vectors = Some(vectors);
+ self
+ }
+
+ pub fn with_limit(&mut self, limit: usize) -> &mut Self {
+ self.limit = Some(limit);
+ self
+ }
+
+ pub async fn execute(&self) -> crate::Result<Vec<SearchResult>> {
+ 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(),
+ })?;
+ if vector_column.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Vector column must be set via
with_vector_column()".to_string(),
+ });
+ }
+
+ let query_vectors =
+ self.query_vectors
+ .as_ref()
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: "Query vectors must be set via
with_query_vectors()".to_string(),
+ })?;
+ if query_vectors.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Query vectors must be set via
with_query_vectors()".to_string(),
+ });
+ }
+
+ let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
+ message: "Limit must be set via with_limit()".to_string(),
+ })?;
+
+ let vector_searches = query_vectors
+ .iter()
+ .map(|vector| VectorSearch::new(vector.clone(), limit,
vector_column.to_string()))
+ .collect::<crate::Result<Vec<_>>>()?;
let snapshot_manager = SnapshotManager::new(
self.table.file_io().clone(),
@@ -121,7 +197,7 @@ impl<'a> VectorSearchBuilder<'a> {
let snapshot = match snapshot_manager.get_latest_snapshot().await? {
Some(s) => s,
- None => return Ok(Vec::new()),
+ None => return Ok(vec![SearchResult::empty();
vector_searches.len()]),
};
let index_entries = match snapshot.index_manifest() {
@@ -136,7 +212,7 @@ impl<'a> VectorSearchBuilder<'a> {
None => Vec::new(),
};
- evaluate_vector_search(
+ evaluate_batch_vector_search(
VectorSearchEvaluation {
table: Some(self.table),
file_io: self.table.file_io(),
@@ -146,12 +222,13 @@ impl<'a> VectorSearchBuilder<'a> {
next_row_id: snapshot.next_row_id(),
},
&index_entries,
- &vector_search,
+ &vector_searches,
)
.await
}
}
+#[derive(Clone, Copy)]
struct VectorSearchEvaluation<'a> {
table: Option<&'a Table>,
file_io: &'a FileIO,
@@ -161,18 +238,48 @@ struct VectorSearchEvaluation<'a> {
next_row_id: Option<i64>,
}
+#[cfg(test)]
async fn evaluate_vector_search(
evaluation: VectorSearchEvaluation<'_>,
index_entries: &[IndexManifestEntry],
vector_search: &VectorSearch,
) -> crate::Result<Vec<RowRange>> {
+ let mut results = evaluate_batch_vector_search(
+ evaluation,
+ index_entries,
+ std::slice::from_ref(vector_search),
+ )
+ .await?;
+ debug_assert_eq!(results.len(), 1);
+ results.remove(0).to_row_ranges()
+}
+
+async fn evaluate_batch_vector_search(
+ evaluation: VectorSearchEvaluation<'_>,
+ index_entries: &[IndexManifestEntry],
+ vector_searches: &[VectorSearch],
+) -> crate::Result<Vec<SearchResult>> {
+ if vector_searches.is_empty() {
+ return Ok(Vec::new());
+ }
+
let table_path = evaluation.table_path.trim_end_matches('/');
let search_mode =
CoreOptions::new(evaluation.table_options).global_index_search_mode()?;
-
- let field_id = match find_field_id_by_name(evaluation.schema_fields,
&vector_search.field_name)
+ let field_name = &vector_searches[0].field_name;
+ if vector_searches
+ .iter()
+ .any(|vector_search| vector_search.field_name != *field_name)
{
+ return Err(crate::Error::DataInvalid {
+ message: "Batch vector search requires all query vectors to use
the same field"
+ .to_string(),
+ source: None,
+ });
+ }
+
+ let field_id = match find_field_id_by_name(evaluation.schema_fields,
field_name) {
Some(id) => id,
- None => return Ok(Vec::new()),
+ None => return Ok(vec![SearchResult::empty(); vector_searches.len()]),
};
let vector_entries: Vec<_> = index_entries
@@ -188,10 +295,10 @@ async fn evaluate_vector_search(
.collect();
if vector_entries.is_empty() && search_mode == GlobalIndexSearchMode::Fast
{
- return Ok(Vec::new());
+ return Ok(vec![SearchResult::empty(); vector_searches.len()]);
}
- let mut merged = SearchResult::empty();
+ let mut merged = vec![SearchResult::empty(); vector_searches.len()];
if !vector_entries.is_empty() {
let futures: Vec<_> = vector_entries
.into_iter()
@@ -204,7 +311,7 @@ async fn evaluate_vector_search(
let file_size = entry.index_file.file_size as u64;
let index_meta_bytes =
global_meta.index_meta.clone().unwrap_or_default();
let row_range_start = global_meta.row_range_start;
- let vector_search_clone = vector_search.clone();
+ let vector_searches = vector_searches.to_vec();
let options = evaluation.table_options.clone();
let input = evaluation.file_io.new_input(&path);
async move {
@@ -222,34 +329,50 @@ async fn evaluate_vector_search(
let io_meta =
GlobalIndexIOMeta::new(file_name.clone(), file_size,
index_meta_bytes);
let data = bytes.to_vec();
- let result = match backend {
+ let results = match backend {
VectorIndexBackend::Lumina => {
let mut reader =
LuminaVectorGlobalIndexReader::new(io_meta, options);
- reader.visit_vector_search(&vector_search_clone,
|_| {
+ reader.visit_batch_vector_search(&vector_searches,
|_| {
Ok(Cursor::new(data))
})?
}
VectorIndexBackend::Vindex => {
let mut reader =
VindexVectorGlobalIndexReader::new(io_meta, options);
- reader.visit_vector_search(&vector_search_clone,
|_| {
+ reader.visit_batch_vector_search(&vector_searches,
|_| {
Ok(Cursor::new(data))
})?
}
};
-
- match result {
- Some(scored_map) => Ok::<_, crate::Error>(
-
SearchResult::from_scored_map(scored_map).offset(row_range_start),
- ),
- None => Ok(SearchResult::empty()),
+ if results.len() != vector_searches.len() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Batch vector search backend returned {}
results for {} query vectors",
+ results.len(),
+ vector_searches.len()
+ ),
+ source: None,
+ });
}
+
+ Ok::<_, crate::Error>(
+ results
+ .into_iter()
+ .map(|result| match result {
+ Some(scored_map) =>
SearchResult::from_scored_map(scored_map)
+ .offset(row_range_start),
+ None => SearchResult::empty(),
+ })
+ .collect::<Vec<_>>(),
+ )
}
})
.collect();
let results = futures::future::try_join_all(futures).await?;
- for r in &results {
- merged = merged.or(r);
+ for per_entry in &results {
+ for (query_index, result) in per_entry.iter().enumerate() {
+ merged[query_index] = merged[query_index].or(result);
+ }
}
}
@@ -283,16 +406,22 @@ async fn evaluate_vector_search(
evaluation.table_options,
index_entries,
field_id,
- &vector_search.field_name,
+ field_name,
)
.await?;
- let raw_result =
- read_raw_vector_search(table, vector_search, &raw_ranges,
metric).await?;
- merged = merged.or(&raw_result);
+ let raw_results =
+ read_raw_batch_vector_search(table, vector_searches,
&raw_ranges, metric).await?;
+ for (query_index, result) in raw_results.iter().enumerate() {
+ merged[query_index] = merged[query_index].or(result);
+ }
}
}
- merged.top_k(vector_search.limit).to_row_ranges()
+ Ok(merged
+ .into_iter()
+ .zip(vector_searches)
+ .map(|(result, vector_search)| result.top_k(vector_search.limit))
+ .collect())
}
fn is_vector_global_index_file(index_file: &IndexFileMeta) -> bool {
@@ -458,53 +587,99 @@ fn configured_raw_vector_metric(
Ok(inferred.unwrap_or(RawVectorMetric::L2))
}
-async fn read_raw_vector_search(
+async fn read_raw_batch_vector_search(
table: &Table,
- vector_search: &VectorSearch,
+ vector_searches: &[VectorSearch],
raw_ranges: &[RowRange],
metric: RawVectorMetric,
-) -> crate::Result<SearchResult> {
+) -> crate::Result<Vec<SearchResult>> {
+ if vector_searches.is_empty() {
+ return Ok(Vec::new());
+ }
if raw_ranges.is_empty() {
- return Ok(SearchResult::empty());
+ return Ok(vec![SearchResult::empty(); vector_searches.len()]);
+ }
+
+ let field_name = &vector_searches[0].field_name;
+ if vector_searches
+ .iter()
+ .any(|vector_search| vector_search.field_name != *field_name)
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Batch vector raw search requires all query vectors to
use the same field"
+ .to_string(),
+ source: None,
+ });
}
let mut read_builder = table.new_read_builder();
read_builder
- .with_projection(&[vector_search.field_name.as_str(),
ROW_ID_FIELD_NAME])
+ .with_projection(&[field_name.as_str(), ROW_ID_FIELD_NAME])
.with_row_ranges(raw_ranges.to_vec());
let plan = read_builder.new_scan().plan().await?;
if plan.splits().is_empty() {
- return Ok(SearchResult::empty());
+ return Ok(vec![SearchResult::empty(); vector_searches.len()]);
}
let read = read_builder.new_read()?;
let mut stream = read.to_arrow(plan.splits())?;
- let mut row_ids = Vec::new();
- let mut scores = Vec::new();
+ let mut row_ids = vec![Vec::new(); vector_searches.len()];
+ let mut scores = vec![Vec::new(); vector_searches.len()];
while let Some(batch) = stream.try_next().await? {
- collect_raw_vector_batch(&batch, vector_search, metric, &mut row_ids,
&mut scores)?;
+ collect_raw_batch_vector_batch(&batch, vector_searches, metric, &mut
row_ids, &mut scores)?;
}
- Ok(SearchResult::new(row_ids, scores).top_k(vector_search.limit))
+ Ok(row_ids
+ .into_iter()
+ .zip(scores)
+ .zip(vector_searches)
+ .map(|((row_ids, scores), vector_search)| {
+ SearchResult::new(row_ids, scores).top_k(vector_search.limit)
+ })
+ .collect())
}
-fn collect_raw_vector_batch(
+fn collect_raw_batch_vector_batch(
batch: &RecordBatch,
- vector_search: &VectorSearch,
+ vector_searches: &[VectorSearch],
metric: RawVectorMetric,
- row_ids_out: &mut Vec<u64>,
- scores_out: &mut Vec<f32>,
+ row_ids_out: &mut [Vec<u64>],
+ scores_out: &mut [Vec<f32>],
) -> crate::Result<()> {
- let vector_index = batch
- .schema()
- .index_of(&vector_search.field_name)
- .map_err(|e| crate::Error::DataInvalid {
- message: format!(
- "Vector column '{}' not found in raw search batch: {}",
- vector_search.field_name, e
- ),
+ if vector_searches.is_empty() {
+ return Ok(());
+ }
+ if row_ids_out.len() != vector_searches.len() || scores_out.len() !=
vector_searches.len() {
+ return Err(crate::Error::DataInvalid {
+ message: "Raw batch vector search output buffers must match query
vector count"
+ .to_string(),
source: None,
- })?;
+ });
+ }
+
+ let field_name = &vector_searches[0].field_name;
+ if vector_searches
+ .iter()
+ .any(|vector_search| vector_search.field_name != *field_name)
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Batch vector raw search requires all query vectors to
use the same field"
+ .to_string(),
+ source: None,
+ });
+ }
+
+ let vector_index =
+ batch
+ .schema()
+ .index_of(field_name)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!(
+ "Vector column '{}' not found in raw search batch: {}",
+ field_name, e
+ ),
+ source: None,
+ })?;
let row_id_index =
batch
.schema()
@@ -558,14 +733,6 @@ fn collect_raw_vector_batch(
});
}
let row_id = row_id_to_u64(row_ids.value(row))?;
- if vector_search
- .include_row_ids
- .as_ref()
- .is_some_and(|include_row_ids| !include_row_ids.contains(row_id))
- {
- continue;
- }
-
let is_null = match layout {
VectorLayout::List(a) => a.is_null(row),
VectorLayout::Fixed(a) => a.is_null(row),
@@ -584,18 +751,8 @@ fn collect_raw_vector_batch(
(row * len, (row + 1) * len)
}
};
- if end - start != vector_search.vector.len() {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "Query vector dimension mismatch: raw row has {}, but
query has {}",
- end - start,
- vector_search.vector.len()
- ),
- source: None,
- });
- }
- let mut stored = Vec::with_capacity(vector_search.vector.len());
+ let mut stored = Vec::with_capacity(end - start);
for value_index in start..end {
if values.is_null(value_index) {
return Err(crate::Error::DataInvalid {
@@ -605,12 +762,32 @@ fn collect_raw_vector_batch(
}
stored.push(values.value(value_index));
}
- row_ids_out.push(row_id);
- scores_out.push(compute_raw_vector_score(
- &vector_search.vector,
- &stored,
- metric,
- ));
+
+ for (query_index, vector_search) in vector_searches.iter().enumerate()
{
+ if vector_search
+ .include_row_ids
+ .as_ref()
+ .is_some_and(|include_row_ids|
!include_row_ids.contains(row_id))
+ {
+ continue;
+ }
+ if stored.len() != vector_search.vector.len() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Query vector dimension mismatch: raw row has {}, but
query has {}",
+ stored.len(),
+ vector_search.vector.len()
+ ),
+ source: None,
+ });
+ }
+ row_ids_out[query_index].push(row_id);
+ scores_out[query_index].push(compute_raw_vector_score(
+ &vector_search.vector,
+ &stored,
+ metric,
+ ));
+ }
}
Ok(())
@@ -659,14 +836,41 @@ fn compute_raw_vector_score(query: &[f32], stored:
&[f32], metric: RawVectorMetr
#[cfg(test)]
mod tests {
use super::*;
+ use crate::catalog::Identifier;
+ use crate::io::FileIOBuilder;
use crate::lumina::{LEGACY_LUMINA_VECTOR_ANN_IDENTIFIER,
LUMINA_IDENTIFIER};
- use crate::spec::{DataType, GlobalIndexMeta, IndexFileMeta,
IndexManifestEntry, IntType};
+ use crate::spec::{
+ ArrayType, DataType, FloatType, GlobalIndexMeta, IndexFileMeta,
IndexManifestEntry,
+ IntType, Schema, TableSchema,
+ };
use crate::vindex::IVF_FLAT_IDENTIFIER;
+ use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
+ use arrow_array::ArrayRef;
+ use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
+ use std::sync::Arc;
fn make_field(id: i32, name: &str) -> DataField {
DataField::new(id, name.to_string(), DataType::Int(IntType::default()))
}
+ fn vector_test_table() -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .build()
+ .unwrap();
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "vector_test"),
+ "memory:/vector_test".to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+ }
+
fn eval_context<'a>(
file_io: &'a FileIO,
options: &'a HashMap<String, String>,
@@ -728,6 +932,126 @@ mod tests {
);
}
+ #[test]
+ fn test_collect_raw_batch_vector_batch_preserves_query_order() {
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut builder =
+ FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(element_field);
+ for vector in [[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]] {
+ builder.values().append_value(vector[0]);
+ builder.values().append_value(vector[1]);
+ builder.append(true);
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new(
+ "embedding",
+ ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true)),
+ 2,
+ ),
+ true,
+ ),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(builder.finish()) as ArrayRef,
+ Arc::new(Int64Array::from(vec![Some(10), Some(11), Some(12)]))
as ArrayRef,
+ ],
+ )
+ .unwrap();
+ let searches = vec![
+ VectorSearch::new(vec![1.0, 0.0], 1,
"embedding".to_string()).unwrap(),
+ VectorSearch::new(vec![0.0, 1.0], 1,
"embedding".to_string()).unwrap(),
+ ];
+ let mut row_ids = vec![Vec::new(); searches.len()];
+ let mut scores = vec![Vec::new(); searches.len()];
+
+ collect_raw_batch_vector_batch(
+ &batch,
+ &searches,
+ RawVectorMetric::L2,
+ &mut row_ids,
+ &mut scores,
+ )
+ .unwrap();
+ let results = row_ids
+ .into_iter()
+ .zip(scores)
+ .map(|(row_ids, scores)| SearchResult::new(row_ids,
scores).top_k(1))
+ .collect::<Vec<_>>();
+
+ assert_eq!(results[0].row_ids, vec![10]);
+ assert_eq!(results[1].row_ids, vec![11]);
+ }
+
+ #[tokio::test]
+ async fn test_batch_vector_search_requires_vectors() {
+ let table = vector_test_table();
+ let err = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(Vec::new())
+ .with_limit(1)
+ .execute()
+ .await
+ .unwrap_err();
+
+ assert!(
+ err.to_string()
+ .contains("Query vectors must be set via
with_query_vectors()"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_batch_vector_search_rejects_zero_limit() {
+ let table = vector_test_table();
+ let err = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0]])
+ .with_limit(0)
+ .execute()
+ .await
+ .unwrap_err();
+
+ assert!(
+ err.to_string().contains("Limit must be between 1"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_batch_evaluate_no_matching_field_returns_empty_per_query() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let fields = vec![make_field(1, "id")];
+ let searches = vec![
+ VectorSearch::new(vec![1.0], 10, "embedding".to_string()).unwrap(),
+ VectorSearch::new(vec![0.0], 10, "embedding".to_string()).unwrap(),
+ ];
+ let options = HashMap::new();
+
+ let entry = make_lumina_entry(
+ "test.idx",
+ LEGACY_LUMINA_VECTOR_ANN_IDENTIFIER,
+ FileKind::Add,
+ 99,
+ );
+
+ let results = evaluate_batch_vector_search(
+ eval_context(&file_io, &options, &fields, None),
+ &[entry],
+ &searches,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(results.len(), searches.len());
+ assert!(results.iter().all(SearchResult::is_empty));
+ }
+
#[tokio::test]
async fn test_evaluate_no_matching_entries() {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
diff --git a/crates/paimon/src/vindex/reader.rs
b/crates/paimon/src/vindex/reader.rs
index 18a1fa7..c4d6abe 100644
--- a/crates/paimon/src/vindex/reader.rs
+++ b/crates/paimon/src/vindex/reader.rs
@@ -55,6 +55,18 @@ impl VindexVectorGlobalIndexReader {
self.search(vector_search)
}
+ pub fn visit_batch_vector_search<S: Read + Seek + Send + 'static>(
+ &mut self,
+ vector_searches: &[VectorSearch],
+ stream_fn: impl FnOnce(&str) -> crate::Result<S>,
+ ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+ self.ensure_loaded(stream_fn)?;
+ vector_searches
+ .iter()
+ .map(|vector_search| self.search(vector_search))
+ .collect()
+ }
+
fn search(&mut self, vector_search: &VectorSearch) ->
crate::Result<Option<HashMap<u64, f32>>> {
let reader = self
.reader