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 a876c3a [table] Accept VECTOR<FLOAT> in Lumina index build (#440)
a876c3a is described below
commit a876c3a4e11c27e60b62bca0819a7a23be55de34
Author: Junrui Lee <[email protected]>
AuthorDate: Fri Jul 3 11:23:51 2026 +0800
[table] Accept VECTOR<FLOAT> in Lumina index build (#440)
---
crates/paimon/src/lumina/mod.rs | 3 +
.../paimon/src/table/lumina_index_build_builder.rs | 360 +++++++++++++++++++--
2 files changed, 338 insertions(+), 25 deletions(-)
diff --git a/crates/paimon/src/lumina/mod.rs b/crates/paimon/src/lumina/mod.rs
index 69fb3d2..4cc3a90 100644
--- a/crates/paimon/src/lumina/mod.rs
+++ b/crates/paimon/src/lumina/mod.rs
@@ -227,6 +227,9 @@ pub const KEY_DIMENSION: &str = "index.dimension";
pub const KEY_DISTANCE_METRIC: &str = "distance.metric";
pub const KEY_INDEX_TYPE: &str = "index.type";
+/// Paimon-prefixed option key for the vector index dimension.
+pub const LUMINA_DIMENSION_OPTION: &str = "lumina.index.dimension";
+
pub struct LuminaIndexMeta {
options: HashMap<String, String>,
}
diff --git a/crates/paimon/src/table/lumina_index_build_builder.rs
b/crates/paimon/src/table/lumina_index_build_builder.rs
index 04a43cb..ea5c126 100644
--- a/crates/paimon/src/table/lumina_index_build_builder.rs
+++ b/crates/paimon/src/table/lumina_index_build_builder.rs
@@ -27,7 +27,7 @@ use crate::table::{
CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table,
TableCommit,
};
use crate::{Error, Result};
-use arrow_array::{Array, Float32Array, Int64Array, ListArray, RecordBatch};
+use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array,
ListArray, RecordBatch};
use bytes::Bytes;
use futures::TryStreamExt;
use std::collections::HashMap;
@@ -94,6 +94,7 @@ impl<'a> LuminaIndexBuildBuilder<'a> {
let resolved_options =
resolve_lumina_options(self.table.schema().options(),
&self.options)?;
+ let resolved_options = effective_lumina_options(index_field,
resolved_options)?;
let lumina_options = LuminaVectorIndexOptions::new(&resolved_options)?;
let dimension = lumina_options.dimension;
let index_meta =
LuminaIndexMeta::new(lumina_options.to_lumina_options()).serialize()?;
@@ -289,13 +290,18 @@ fn find_index_field<'a>(table: &'a Table, column: &str)
-> Result<&'a DataField>
}
fn validate_vector_field(field: &DataField) -> Result<()> {
- if !matches!(
+ let is_array_float = matches!(
field.data_type(),
DataType::Array(array) if matches!(array.element_type(),
DataType::Float(_))
- ) {
+ );
+ let is_vector_float = matches!(
+ field.data_type(),
+ DataType::Vector(vector) if matches!(vector.element_type(),
DataType::Float(_))
+ );
+ if !is_array_float && !is_vector_float {
return Err(Error::DataInvalid {
message: format!(
- "Lumina index requires ARRAY<FLOAT> column, got {:?} for
column '{}'",
+ "Lumina index requires ARRAY<FLOAT> or VECTOR<FLOAT> column,
got {:?} for column '{}'",
field.data_type(),
field.name()
),
@@ -305,6 +311,41 @@ fn validate_vector_field(field: &DataField) -> Result<()> {
Ok(())
}
+/// For a `VECTOR<FLOAT, N>` column, ensure the effective Lumina options carry
+/// `lumina.index.dimension = N`, so the native options and serialized index
+/// metadata match the column type. Absent → inject N; present-and-equal →
keep;
+/// present-and-different → ConfigInvalid. Non-vector columns (e.g.
ARRAY<FLOAT>)
+/// are returned unchanged so the existing option/default behavior is
preserved.
+fn effective_lumina_options(
+ field: &DataField,
+ mut resolved: HashMap<String, String>,
+) -> Result<HashMap<String, String>> {
+ let DataType::Vector(vector) = field.data_type() else {
+ return Ok(resolved);
+ };
+ let n = vector.length().to_string();
+ match resolved.get(crate::lumina::LUMINA_DIMENSION_OPTION) {
+ None => {
+
resolved.insert(crate::lumina::LUMINA_DIMENSION_OPTION.to_string(), n);
+ }
+ Some(existing) if *existing == n => {}
+ Some(existing) => {
+ return Err(Error::ConfigInvalid {
+ message: format!(
+ "Vector column '{}' has dimension {} from its type, but
'{}' is set to '{}'. \
+ Remove the option or set it to {}.",
+ field.name(),
+ n,
+ crate::lumina::LUMINA_DIMENSION_OPTION,
+ existing,
+ n
+ ),
+ });
+ }
+ }
+ Ok(resolved)
+}
+
fn resolve_lumina_options(
table_options: &HashMap<String, String>,
user_options: &HashMap<String, String>,
@@ -605,22 +646,34 @@ fn extract_vectors_from_batches(
message: format!("_ROW_ID column not found in read batch:
{e}"),
source: None,
})?;
- let vectors_array = batch
- .column(vector_index)
- .as_any()
- .downcast_ref::<ListArray>()
- .ok_or_else(|| Error::DataInvalid {
- message: "Lumina vector extraction requires Arrow
List<Float32>".to_string(),
- source: None,
- })?;
- let values = vectors_array
- .values()
- .as_any()
- .downcast_ref::<Float32Array>()
- .ok_or_else(|| Error::DataInvalid {
- message: "Lumina vector extraction requires Arrow
List<Float32>".to_string(),
+ // Resolve the vector column as either List<Float32> (ARRAY<FLOAT>) or
+ // FixedSizeList<Float32> (VECTOR<FLOAT>). Both yield a Float32Array of
+ // values plus a per-row [start, end) slice.
+ let column = batch.column(vector_index);
+ enum VectorLayout<'a> {
+ List(&'a ListArray),
+ Fixed(&'a FixedSizeListArray),
+ }
+ let layout = if let Some(a) =
column.as_any().downcast_ref::<ListArray>() {
+ VectorLayout::List(a)
+ } else if let Some(a) =
column.as_any().downcast_ref::<FixedSizeListArray>() {
+ VectorLayout::Fixed(a)
+ } else {
+ return Err(Error::DataInvalid {
+ message: "Lumina vector extraction requires Arrow
List<Float32> or FixedSizeList<Float32>".to_string(),
source: None,
- })?;
+ });
+ };
+ let values = match layout {
+ VectorLayout::List(a) => a.values(),
+ VectorLayout::Fixed(a) => a.values(),
+ }
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Lumina vector extraction requires Float32 vector
elements".to_string(),
+ source: None,
+ })?;
let row_ids = batch
.column(row_id_index)
.as_any()
@@ -649,15 +702,26 @@ fn extract_vectors_from_batches(
}
expected_row_id += 1;
- if vectors_array.is_null(row) {
+ let is_null = match layout {
+ VectorLayout::List(a) => a.is_null(row),
+ VectorLayout::Fixed(a) => a.is_null(row),
+ };
+ if is_null {
return Err(Error::DataInvalid {
message: "Lumina vector extraction found null vector
row".to_string(),
source: None,
});
}
- let value_offsets = vectors_array.value_offsets();
- let start = value_offsets[row] as usize;
- let end = value_offsets[row + 1] as usize;
+ let (start, end) = match layout {
+ VectorLayout::List(a) => {
+ let offsets = a.value_offsets();
+ (offsets[row] as usize, offsets[row + 1] as usize)
+ }
+ VectorLayout::Fixed(a) => {
+ let len = a.value_length() as usize;
+ (row * len, (row + 1) * len)
+ }
+ };
if end - start != dimension {
return Err(Error::DataInvalid {
message: format!(
@@ -819,10 +883,13 @@ mod tests {
use crate::catalog::Identifier;
use crate::io::FileIO;
use crate::io::FileIOBuilder;
+ use crate::lumina::LUMINA_DIMENSION_OPTION;
use crate::spec::stats::BinaryTableStats;
- use crate::spec::{ArrayType, FloatType, IntType, ManifestEntry, Schema,
TableSchema};
+ use crate::spec::{
+ ArrayType, DoubleType, FloatType, IntType, ManifestEntry, Schema,
TableSchema, VectorType,
+ };
use crate::table::TableWrite;
- use arrow_array::builder::{Float32Builder, Int64Builder, ListBuilder};
+ use arrow_array::builder::{FixedSizeListBuilder, Float32Builder,
Int64Builder, ListBuilder};
use arrow_array::{ArrayRef, Int32Array};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
use chrono::{DateTime, Utc};
@@ -1039,6 +1106,128 @@ mod tests {
);
}
+ #[test]
+ fn test_validate_vector_field_accepts_array_float() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ );
+ assert!(validate_vector_field(&field).is_ok());
+ }
+
+ #[test]
+ fn test_validate_vector_field_accepts_vector_float() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 4,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ );
+ assert!(validate_vector_field(&field).is_ok());
+ }
+
+ #[test]
+ fn test_validate_vector_field_rejects_vector_double() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 4,
DataType::Double(DoubleType::new())).unwrap(),
+ ),
+ );
+ let err = validate_vector_field(&field).expect_err("VECTOR<DOUBLE>
must be rejected");
+ assert!(matches!(err, Error::DataInvalid { .. }));
+ }
+
+ #[test]
+ fn test_effective_options_vector_absent_inserts_length() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 256,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ );
+ let opts = effective_lumina_options(&field, HashMap::new()).unwrap();
+ assert_eq!(
+ opts.get(LUMINA_DIMENSION_OPTION).map(String::as_str),
+ Some("256")
+ );
+ }
+
+ #[test]
+ fn test_effective_options_vector_matching_option_ok() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 256,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ );
+ let resolved = HashMap::from([(LUMINA_DIMENSION_OPTION.to_string(),
"256".to_string())]);
+ let opts = effective_lumina_options(&field, resolved).unwrap();
+ assert_eq!(
+ opts.get(LUMINA_DIMENSION_OPTION).map(String::as_str),
+ Some("256")
+ );
+ }
+
+ #[test]
+ fn test_effective_options_vector_mismatch_errors() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 256,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ );
+ let resolved = HashMap::from([(LUMINA_DIMENSION_OPTION.to_string(),
"128".to_string())]);
+ let err =
+ effective_lumina_options(&field, resolved).expect_err("dimension
mismatch must error");
+ assert!(matches!(err, Error::ConfigInvalid { .. }));
+ }
+
+ #[test]
+ fn test_effective_options_array_unchanged() {
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ );
+ // No dimension option set: array path must NOT inject one.
+ let opts = effective_lumina_options(&field, HashMap::new()).unwrap();
+ assert!(!opts.contains_key(LUMINA_DIMENSION_OPTION));
+ }
+
+ #[test]
+ fn test_vector_without_option_propagates_dimension_to_native_options() {
+ use crate::lumina::{LuminaVectorIndexOptions, KEY_DIMENSION};
+ let field = DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 256,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ );
+ // No lumina.index.dimension set by the user.
+ let resolved = effective_lumina_options(&field,
HashMap::new()).unwrap();
+ let opts = LuminaVectorIndexOptions::new(&resolved).unwrap();
+
+ assert_eq!(
+ opts.dimension, 256,
+ "local dimension must be N, not default 128"
+ );
+ assert_eq!(
+ opts.to_lumina_options()
+ .get(KEY_DIMENSION)
+ .map(String::as_str),
+ Some("256"),
+ "native index.dimension must be N, not default 128"
+ );
+ }
+
#[tokio::test]
async fn test_execute_rejects_primary_key_table() {
let table = test_table_with_schema(
@@ -1215,6 +1404,127 @@ mod tests {
);
}
+ fn fixed_size_vector_batch(
+ rows: Vec<Option<Vec<f32>>>,
+ row_ids: Vec<Option<i64>>,
+ len: i32,
+ ) -> RecordBatch {
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut builder =
+ FixedSizeListBuilder::new(Float32Builder::new(),
len).with_field(element_field);
+ for row in rows {
+ match row {
+ Some(values) => {
+ for v in values {
+ builder.values().append_value(v);
+ }
+ builder.append(true);
+ }
+ None => {
+ for _ in 0..len {
+ builder.values().append_value(0.0);
+ }
+ builder.append(false);
+ }
+ }
+ }
+ let mut row_id_builder = Int64Builder::new();
+ for row_id in row_ids {
+ match row_id {
+ Some(value) => row_id_builder.append_value(value),
+ None => row_id_builder.append_null(),
+ }
+ }
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new(
+ "embedding",
+ ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true)),
+ len,
+ ),
+ true,
+ ),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(builder.finish()) as ArrayRef,
+ Arc::new(row_id_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn test_extract_vectors_accepts_fixed_size_list_float32() {
+ let batch = fixed_size_vector_batch(
+ vec![Some(vec![1.0, 2.0]), Some(vec![3.0, 4.0])],
+ vec![Some(10), Some(11)],
+ 2,
+ );
+ let vectors = extract_vectors_from_batches(&[batch], "embedding", 2,
10, 2).unwrap();
+ assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
+ }
+
+ #[test]
+ fn test_extract_vectors_fixed_size_list_rejects_null_vector() {
+ let batch = fixed_size_vector_batch(vec![None], vec![Some(0)], 2);
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("null vector should fail");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("null vector"))
+ );
+ }
+
+ #[test]
+ fn test_extract_vectors_fixed_size_list_dimension_mismatch() {
+ // Column is FixedSizeList of length 3, but caller expects dimension 2.
+ let batch = fixed_size_vector_batch(vec![Some(vec![1.0, 2.0, 3.0])],
vec![Some(0)], 3);
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("dimension mismatch should fail");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("dimension mismatch"))
+ );
+ }
+
+ #[test]
+ fn test_extract_vectors_fixed_size_list_rejects_null_element() {
+ // A non-null vector row whose second child element is null. Mirrors
the
+ // List path's test_extract_vectors_rejects_null_element so both
layouts
+ // reject null elements identically.
+ let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut builder =
+ FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(element_field);
+ builder.values().append_value(1.0);
+ builder.values().append_null();
+ builder.append(true);
+ let row_ids = Arc::new(Int64Array::from(vec![Some(0)])) as ArrayRef;
+ 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, row_ids],
+ )
+ .unwrap();
+
+ let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
+ .expect_err("null element should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("null vector element"))
+ );
+ }
+
#[test]
fn test_checked_metadata_conversion_rejects_large_file_size() {
let err = checked_i32(i32::MAX as u64 + 1, "Index file is too large")