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 5e4d3784 Support sparse vindex builds with Java-compatible training
semantics (#1093)
5e4d3784 is described below
commit 5e4d3784f9f5e9270362b90ffa37dededf344d35
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 17:41:27 2026 +0800
Support sparse vindex builds with Java-compatible training semantics (#1093)
---
crates/paimon/src/table/pk_vector_search_params.rs | 18 +-
.../src/table/pk_vector_search_params/tests.rs | 34 ++
.../paimon/src/table/vindex_index_build_builder.rs | 21 +-
.../table/vindex_index_build_builder/extraction.rs | 200 +++++--
.../table/vindex_index_build_builder/pipeline.rs | 142 ++---
.../vindex_index_build_builder/sparse_tests.rs | 534 +++++++++++++++++++
.../src/table/vindex_index_build_builder/tests.rs | 5 +-
.../src/table/vindex_index_build_builder/writer.rs | 404 +++++++-------
crates/paimon/src/vindex/mod.rs | 578 ++++++++++-----------
docs/src/sql.md | 19 +-
10 files changed, 1338 insertions(+), 617 deletions(-)
diff --git a/crates/paimon/src/table/pk_vector_search_params.rs
b/crates/paimon/src/table/pk_vector_search_params.rs
index 954c0285..fbd751db 100644
--- a/crates/paimon/src/table/pk_vector_search_params.rs
+++ b/crates/paimon/src/table/pk_vector_search_params.rs
@@ -193,15 +193,31 @@ fn pk_vector_query_dimension(
for key in [
"dimension".to_string(),
format!("{index_type}.dimension"),
+ format!("{index_type}.index.dimension"),
format!("fields.{}.dimension", vector_field.name()),
+ format!("fields.{}.index.dimension", vector_field.name()),
] {
if let Some(value) = query_options.get(&key) {
+ let key = if key == "dimension" {
+ format!("{index_type}.dimension")
+ } else {
+ key
+ };
dimension_options.insert(key, value.clone());
}
}
+ let mut table_dimension_options = table_options.clone();
+ if let Some(value) = table_options.get("dimension") {
+ if
!table_options.contains_key(&format!("{index_type}.dimension"))
+ &&
!table_options.contains_key(&format!("{index_type}.index.dimension"))
+ {
+ table_dimension_options
+ .insert(format!("{index_type}.dimension"),
value.clone());
+ }
+ }
Ok(Some(
VindexVectorIndexOptions::new(
- table_options,
+ &table_dimension_options,
&dimension_options,
index_type,
vector_field,
diff --git a/crates/paimon/src/table/pk_vector_search_params/tests.rs
b/crates/paimon/src/table/pk_vector_search_params/tests.rs
index 061c09d4..f01a4274 100644
--- a/crates/paimon/src/table/pk_vector_search_params/tests.rs
+++ b/crates/paimon/src/table/pk_vector_search_params/tests.rs
@@ -39,3 +39,37 @@ fn vindex_array_dimension_accepts_diskann_search_options() {
Some(8)
);
}
+
+#[test]
+fn raw_query_dimensions_remain_supported_with_java_build_option_mapping() {
+ let field = DataField::new(
+ 1,
+ "embedding".into(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ );
+ for index_type in ["ivf-flat", "ivf-pq", "diskann"] {
+ let table = HashMap::from([("dimension".into(), "8".into())]);
+ assert_eq!(
+ pk_vector_query_dimension(&table, &HashMap::new(), index_type,
&field).unwrap(),
+ Some(8)
+ );
+ let query = HashMap::from([("dimension".into(), "2".into())]);
+ assert_eq!(
+ pk_vector_query_dimension(&table, &query, index_type,
&field).unwrap(),
+ Some(2)
+ );
+ let table = HashMap::from([
+ ("dimension".into(), "8".into()),
+ (format!("{index_type}.index.dimension"), "16".into()),
+ ("fields.embedding.index.dimension".into(), "32".into()),
+ ]);
+ assert_eq!(
+ pk_vector_query_dimension(&table, &HashMap::new(), index_type,
&field).unwrap(),
+ Some(32)
+ );
+ assert_eq!(
+ pk_vector_query_dimension(&table, &query, index_type,
&field).unwrap(),
+ Some(2)
+ );
+ }
+}
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index ca049b19..10a4637e 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -92,15 +92,27 @@ impl<'a> VindexIndexBuildBuilder<'a> {
let core_options = CoreOptions::new(&merged_options);
validate_table_options(self.table, &core_options)?;
let rows_per_shard = core_options.global_index_row_count_per_shard()?;
+ if let Some(value) =
merged_options.get("global-index.build.parallelism") {
+ let parallelism = value
+ .trim()
+ .parse::<usize>()
+ .map_err(|_| Error::ConfigInvalid {
+ message: "Option 'global-index.build.parallelism' must be
greater than 0."
+ .into(),
+ })?;
+ if parallelism == 0 {
+ return Err(Error::ConfigInvalid {
+ message: "Option 'global-index.build.parallelism' must be
greater than 0."
+ .into(),
+ });
+ }
+ }
let index_field = find_index_field(self.table, index_column)?;
validate_vector_field(index_field)?;
- // The builder consumes shard sizing; it is not a native writer option.
- let mut writer_options = self.options.clone();
- writer_options.remove("global-index.row-count-per-shard");
let vindex_options = VindexVectorIndexOptions::new(
self.table.schema().options(),
- &writer_options,
+ &self.options,
&self.index_type,
index_field,
)?;
@@ -194,6 +206,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
return Err(error);
}
};
+ let Some(built) = built else { continue };
let mut message =
CommitMessage::new(shard.partition_bytes.clone(), 0, vec![]);
message.new_index_files = vec![built.meta];
messages.push(message);
diff --git a/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
b/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
index 37a8bb51..b8aabaf7 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
@@ -21,6 +21,7 @@ use crate::spec::ROW_ID_FIELD_NAME;
use crate::table::{DataSplit, DataSplitBuilder, RowRange};
use crate::{Error, Result};
use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array,
ListArray, RecordBatch};
+use arrow_buffer::ScalarBuffer;
pub(super) fn data_split_for_shard(shard: &VindexIndexShard) ->
Result<DataSplit> {
data_split_for_shard_ranges(
@@ -44,27 +45,40 @@ pub(super) fn data_split_for_shard_ranges(
.build()
}
-pub(super) struct ValidatedVectorBatch<'a> {
- pub(super) values: &'a [f32],
- pub(super) bytes: &'a [u8],
- pub(super) row_ids: &'a [i64],
- pub(super) row_count: usize,
+pub(super) struct ValidatedVectorBatch {
+ pub(super) values: ScalarBuffer<f32>,
+ pub(super) row_ids: ScalarBuffer<i64>,
+ /// Number of non-null vectors, not the number of source rows.
+ pub(super) vector_count: usize,
+ pub(super) source_rows: usize,
}
-pub(super) fn extract_vector_batch<'a>(
- batch: &'a RecordBatch,
+impl ValidatedVectorBatch {
+ pub(super) fn bytes(&self) -> &[u8] {
+ self.values.inner().as_slice()
+ }
+}
+
+pub(super) fn contains_null_vectors(batch: &RecordBatch, index_column: &str)
-> bool {
+ batch
+ .column_by_name(index_column)
+ .is_some_and(|column| column.null_count() != 0)
+}
+
+pub(super) fn extract_vector_batch(
+ batch: &RecordBatch,
index_column: &str,
dimension: usize,
-) -> Result<ValidatedVectorBatch<'a>> {
+) -> Result<ValidatedVectorBatch> {
validate_vector_batch_with(batch, index_column, dimension, |_| Ok(()))
}
-pub(super) fn validate_vector_batch<'a>(
- batch: &'a RecordBatch,
+pub(super) fn validate_vector_batch(
+ batch: &RecordBatch,
index_column: &str,
dimension: usize,
expected_row_id: &mut i64,
-) -> Result<ValidatedVectorBatch<'a>> {
+) -> Result<ValidatedVectorBatch> {
validate_vector_batch_with(batch, index_column, dimension, |row_id| {
if row_id != *expected_row_id {
return Err(Error::DataInvalid {
@@ -85,14 +99,14 @@ pub(super) fn validate_vector_batch<'a>(
})
}
-pub(super) fn validate_vector_batch_ranges<'a>(
- batch: &'a RecordBatch,
+pub(super) fn validate_vector_batch_ranges(
+ batch: &RecordBatch,
index_column: &str,
dimension: usize,
ranges: &[RowRange],
range_index: &mut usize,
expected_row_id: &mut i64,
-) -> Result<ValidatedVectorBatch<'a>> {
+) -> Result<ValidatedVectorBatch> {
validate_vector_batch_with(batch, index_column, dimension, |row_id| {
let range = ranges.get(*range_index).ok_or_else(|| Error::DataInvalid {
message: format!("vindex vector extraction got unexpected _ROW_ID
{row_id}"),
@@ -126,12 +140,12 @@ pub(super) fn validate_vector_batch_ranges<'a>(
})
}
-fn validate_vector_batch_with<'a>(
- batch: &'a RecordBatch,
+fn validate_vector_batch_with(
+ batch: &RecordBatch,
index_column: &str,
dimension: usize,
mut validate_row_id: impl FnMut(i64) -> Result<()>,
-) -> Result<ValidatedVectorBatch<'a>> {
+) -> Result<ValidatedVectorBatch> {
let vector_index = batch
.schema()
.index_of(index_column)
@@ -147,14 +161,30 @@ fn validate_vector_batch_with<'a>(
message: format!("_ROW_ID column not found in read batch:
{e}"),
source: None,
})?;
+ let row_ids = batch
+ .column(row_id_index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex vector extraction requires non-null Int64
_ROW_ID".to_string(),
+ source: None,
+ })?;
+ if row_ids.null_count() != 0 {
+ return Err(Error::DataInvalid {
+ message: "vindex vector extraction found null _ROW_ID".to_string(),
+ source: None,
+ });
+ }
+ for row_id in row_ids.values() {
+ validate_row_id(*row_id)?;
+ }
+
let column = batch.column(vector_index);
+ if column.null_count() != 0 {
+ return compact_nullable_vectors(column.as_ref(), row_ids, dimension,
batch.num_rows());
+ }
+
let (values, start, end) = if let Some(array) =
column.as_any().downcast_ref::<ListArray>() {
- if array.null_count() != 0 {
- return Err(Error::DataInvalid {
- message: "vindex vector extraction found null vector
row".to_string(),
- source: None,
- });
- }
let offsets = array.value_offsets();
for offsets in offsets.windows(2) {
let actual = offsets[1] - offsets[0];
@@ -192,12 +222,6 @@ fn validate_vector_batch_with<'a>(
source: None,
});
}
- if array.null_count() != 0 {
- return Err(Error::DataInvalid {
- message: "vindex vector extraction found null vector
row".to_string(),
- source: None,
- });
- }
let end = batch
.num_rows()
.checked_mul(dimension)
@@ -231,30 +255,112 @@ fn validate_vector_batch_with<'a>(
source: None,
});
}
- let row_ids = batch
- .column(row_id_index)
- .as_any()
- .downcast_ref::<Int64Array>()
- .ok_or_else(|| Error::DataInvalid {
- message: "vindex vector extraction requires non-null Int64
_ROW_ID".to_string(),
+ checked_vector_bytes(end - start, 1)?;
+ Ok(ValidatedVectorBatch {
+ values: values.values().slice(start, end - start),
+ row_ids: row_ids.values().clone(),
+ vector_count: batch.num_rows(),
+ source_rows: batch.num_rows(),
+ })
+}
+
+fn compact_nullable_vectors(
+ column: &dyn Array,
+ row_ids: &Int64Array,
+ dimension: usize,
+ source_rows: usize,
+) -> Result<ValidatedVectorBatch> {
+ let list = column.as_any().downcast_ref::<ListArray>();
+ let fixed = column.as_any().downcast_ref::<FixedSizeListArray>();
+ if list.is_none() && fixed.is_none() {
+ return Err(Error::DataInvalid {
+ message:
+ "vindex vector extraction requires Arrow List<Float32> or
FixedSizeList<Float32>"
+ .into(),
source: None,
- })?;
- if row_ids.null_count() != 0 {
+ });
+ }
+ if let Some(fixed) = fixed {
+ if fixed.value_length() as usize != dimension {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex vector dimension mismatch: expected {dimension},
got {}",
+ fixed.value_length()
+ ),
+ source: None,
+ });
+ }
+ }
+ let child = list
+ .map(|list| list.values())
+ .or_else(|| fixed.map(|list| list.values()))
+ .unwrap();
+ if !child.as_any().is::<Float32Array>() {
return Err(Error::DataInvalid {
- message: "vindex vector extraction found null _ROW_ID".to_string(),
+ message: "vindex vector extraction requires Float32 vector
elements".into(),
source: None,
});
}
- for row_id in row_ids.values() {
- validate_row_id(*row_id)?;
+ let count = source_rows - column.null_count();
+ checked_vector_bytes(count, dimension)?;
+ let mut values = Vec::with_capacity(count * dimension);
+ let mut ids = Vec::with_capacity(count);
+ for row in 0..source_rows {
+ if column.is_null(row) {
+ continue;
+ }
+ let vector = match list {
+ Some(list) => list.value(row),
+ None => fixed.unwrap().value(row),
+ };
+ if vector.len() != dimension {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex vector dimension mismatch: expected {dimension},
got {}",
+ vector.len()
+ ),
+ source: None,
+ });
+ }
+ let vector = vector.as_any().downcast_ref::<Float32Array>().unwrap();
+ if vector.null_count() != 0 {
+ return Err(Error::DataInvalid {
+ message: "vindex vector extraction found null vector
element".into(),
+ source: None,
+ });
+ }
+ values.extend_from_slice(vector.values());
+ ids.push(row_ids.value(row));
}
-
- let byte_start = checked_vector_bytes(start, 1)?;
- let byte_end = checked_vector_bytes(end, 1)?;
Ok(ValidatedVectorBatch {
- values: &values.values()[start..end],
- bytes: &values.values().inner().as_slice()[byte_start..byte_end],
- row_ids: row_ids.values(),
- row_count: batch.num_rows(),
+ values: values.into(),
+ row_ids: ids.into(),
+ vector_count: count,
+ source_rows,
})
}
+
+pub(super) fn local_ids(row_ids: &[i64], start: i64, row_count: usize) ->
Result<Vec<i64>> {
+ let end = start
+ .checked_add(i64::try_from(row_count).map_err(|e| Error::DataInvalid {
+ message: "vindex row count does not fit i64".to_string(),
+ source: Some(Box::new(e)),
+ })?)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex row range overflows i64".to_string(),
+ source: None,
+ })?;
+ row_ids
+ .iter()
+ .map(|row_id| {
+ if *row_id < start || *row_id >= end {
+ Err(Error::DataInvalid {
+ message: format!("vindex row id {row_id} is outside shard
[{start}, {end})"),
+ source: None,
+ })
+ } else {
+ Ok(*row_id - start)
+ }
+ })
+ .collect()
+}
diff --git a/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
b/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
index 26088ca3..5848a9c2 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/pipeline.rs
@@ -16,7 +16,8 @@
// under the License.
use super::extraction::{
- data_split_for_shard_ranges, extract_vector_batch,
validate_vector_batch_ranges,
+ contains_null_vectors, data_split_for_shard_ranges, extract_vector_batch,
local_ids,
+ validate_vector_batch_ranges,
};
use super::planning::VindexIndexShard;
use super::timing::{vector_index_build_timing_enabled, VectorIndexBuildTiming};
@@ -56,6 +57,18 @@ const QUEUE_CAPACITY: usize = 2;
const BUFFER_BYTES: usize = 8 * 1024 * 1024;
const REPLAY_TARGET_BYTES: usize = 32 * 1024 * 1024;
+pub(super) enum GranuleBuildOutcome {
+ Built(Box<BuiltIndexFile>),
+ Sparse,
+}
+
+#[derive(PartialEq)]
+enum SourceLayout {
+ Dense,
+ Sparse,
+}
+
+#[derive(Clone, Debug)]
struct Granule {
range: RowRange,
file_index: usize,
@@ -189,19 +202,19 @@ fn spawn_add_consumer(
match item {
AddItem::Batch(batch, ids) => {
let vectors = extract_vector_batch(&batch, &index_column,
dimension)?;
- if ids.len() != vectors.row_count {
+ if ids.len() != vectors.vector_count {
return Err(Error::DataInvalid {
message: "vindex add batch id count
mismatch".to_string(),
source: None,
});
}
writer
- .add_vectors(&ids, vectors.values, vectors.row_count)
+ .add_vectors(&ids, &vectors.values,
vectors.vector_count)
.map_err(|e| Error::UnexpectedError {
message: format!("Failed to add vectors to vindex
index: {e}"),
source: Some(Box::new(e)),
})?;
- rows_added += vectors.row_count;
+ rows_added += vectors.vector_count;
}
AddItem::Spilled(ids, buffer) => {
let values = buffer.typed_data::<f32>();
@@ -610,31 +623,6 @@ fn granules_partition_shard(granules: &[Granule],
shard_range: &RowRange) -> boo
.all(|pair| pair[0].range.to().checked_add(1) ==
Some(pair[1].range.from()))
}
-fn local_ids(row_ids: &[i64], start: i64, row_count: usize) ->
Result<Vec<i64>> {
- let end = start
- .checked_add(i64::try_from(row_count).map_err(|e| Error::DataInvalid {
- message: "vindex row count does not fit i64".to_string(),
- source: Some(Box::new(e)),
- })?)
- .ok_or_else(|| Error::DataInvalid {
- message: "vindex row range overflows i64".to_string(),
- source: None,
- })?;
- row_ids
- .iter()
- .map(|row_id| {
- if *row_id < start || *row_id >= end {
- Err(Error::DataInvalid {
- message: format!("vindex row id {row_id} is outside shard
[{start}, {end})"),
- source: None,
- })
- } else {
- Ok(*row_id - start)
- }
- })
- .collect()
-}
-
impl<'a> VindexIndexBuildBuilder<'a> {
fn open_vector_stream(
&self,
@@ -786,7 +774,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
index_field_id: i32,
options: &VindexVectorIndexOptions,
index_meta: Vec<u8>,
- ) -> Result<BuiltIndexFile> {
+ ) -> Result<GranuleBuildOutcome> {
let timing_enabled = vector_index_build_timing_enabled();
let total_start = timing_enabled.then(Instant::now);
let mut source_batch_wait = Duration::ZERO;
@@ -816,14 +804,15 @@ impl<'a> VindexIndexBuildBuilder<'a> {
}
checked_vector_bytes(row_count_usize, dimension)?;
let eligible = checked_training_vector_count(row_count_usize,
options.train_sample_ratio)?;
+ let training_config = options.training_config(row_count_usize)?;
let retained =
- default_training_vector_count(eligible,
options.config.nlist()).unwrap_or(eligible);
+ default_training_vector_count(eligible,
training_config.nlist()).unwrap_or(eligible);
let plan = self
.plan_granules(shard, index_column, eligible, retained)
.await?;
let mut trainer =
- VectorIndexTrainer::new(options.config.clone()).map_err(|e|
Error::DataInvalid {
+ VectorIndexTrainer::new(training_config).map_err(|e|
Error::DataInvalid {
message: format!("Failed to initialize vindex trainer: {e}"),
source: Some(Box::new(e)),
})?;
@@ -835,7 +824,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
let mut next_training_sample = 0usize;
let mut first_rows = 0usize;
let mut range_index = 0usize;
- let first_result: Result<()> = async {
+ let first_result: Result<SourceLayout> = async {
let mut stream = self.open_vector_stream(
shard,
plan.first.clone(),
@@ -852,6 +841,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
}
let Some(batch) = batch else { break };
batch_count += 1;
+ if contains_null_vectors(&batch, index_column) { return
Ok(SourceLayout::Sparse); }
let vectors = validate_vector_batch_ranges(
&batch,
index_column,
@@ -860,7 +850,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
&mut range_index,
&mut expected_row_id,
)?;
- let batch_end =
first_rows.checked_add(vectors.row_count).ok_or_else(|| {
+ let batch_end =
first_rows.checked_add(vectors.vector_count).ok_or_else(|| {
Error::DataInvalid {
message: "vindex first-batch row count overflows
usize".to_string(),
source: None,
@@ -871,7 +861,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
let sample = training[next_training_sample];
match vectors.row_ids.binary_search(&sample) {
Ok(row) => row,
- Err(row) if row == vectors.row_count => break,
+ Err(row) if row == vectors.vector_count => break,
Err(_) => return Err(Error::DataInvalid {
message: format!("Missing vindex training row
{sample}"),
source: None,
@@ -905,8 +895,8 @@ impl<'a> VindexIndexBuildBuilder<'a> {
}
}
let record = SpillRecord {
- ids: local_ids(vectors.row_ids, shard.row_range_start,
row_count_usize)?,
- bytes: vectors.bytes.to_vec(),
+ ids: local_ids(&vectors.row_ids, shard.row_range_start,
row_count_usize)?,
+ bytes: vectors.bytes().to_vec(),
};
first_rows = batch_end;
if spill.as_ref().unwrap().sender.send(record).await.is_err() {
@@ -937,14 +927,22 @@ impl<'a> VindexIndexBuildBuilder<'a> {
source: None,
});
}
- Ok(())
+ Ok(SourceLayout::Dense)
}
.await;
- if let Err(error) = first_result {
- return match spill.take().unwrap().finish().await {
- Ok(_) => Err(error),
- Err(spill_error) => Err(spill_error),
- };
+ let first_layout = match first_result {
+ Ok(layout) => layout,
+ Err(error) => {
+ return match spill.take().unwrap().finish().await {
+ Ok(_) => Err(error),
+ Err(spill_error) => Err(spill_error),
+ };
+ }
+ };
+
+ if first_layout == SourceLayout::Sparse {
+ spill.take().unwrap().finish().await?;
+ return Ok(GranuleBuildOutcome::Sparse);
}
let mut training: Option<TrainingTask> =
Some(tokio::task::spawn_blocking(
@@ -977,7 +975,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
}
let mut rest_rows = 0usize;
- let producer_result: Result<()> = async {
+ let producer_result: Result<SourceLayout> = async {
if !plan.rest.is_empty() {
let mut stream = self.open_vector_stream(
shard,
@@ -999,6 +997,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
if live.is_none() && training.as_ref().is_some_and(|task|
task.is_finished()) {
go_live!();
}
+ if contains_null_vectors(&batch, &index_column) { return
Ok(SourceLayout::Sparse); }
let vectors = validate_vector_batch_ranges(
&batch,
&index_column,
@@ -1007,8 +1006,8 @@ impl<'a> VindexIndexBuildBuilder<'a> {
&mut range_index,
&mut expected_row_id,
)?;
- let ids = local_ids(vectors.row_ids,
shard.row_range_start, row_count_usize)?;
- rest_rows =
rest_rows.checked_add(vectors.row_count).ok_or_else(|| {
+ let ids = local_ids(&vectors.row_ids,
shard.row_range_start, row_count_usize)?;
+ rest_rows =
rest_rows.checked_add(vectors.vector_count).ok_or_else(|| {
Error::DataInvalid {
message: "vindex remaining row count overflows
usize".to_string(),
source: None,
@@ -1029,7 +1028,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
} else {
let record = SpillRecord {
ids,
- bytes: vectors.bytes.to_vec(),
+ bytes: vectors.bytes().to_vec(),
};
if
spill.as_ref().unwrap().sender.send(record).await.is_err() {
return Err(Error::UnexpectedError {
@@ -1052,28 +1051,46 @@ impl<'a> VindexIndexBuildBuilder<'a> {
});
}
}
- Ok(())
+ Ok(SourceLayout::Dense)
}
.await;
- if let Err(producer_error) = producer_result {
+ let rest_layout = match producer_result {
+ Ok(layout) => layout,
+ Err(producer_error) => {
+ if let Some(pipeline) = live.take() {
+ let (consumer, replay, _, _) =
finish_live_pipeline(pipeline).await;
+ consumer?;
+ replay?;
+ } else {
+ let spill_result = match spill.take() {
+ Some(spill) => spill.finish().await.map(|_| ()),
+ None => Ok(()),
+ };
+ let training_result = match training.take() {
+ Some(training) =>
join_training(training).await.map(|_| ()),
+ None => Ok(()),
+ };
+ spill_result?;
+ training_result?;
+ }
+ return Err(producer_error);
+ }
+ };
+ if rest_layout == SourceLayout::Sparse {
if let Some(pipeline) = live.take() {
let (consumer, replay, _, _) =
finish_live_pipeline(pipeline).await;
consumer?;
replay?;
} else {
- let spill_result = match spill.take() {
- Some(spill) => spill.finish().await.map(|_| ()),
- None => Ok(()),
- };
- let training_result = match training.take() {
- Some(training) => join_training(training).await.map(|_|
()),
- None => Ok(()),
- };
- spill_result?;
- training_result?;
+ if let Some(spill) = spill.take() {
+ spill.finish().await?;
+ }
+ if let Some(training) = training.take() {
+ join_training(training).await?;
+ }
}
- return Err(producer_error);
+ return Ok(GranuleBuildOutcome::Sparse);
}
if live.is_none() {
go_live!();
@@ -1141,7 +1158,10 @@ impl<'a> VindexIndexBuildBuilder<'a> {
data_file_count: shard.files.len(),
file_name: meta.file_name.clone(),
});
- Ok(BuiltIndexFile { meta, timing })
+ Ok(GranuleBuildOutcome::Built(Box::new(BuiltIndexFile {
+ meta,
+ timing,
+ })))
}
}
diff --git a/crates/paimon/src/table/vindex_index_build_builder/sparse_tests.rs
b/crates/paimon/src/table/vindex_index_build_builder/sparse_tests.rs
new file mode 100644
index 00000000..6a8def68
--- /dev/null
+++ b/crates/paimon/src/table/vindex_index_build_builder/sparse_tests.rs
@@ -0,0 +1,534 @@
+// 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.
+
+use super::*;
+use
crate::table::vindex_index_build_builder::extraction::{extract_vector_batch,
local_ids};
+use arrow_array::Array;
+use arrow_buffer::NullBuffer;
+use paimon_vindex_core::index::VectorIndexReader;
+use std::io::Cursor;
+
+fn nullable_batch(rows: Vec<Option<Vec<Option<f32>>>>, start: i64) ->
RecordBatch {
+ let mut vectors = ListBuilder::new(Float32Builder::new());
+ for row in &rows {
+ if let Some(row) = row {
+ for value in row {
+ vectors.values().append_option(*value);
+ }
+ vectors.append(true);
+ } else {
+ vectors.append(false);
+ }
+ }
+ let vectors = vectors.finish();
+ RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("embedding", vectors.data_type().clone(), true),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, false),
+ ])),
+ vec![
+ Arc::new(vectors),
+ Arc::new(Int64Array::from(
+ (start..start + rows.len() as i64).collect::<Vec<_>>(),
+ )),
+ ],
+ )
+ .unwrap()
+}
+
+#[test]
+fn null_rows_are_compacted_without_renumbering_or_accepting_null_ids() {
+ let batch = nullable_batch(
+ vec![
+ None,
+ Some(vec![Some(1.), Some(2.)]),
+ None,
+ Some(vec![Some(3.), Some(4.)]),
+ None,
+ ],
+ 41,
+ )
+ .slice(1, 3);
+ let mut expected = 42;
+ let result = validate_vector_batch(&batch, "embedding", 2, &mut
expected).unwrap();
+ assert_eq!(expected, 45);
+ assert_eq!(result.source_rows, 3);
+ assert_eq!(result.vector_count, 2);
+ assert_eq!(result.values.as_ref(), &[1., 2., 3., 4.]);
+ assert_eq!(result.row_ids.as_ref(), &[42, 44]);
+ assert_eq!(local_ids(&result.row_ids, 40, 10).unwrap(), vec![2, 4]);
+ assert_eq!(result.bytes().len(), 16);
+ let bad = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ batch.schema().field(0).clone(),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ])),
+ vec![
+ batch.column(0).clone(),
+ Arc::new(Int64Array::from(vec![Some(42), None, Some(44)])),
+ ],
+ )
+ .unwrap();
+ assert!(extract_vector_batch(&bad, "embedding", 2).is_err());
+}
+
+#[test]
+fn fixed_size_null_rows_ignore_hidden_child_nulls_and_preserve_slices() {
+ let array = arrow_array::FixedSizeListArray::new(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
+ 2,
+ Arc::new(arrow_array::Float32Array::from(vec![
+ None,
+ None,
+ Some(1.),
+ Some(2.),
+ None,
+ None,
+ Some(3.),
+ Some(4.),
+ None,
+ None,
+ ])),
+ Some(NullBuffer::from(vec![false, true, false, true, false])),
+ );
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("embedding", array.data_type().clone(), true),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, false),
+ ])),
+ vec![
+ Arc::new(array),
+ Arc::new(Int64Array::from(vec![10, 11, 12, 13, 14])),
+ ],
+ )
+ .unwrap()
+ .slice(1, 3);
+ let vectors = extract_vector_batch(&batch, "embedding", 2).unwrap();
+ assert_eq!(vectors.values.as_ref(), &[1., 2., 3., 4.]);
+ assert_eq!(vectors.row_ids.as_ref(), &[11, 13]);
+ assert_eq!(vectors.source_rows, 3);
+ assert!(extract_vector_batch(&batch, "embedding", 3).is_err());
+}
+
+#[test]
+fn null_rows_do_not_hide_invalid_dimensions_elements_or_row_ranges() {
+ for vector in [vec![Some(1.)], vec![Some(1.), None]] {
+ let batch = nullable_batch(vec![None, Some(vector), None], 100);
+ assert!(extract_vector_batch(&batch, "embedding", 2).is_err());
+ }
+ let batch = nullable_batch(vec![None, Some(vec![Some(1.), Some(2.)])],
100);
+ let mut expected = 99;
+ assert!(validate_vector_batch(&batch, "embedding", 2, &mut
expected).is_err());
+ let mut range_index = 0;
+ let mut expected = 100;
+ assert!(validate_vector_batch_ranges(
+ &batch,
+ "embedding",
+ 2,
+ &[RowRange::new(100, 100)],
+ &mut range_index,
+ &mut expected
+ )
+ .is_err());
+}
+
+#[test]
+fn dense_batches_keep_borrowed_arrow_storage() {
+ let batch = nullable_batch(
+ vec![
+ Some(vec![Some(1.), Some(2.)]),
+ Some(vec![Some(3.), Some(4.)]),
+ ],
+ 0,
+ );
+ let values = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<arrow_array::ListArray>()
+ .unwrap()
+ .values()
+ .as_any()
+ .downcast_ref::<arrow_array::Float32Array>()
+ .unwrap();
+ let vectors = extract_vector_batch(&batch, "embedding", 2).unwrap();
+ assert_eq!(vectors.values.as_ptr(), values.values().as_ptr());
+}
+
+async fn append_nullable(table: &Table, rows: Vec<Option<Vec<Option<f32>>>>) {
+ let batch = nullable_batch(rows, 0);
+ let ids: ArrayRef = Arc::new(Int32Array::from(
+ (0..batch.num_rows() as i32).collect::<Vec<_>>(),
+ ));
+ let input = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding", batch.column(0).data_type().clone(),
true),
+ ])),
+ vec![ids, batch.column(0).clone()],
+ )
+ .unwrap();
+ let mut writer = TableWrite::new(table, "sparse".into()).unwrap();
+ writer.write_arrow_batch(&input).await.unwrap();
+ TableCommit::new(table.clone(), "sparse".into())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+}
+
+async fn metadata(
+ table: &Table,
+ file: &IndexFileMeta,
+) -> paimon_vindex_core::index::VectorIndexMetadata {
+ let path = file
+ .external_path
+ .clone()
+ .unwrap_or_else(|| format!("{}/index/{}", table.location(),
file.file_name));
+ let input = table.file_io().new_input(&path).unwrap();
+ VectorIndexReader::open(Cursor::new(input.read().await.unwrap()))
+ .unwrap()
+ .metadata()
+}
+
+#[tokio::test]
+async fn sparse_and_all_null_shards_obey_prepare_and_cardinality_contract() {
+ for kind in ["ivf-flat", "ivf-pq", "ivf-sq", "ivf-rq", "diskann"] {
+ for granule in [false, true] {
+ let table =
vindex_e2e_table(&format!("memory:/sparse_{kind}_{granule}"), "10");
+ setup_dirs(table.file_io(), table.location()).await;
+ append_nullable(
+ &table,
+ vec![
+ None,
+ Some(vec![Some(1.), Some(0.)]),
+ None,
+ Some(vec![Some(0.), Some(1.)]),
+ None,
+ ],
+ )
+ .await;
+ let mut builder = table.new_global_index_build_builder();
+ let mut options = HashMap::from([(format!("{kind}.dimension"),
"2".into())]);
+ if kind != "diskann" {
+ options.insert(format!("{kind}.nlist"), "1".into());
+ options.insert("vindex.build.granule.enabled".into(),
granule.to_string());
+ }
+ builder
+ .with_index_column("embedding")
+ .with_index_type(kind)
+ .with_options(options);
+ let messages = builder.build().await.unwrap();
+ assert_eq!(messages.len(), 1, "{kind}, granule={granule}");
+ let file = &messages[0].new_index_files[0];
+ assert_eq!(file.row_count, 5);
+
assert_eq!(file.global_index_meta.as_ref().unwrap().row_range_start, 0);
+ assert_eq!(metadata(&table, file).await.total_vectors, 2);
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ 1
+ );
+ TableCommit::new(table.clone(), "sparse-index".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1., 1.])
+ .with_limit(10)
+ .execute()
+ .await
+ .unwrap();
+ let mut ids = result.row_ids().unwrap().row_ids.clone();
+ ids.sort_unstable();
+ assert_eq!(ids, vec![1, 3], "{kind}, granule={granule}");
+ assert!(builder.build().await.unwrap().is_empty());
+ append_nullable(&table, vec![None, None, None]).await;
+ let before = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id();
+ assert!(builder.build().await.unwrap().is_empty());
+ assert_eq!(builder.execute().await.unwrap(), 0);
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ before
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn
automatic_ivf_sizing_uses_valid_vectors_and_declared_row_count_includes_nulls()
{
+ let table = vindex_e2e_table("memory:/sparse_auto", "10000");
+ setup_dirs(table.file_io(), table.location()).await;
+ let mut rows = vec![None; 1000];
+ rows[31] = Some(vec![Some(1.), Some(0.)]);
+ rows[901] = Some(vec![Some(0.), Some(1.)]);
+ append_nullable(&table, rows).await;
+ let messages = table
+ .new_global_index_build_builder()
+ .with_index_column("embedding")
+ .with_index_type("ivf-flat")
+ .with_options(HashMap::from([
+ ("ivf-flat.nlist".into(), "auto".into()),
+ ("ivf-flat.train.sample-ratio".into(), "0.5".into()),
+ ]))
+ .build()
+ .await
+ .unwrap();
+ let file = &messages[0].new_index_files[0];
+ assert_eq!(file.row_count, 1000);
+ let meta = metadata(&table, file).await;
+ assert_eq!(meta.total_vectors, 2);
+ assert_eq!(
+ meta.nlist,
+ paimon_vindex_core::autotune::infer_ivf_nlist(2).unwrap()
+ );
+}
+
+#[tokio::test]
+async fn
null_in_rest_granules_discards_private_training_then_builds_sparse_index() {
+ use super::super::pipeline::GranuleBuildOutcome;
+ use super::super::VindexIndexBuildBuilder;
+ use crate::vindex::VindexVectorIndexOptions;
+ use parquet::arrow::AsyncArrowWriter;
+ use parquet::basic::Compression;
+ use parquet::file::properties::WriterProperties;
+
+ const FILES: usize = 1024;
+ const ROWS: usize = 256;
+ let table = vindex_e2e_table("memory:/sparse_rest", "1000000");
+ setup_dirs(table.file_io(), table.location()).await;
+ let dense = nullable_batch(vec![Some(vec![Some(1.), Some(0.)]); ROWS], 0);
+ let properties = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(ROWS))
+ .set_offset_index_disabled(true)
+ .set_dictionary_enabled(false)
+ .set_compression(Compression::UNCOMPRESSED)
+ .build();
+ let serialize = |batch: RecordBatch| {
+ let properties = properties.clone();
+ async move {
+ let mut bytes = Vec::new();
+ let mut writer =
+ AsyncArrowWriter::try_new(&mut bytes, batch.schema(),
Some(properties)).unwrap();
+ writer.write(&batch).await.unwrap();
+ writer.close().await.unwrap();
+ bytes::Bytes::from(bytes)
+ }
+ };
+ let dense_bytes = serialize(dense).await;
+ let mut files = Vec::new();
+ for i in 0..FILES {
+ let name = format!("data-{i:04}.parquet");
+ table
+ .file_io()
+ .new_output(&format!("{}/bucket-0/{name}", table.location()))
+ .unwrap()
+ .write(dense_bytes.clone())
+ .await
+ .unwrap();
+ let mut file = data_file(&name, Some((i * ROWS) as i64), ROWS as i64);
+ file.file_size = dense_bytes.len() as i64;
+ file.file_source = Some(0);
+ files.push(file);
+ }
+ let plan = |files: Vec<DataFileMeta>| {
+ plan_vindex_shards(
+ table.location(),
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ &CoreOptions::new(table.schema().options()),
+ 1,
+ files
+ .into_iter()
+ .map(|file| {
+ ManifestEntry::new(
+ FileKind::Add,
+ BinaryRow::new(0).to_serialized_bytes(),
+ 0,
+ 1,
+ file,
+ 2,
+ )
+ })
+ .collect(),
+ 1_000_000,
+ &[],
+ )
+ .unwrap()
+ };
+ let builder = VindexIndexBuildBuilder::new(&table, IVF_FLAT_IDENTIFIER);
+ let dense_plan = builder
+ .plan_granules(
+ &plan(files.clone())[0],
+ "embedding",
+ FILES * ROWS / 8,
+ FILES * ROWS / 8,
+ )
+ .await
+ .unwrap();
+ assert!(!dense_plan.rest.is_empty());
+ let null_row = dense_plan.rest.last().unwrap().to();
+ let file = &mut files[null_row as usize / ROWS];
+ let mut rows = vec![Some(vec![Some(1.), Some(0.)]); ROWS];
+ rows[null_row as usize % ROWS] = None;
+ let sparse_bytes = serialize(nullable_batch(rows, 0)).await;
+ table
+ .file_io()
+ .new_output(&format!("{}/bucket-0/{}", table.location(),
file.file_name))
+ .unwrap()
+ .write(sparse_bytes.clone())
+ .await
+ .unwrap();
+ file.file_size = sparse_bytes.len() as i64;
+ for file in &mut files {
+ file.first_row_id = None;
+ }
+ TableCommit::new(table.clone(), "sparse-rest".into())
+ .commit(vec![CommitMessage::new(
+ BinaryRow::new(0).to_serialized_bytes(),
+ 0,
+ files,
+ )])
+ .await
+ .unwrap();
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let entries = table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await
+ .unwrap();
+ let shards = plan_vindex_shards(
+ table.location(),
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ &CoreOptions::new(table.schema().options()),
+ snapshot.id(),
+ entries,
+ 1_000_000,
+ &[],
+ )
+ .unwrap();
+ let shard = &shards[0];
+ let actual_plan = builder
+ .plan_granules(shard, "embedding", FILES * ROWS / 8, FILES * ROWS / 8)
+ .await
+ .unwrap();
+ assert_eq!(actual_plan.first, dense_plan.first);
+ assert_eq!(actual_plan.rest, dense_plan.rest);
+ assert!(actual_plan
+ .first
+ .iter()
+ .all(|range| !(range.from()..=range.to()).contains(&null_row)));
+ let user_options = HashMap::from([("ivf-flat.train.sample-ratio".into(),
"0.125".into())]);
+ let field = find_index_field(&table, "embedding").unwrap();
+ let options = VindexVectorIndexOptions::new(
+ table.schema().options(),
+ &user_options,
+ IVF_FLAT_IDENTIFIER,
+ field,
+ )
+ .unwrap();
+ let outcome = builder
+ .build_index_file_granule(
+ shard,
+ "embedding",
+ 2,
+ field.id(),
+ &options,
+ serde_json::to_vec(&options.native_options).unwrap(),
+ )
+ .await
+ .unwrap();
+ assert!(matches!(outcome, GranuleBuildOutcome::Sparse));
+ let root = format!("{}/index", table.location());
+ assert!(table.file_io().list_status(&root).await.unwrap().is_empty());
+ let messages = table
+ .new_global_index_build_builder()
+ .with_index_column("embedding")
+ .with_index_type(IVF_FLAT_IDENTIFIER)
+ .with_options(user_options)
+ .build()
+ .await
+ .unwrap();
+ let file = &messages[0].new_index_files[0];
+ assert_eq!(file.row_count, (FILES * ROWS) as i64);
+ assert_eq!(
+ metadata(&table, file).await.total_vectors,
+ (FILES * ROWS - 1) as i64
+ );
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ 1
+ );
+}
+
+#[tokio::test]
+async fn automatic_pq_budget_is_validated_against_non_null_cardinality() {
+ let table = vindex_e2e_table("memory:/sparse_budget", "10000");
+ setup_dirs(table.file_io(), table.location()).await;
+ append_nullable(
+ &table,
+ (0..2000)
+ .map(|i| (i % 2 == 1).then_some(vec![Some(1.), Some(0.)]))
+ .collect(),
+ )
+ .await;
+ let messages = table
+ .new_global_index_build_builder()
+ .with_index_column("embedding")
+ .with_index_type("ivf-pq")
+ .with_options(HashMap::from([
+ ("ivf-pq.dimension".into(), "2".into()),
+ ("ivf-pq.max-bytes-per-vector".into(), "64".into()),
+ ]))
+ .build()
+ .await
+ .unwrap();
+ let file = &messages[0].new_index_files[0];
+ assert_eq!(file.row_count, 2000);
+ assert_eq!(metadata(&table, file).await.total_vectors, 1000);
+}
diff --git a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
index 97fad54d..998791ef 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
@@ -222,7 +222,7 @@ fn extract_vectors_from_batches(
let mut vectors = Vec::new();
for batch in batches {
vectors.extend_from_slice(
- validate_vector_batch(batch, index_column, dimension, &mut
expected_row_id)?.values,
+ &validate_vector_batch(batch, index_column, dimension, &mut
expected_row_id)?.values,
);
}
if expected_row_id - row_range_start != expected_row_count {
@@ -1261,3 +1261,6 @@ fn vindex_plan_incremental_prefix_leaves_suffix() {
);
}
}
+
+#[path = "sparse_tests.rs"]
+mod sparse_tests;
diff --git a/crates/paimon/src/table/vindex_index_build_builder/writer.rs
b/crates/paimon/src/table/vindex_index_build_builder/writer.rs
index bc4c77d2..7b77cff4 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/writer.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/writer.rs
@@ -15,7 +15,8 @@
// specific language governing permissions and limitations
// under the License.
-use super::extraction::{data_split_for_shard, validate_vector_batch};
+use super::extraction::{data_split_for_shard, local_ids,
validate_vector_batch};
+use super::pipeline::GranuleBuildOutcome;
use super::planning::VindexIndexShard;
use super::timing::{vector_index_build_timing_enabled, VectorIndexBuildTiming};
use super::validation::{
@@ -31,7 +32,7 @@ use crate::{Error, Result};
use arrow_buffer::MutableBuffer;
use futures::TryStreamExt;
use paimon_vindex_core::autotune::default_training_vector_count;
-use paimon_vindex_core::index::{VectorIndexTrainer, VectorIndexWriter};
+use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer,
VectorIndexWriter};
use paimon_vindex_core::io::PosWriter;
use std::io::{Read, Seek, SeekFrom};
use std::sync::Arc;
@@ -55,24 +56,47 @@ impl<'a> VindexIndexBuildBuilder<'a> {
index_field_id: i32,
options: &VindexVectorIndexOptions,
index_meta: Vec<u8>,
- ) -> Result<BuiltIndexFile> {
- let use_granule = self.index_type != DISKANN_IDENTIFIER &&
options.granule_build_enabled;
+ ) -> Result<Option<BuiltIndexFile>> {
+ let rows = usize::try_from(checked_row_count(
+ shard.row_range_start,
+ shard.row_range_end,
+ )?)
+ .map_err(|error| Error::DataInvalid {
+ message: "vindex row count does not fit usize".into(),
+ source: Some(Box::new(error)),
+ })?;
+ let use_granule = self.index_type != DISKANN_IDENTIFIER
+ && options.granule_build_enabled
+ && !options.needs_vector_count()
+ // Invalid training options still produce no file for an all-NULL
+ // shard, like Java. Full spill determines whether training is
needed.
+ && options.training_config(rows).is_ok();
+ // Auto IVF sizing needs the complete non-null cardinality, like Java.
+ // Explicit nlist/expected-count builds retain the granule fast path.
log::info!(
"vindex build strategy: index_type={}, strategy={}",
self.index_type,
if use_granule { "granule" } else { "full-spill" }
);
if use_granule {
- return self
+ match self
.build_index_file_granule(
shard,
index_column,
dimension,
index_field_id,
options,
- index_meta,
+ index_meta.clone(),
)
- .await;
+ .await?
+ {
+ GranuleBuildOutcome::Built(built) => return Ok(Some(*built)),
+ GranuleBuildOutcome::Sparse => {
+ // Only private in-memory/spilled preparation has
happened. The
+ // pinned snapshot is reread with sparse row IDs and
valid-only training.
+ log::info!("vindex granule source contains NULL vectors;
use sparse full-spill preparation");
+ }
+ }
}
self.build_full_spill_index_file(
shard,
@@ -93,7 +117,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
index_field_id: i32,
options: &VindexVectorIndexOptions,
index_meta: Vec<u8>,
- ) -> Result<BuiltIndexFile> {
+ ) -> Result<Option<BuiltIndexFile>> {
let timing_enabled = vector_index_build_timing_enabled();
let total_start = timing_enabled.then(Instant::now);
let mut source_batch_wait = Duration::ZERO;
@@ -122,28 +146,9 @@ impl<'a> VindexIndexBuildBuilder<'a> {
source: None,
});
}
- let expected_bytes = checked_vector_bytes(row_count_usize,
dimension_usize)?;
- let training_vector_count =
- checked_training_vector_count(row_count_usize,
options.train_sample_ratio)?;
- let training_buffer_rows =
- (VECTOR_BUFFER_BYTES / checked_vector_bytes(1,
dimension_usize)?).max(1);
- let training_buffer_floats = training_buffer_rows
- .checked_mul(dimension_usize)
- .ok_or_else(|| Error::DataInvalid {
- message: "vindex training buffer length overflows
usize".to_string(),
- source: None,
- })?;
-
- let mut trainer =
- VectorIndexTrainer::new(options.config.clone()).map_err(|e|
Error::DataInvalid {
- message: format!("Failed to initialize vindex trainer: {e}"),
- source: Some(Box::new(e)),
- })?;
- let raw_file = tempfile::tempfile().map_err(|e| Error::UnexpectedError
{
- message: format!("Failed to create temporary vindex vector file:
{e}"),
- source: Some(Box::new(e)),
- })?;
- let mut raw_file = tokio::fs::File::from_std(raw_file);
+ checked_vector_bytes(row_count_usize, dimension_usize)?;
+ let mut raw_file = temporary_vector_file("vectors")?;
+ let mut id_file = temporary_vector_file("row IDs")?;
let split = data_split_for_shard(shard)?;
let mut read_builder = self.table.new_read_builder();
read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME])?;
@@ -159,211 +164,115 @@ impl<'a> VindexIndexBuildBuilder<'a> {
let mut batches = read.to_arrow(&[split])?;
let mut expected_row_id = shard.row_range_start;
let mut rows_seen = 0usize;
+ let mut vector_count = 0usize;
let mut bytes_written = 0usize;
- let mut next_training_sample = 0usize;
- let mut training_buffer = Vec::with_capacity(training_buffer_floats);
-
loop {
let source_start = timing_enabled.then(Instant::now);
let batch = batches.try_next().await?;
- if let Some(source_start) = source_start {
- source_batch_wait =
source_batch_wait.saturating_add(source_start.elapsed());
+ if let Some(start) = source_start {
+ source_batch_wait =
source_batch_wait.saturating_add(start.elapsed());
}
let Some(batch) = batch else { break };
batch_count += 1;
let vectors =
validate_vector_batch(&batch, index_column, dimension_usize,
&mut expected_row_id)?;
- let batch_end =
+ rows_seen =
rows_seen
- .checked_add(vectors.row_count)
+ .checked_add(vectors.source_rows)
.ok_or_else(|| Error::DataInvalid {
- message: "vindex streamed row count overflows
usize".to_string(),
+ message: "vindex streamed row count overflows
usize".into(),
source: None,
})?;
-
- if training_vector_count == row_count_usize {
- trainer
- .add_training_vectors_mut(vectors.values,
vectors.row_count)
- .map_err(|e| Error::DataInvalid {
- message: format!("Failed to add vindex training
vectors: {e}"),
- source: Some(Box::new(e)),
- })?;
- } else {
- while next_training_sample < training_vector_count {
- let sample_row = checked_training_sample_index(
- next_training_sample,
- row_count_usize,
- training_vector_count,
- )?;
- if sample_row >= batch_end {
- break;
- }
- let start = (sample_row - rows_seen) * dimension_usize;
- training_buffer
- .extend_from_slice(&vectors.values[start..start +
dimension_usize]);
- next_training_sample += 1;
- if training_buffer.len() == training_buffer_floats {
- trainer
- .add_training_vectors_mut(
- &training_buffer,
- training_buffer.len() / dimension_usize,
- )
- .map_err(|e| Error::DataInvalid {
- message: format!("Failed to add vindex
training vectors: {e}"),
- source: Some(Box::new(e)),
- })?;
- training_buffer.clear();
- }
- }
- }
-
+ vector_count = vector_count
+ .checked_add(vectors.vector_count)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex streamed vector count overflows
usize".into(),
+ source: None,
+ })?;
+ let ids: arrow_buffer::ScalarBuffer<i64> =
+ local_ids(&vectors.row_ids, shard.row_range_start,
row_count_usize)?.into();
let raw_write_start = timing_enabled.then(Instant::now);
raw_file
- .write_all(vectors.bytes)
+ .write_all(vectors.bytes())
.await
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to spill vindex vectors: {e}"),
- source: Some(Box::new(e)),
- })?;
- if let Some(raw_write_start) = raw_write_start {
- raw_temp_write =
raw_temp_write.saturating_add(raw_write_start.elapsed());
+ .map_err(spill_error)?;
+ id_file
+ .write_all(ids.inner().as_slice())
+ .await
+ .map_err(spill_error)?;
+ if let Some(start) = raw_write_start {
+ raw_temp_write =
raw_temp_write.saturating_add(start.elapsed());
}
bytes_written = bytes_written
- .checked_add(vectors.bytes.len())
+ .checked_add(vectors.bytes().len())
.ok_or_else(|| Error::DataInvalid {
- message: "vindex spilled byte count overflows
usize".to_string(),
+ message: "vindex spilled byte count overflows
usize".into(),
source: None,
})?;
- rows_seen = batch_end;
}
-
- if !training_buffer.is_empty() {
- trainer
- .add_training_vectors_mut(&training_buffer,
training_buffer.len() / dimension_usize)
- .map_err(|e| Error::DataInvalid {
- message: format!("Failed to add vindex training vectors:
{e}"),
- source: Some(Box::new(e)),
+ let expected_end =
+ shard
+ .row_range_end
+ .checked_add(1)
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex row range end overflows i64".into(),
+ source: None,
})?;
- }
if rows_seen != row_count_usize
- || expected_row_id
- != shard
- .row_range_end
- .checked_add(1)
- .ok_or_else(|| Error::DataInvalid {
- message: "vindex row range end overflows
i64".to_string(),
- source: None,
- })?
- || (training_vector_count != row_count_usize
- && next_training_sample != training_vector_count)
- || bytes_written != expected_bytes
+ || expected_row_id != expected_end
+ || bytes_written != checked_vector_bytes(vector_count,
dimension_usize)?
{
return Err(Error::DataInvalid {
- message: format!(
- "vindex streamed data mismatch:
rows={rows_seen}/{row_count_usize},
training={next_training_sample}/{training_vector_count},
bytes={bytes_written}/{expected_bytes}"
- ),
- source: None,
+ message: format!("vindex streamed data mismatch:
rows={rows_seen}/{row_count_usize}, vectors={vector_count},
bytes={bytes_written}"), source: None,
});
}
- let raw_write_start = timing_enabled.then(Instant::now);
- raw_file.flush().await.map_err(|e| Error::UnexpectedError {
- message: format!("Failed to flush temporary vindex vector file:
{e}"),
- source: Some(Box::new(e)),
- })?;
- if let Some(raw_write_start) = raw_write_start {
- raw_temp_write =
raw_temp_write.saturating_add(raw_write_start.elapsed());
+ // Like Java NativeVectorGlobalIndexWriter.finish: no vectors means no
file.
+ if vector_count == 0 {
+ return Ok(None);
}
- let raw_file_len = raw_file
- .metadata()
- .await
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to inspect temporary vindex vector
file: {e}"),
- source: Some(Box::new(e)),
- })?
- .len();
- if raw_file_len != expected_bytes as u64 {
+ raw_file.flush().await.map_err(spill_error)?;
+ id_file.flush().await.map_err(spill_error)?;
+ if raw_file.metadata().await.map_err(spill_error)?.len() !=
bytes_written as u64
+ || id_file.metadata().await.map_err(spill_error)?.len()
+ != checked_vector_bytes(vector_count, 2)? as u64
+ {
return Err(Error::DataInvalid {
- message: format!(
- "temporary vindex vector file size mismatch:
{raw_file_len}/{expected_bytes}"
- ),
+ message: "temporary vindex vector or row ID file size
mismatch".into(),
source: None,
});
}
let raw_file = raw_file.into_std().await;
- // Diagnostics only: never fail the build for a timing log field.
+ let id_file = id_file.into_std().await;
+ let training_vector_count =
+ checked_training_vector_count(vector_count,
options.train_sample_ratio)?;
+ let config = options.training_config(vector_count)?;
let training_rows_retained = if timing_enabled {
- default_training_vector_count(training_vector_count,
options.config.nlist())
- .unwrap_or(0)
+ default_training_vector_count(training_vector_count,
config.nlist()).unwrap_or(0)
} else {
0
};
-
- let (writer, train_finish, raw_temp_reread, index_add) =
tokio::task::spawn_blocking(
- move || -> std::io::Result<(VectorIndexWriter, Duration, Duration,
Duration)> {
- let train_start = timing_enabled.then(Instant::now);
- let training = trainer.finish()?;
- let train_finish = train_start.map_or(Duration::ZERO, |start|
start.elapsed());
- let mut writer = VectorIndexWriter::new(training);
- let mut raw_temp_reread = Duration::ZERO;
- let mut index_add = Duration::ZERO;
- let mut raw_file = raw_file;
- let reread_start = timing_enabled.then(Instant::now);
- raw_file.seek(SeekFrom::Start(0))?;
- if let Some(start) = reread_start {
- raw_temp_reread =
raw_temp_reread.saturating_add(start.elapsed());
- }
- let batch_rows = training_buffer_rows.min(row_count_usize);
- let batch_bytes = checked_std_vector_bytes(batch_rows,
dimension_usize)?;
- let mut buffer = MutableBuffer::new(batch_bytes);
- let mut ids = Vec::with_capacity(batch_rows);
- let mut rows_added = 0usize;
- while rows_added < row_count_usize {
- let rows = batch_rows.min(row_count_usize - rows_added);
- buffer.resize(checked_std_vector_bytes(rows,
dimension_usize)?, 0);
- let reread_start = timing_enabled.then(Instant::now);
- raw_file.read_exact(buffer.as_slice_mut())?;
- if let Some(start) = reread_start {
- raw_temp_reread =
raw_temp_reread.saturating_add(start.elapsed());
- }
- ids.clear();
- for row in rows_added..rows_added + rows {
- ids.push(i64::try_from(row).map_err(|_| {
- std::io::Error::new(
- std::io::ErrorKind::InvalidData,
- "vindex row id does not fit i64",
- )
- })?);
- }
- let add_start = timing_enabled.then(Instant::now);
- writer.add_vectors(&ids, buffer.typed_data::<f32>(),
rows)?;
- if let Some(start) = add_start {
- index_add = index_add.saturating_add(start.elapsed());
- }
- rows_added += rows;
- }
- let mut trailing = [0u8; 1];
- let reread_start = timing_enabled.then(Instant::now);
- if raw_file.read(&mut trailing)? != 0 {
- return Err(std::io::Error::new(
- std::io::ErrorKind::InvalidData,
- "temporary vindex vector file contains trailing bytes",
- ));
- }
- if let Some(start) = reread_start {
- raw_temp_reread =
raw_temp_reread.saturating_add(start.elapsed());
- }
- Ok((writer, train_finish, raw_temp_reread, index_add))
- },
- )
- .await
- .map_err(|e| Error::UnexpectedError {
- message: format!("vindex training task failed: {e}"),
- source: None,
- })?
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to train or add vectors to vindex index:
{e}"),
- source: Some(Box::new(e)),
- })?;
+ let ratio = options.train_sample_ratio;
+ let (writer, train_finish, raw_temp_reread, index_add) =
+ tokio::task::spawn_blocking(move || {
+ train_and_add_spilled_vectors(
+ raw_file,
+ id_file,
+ config,
+ vector_count,
+ dimension_usize,
+ ratio,
+ timing_enabled,
+ )
+ })
+ .await
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("vindex training task failed: {e}"),
+ source: None,
+ })?
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to train or add vectors to vindex
index: {e}"),
+ source: Some(Box::new(e)),
+ })?;
let serialize_upload_start = timing_enabled.then(Instant::now);
let meta = self
@@ -414,7 +323,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
data_file_count: shard.files.len(),
file_name: meta.file_name.clone(),
});
- Ok(BuiltIndexFile { meta, timing })
+ Ok(Some(BuiltIndexFile { meta, timing }))
}
pub(super) async fn finish_index_file(
@@ -490,3 +399,102 @@ impl<'a> VindexIndexBuildBuilder<'a> {
})
}
}
+
+fn spill_error(error: std::io::Error) -> Error {
+ Error::UnexpectedError {
+ message: format!("Failed to spill vindex vectors: {error}"),
+ source: Some(Box::new(error)),
+ }
+}
+
+fn temporary_vector_file(kind: &str) -> Result<tokio::fs::File> {
+ tempfile::tempfile()
+ .map(tokio::fs::File::from_std)
+ .map_err(|error| Error::UnexpectedError {
+ message: format!("Failed to create temporary vindex {kind} file:
{error}"),
+ source: Some(Box::new(error)),
+ })
+}
+
+/// Java samples the compacted non-null vector stream, then adds the original
relative IDs.
+fn train_and_add_spilled_vectors(
+ mut raw_file: std::fs::File,
+ mut id_file: std::fs::File,
+ config: VectorIndexConfig,
+ vector_count: usize,
+ dimension: usize,
+ ratio: f64,
+ timing_enabled: bool,
+) -> std::io::Result<(VectorIndexWriter, Duration, Duration, Duration)> {
+ let train_start = timing_enabled.then(Instant::now);
+ let samples =
+ checked_training_vector_count(vector_count,
ratio).map_err(std::io::Error::other)?;
+ let batch_rows = (VECTOR_BUFFER_BYTES / checked_std_vector_bytes(1,
dimension)?)
+ .max(1)
+ .min(vector_count);
+ let mut buffer = MutableBuffer::new(checked_std_vector_bytes(batch_rows,
dimension)?);
+ let mut sample_buffer = Vec::with_capacity(batch_rows * dimension);
+ let mut trainer = VectorIndexTrainer::new(config)?;
+ raw_file.seek(SeekFrom::Start(0))?;
+ let mut rows_seen = 0;
+ let mut selected = 0;
+ while rows_seen < vector_count {
+ let rows = batch_rows.min(vector_count - rows_seen);
+ buffer.resize(checked_std_vector_bytes(rows, dimension)?, 0);
+ raw_file.read_exact(buffer.as_slice_mut())?;
+ let values = buffer.typed_data::<f32>();
+ while selected < samples {
+ let sample = checked_training_sample_index(selected, vector_count,
samples)
+ .map_err(std::io::Error::other)?;
+ if sample >= rows_seen + rows {
+ break;
+ }
+ let offset = (sample - rows_seen) * dimension;
+ sample_buffer.extend_from_slice(&values[offset..offset +
dimension]);
+ selected += 1;
+ }
+ if !sample_buffer.is_empty() {
+ trainer.add_training_vectors_mut(&sample_buffer,
sample_buffer.len() / dimension)?;
+ sample_buffer.clear();
+ }
+ rows_seen += rows;
+ }
+ if selected != samples {
+ return Err(std::io::Error::other(
+ "vindex training sample count mismatch",
+ ));
+ }
+ let training = trainer.finish()?;
+ let train_finish = train_start.map_or(Duration::ZERO, |start|
start.elapsed());
+ let mut writer = VectorIndexWriter::new(training);
+ let mut raw_temp_reread = Duration::ZERO;
+ let mut index_add = Duration::ZERO;
+ raw_file.seek(SeekFrom::Start(0))?;
+ id_file.seek(SeekFrom::Start(0))?;
+ let mut ids = MutableBuffer::new(batch_rows * std::mem::size_of::<i64>());
+ let mut rows_added = 0;
+ while rows_added < vector_count {
+ let rows = batch_rows.min(vector_count - rows_added);
+ buffer.resize(checked_std_vector_bytes(rows, dimension)?, 0);
+ ids.resize(rows * std::mem::size_of::<i64>(), 0);
+ let read_start = timing_enabled.then(Instant::now);
+ raw_file.read_exact(buffer.as_slice_mut())?;
+ id_file.read_exact(ids.as_slice_mut())?;
+ if let Some(start) = read_start {
+ raw_temp_reread = raw_temp_reread.saturating_add(start.elapsed());
+ }
+ let add_start = timing_enabled.then(Instant::now);
+ writer.add_vectors(ids.typed_data::<i64>(),
buffer.typed_data::<f32>(), rows)?;
+ if let Some(start) = add_start {
+ index_add = index_add.saturating_add(start.elapsed());
+ }
+ rows_added += rows;
+ }
+ let mut trailing = [0u8; 1];
+ if raw_file.read(&mut trailing)? != 0 || id_file.read(&mut trailing)? != 0
{
+ return Err(std::io::Error::other(
+ "temporary vindex vector or row ID file contains trailing bytes",
+ ));
+ }
+ Ok((writer, train_finish, raw_temp_reread, index_add))
+}
diff --git a/crates/paimon/src/vindex/mod.rs b/crates/paimon/src/vindex/mod.rs
index 90d6db1f..425a0eec 100644
--- a/crates/paimon/src/vindex/mod.rs
+++ b/crates/paimon/src/vindex/mod.rs
@@ -36,9 +36,6 @@ pub const DISKANN_IDENTIFIER: &str = "diskann";
const DEFAULT_DIMENSION: &str = "128";
const DEFAULT_METRIC: &str = "inner_product";
-const DEFAULT_NLIST: &str = "256";
-const DEFAULT_PQ_M: &str = "16";
-const DEFAULT_PQ_USE_OPQ: &str = "false";
const DEFAULT_TRAIN_SAMPLE_RATIO: f64 = 1.0;
const VINDEX_BUILD_GRANULE_ENABLED: &str = "vindex.build.granule.enabled";
const VECTOR_SEARCH_TIMING_ENV: &str = "PAIMON_LOG_VECTOR_SEARCH_TIMING";
@@ -112,7 +109,7 @@ pub(crate) fn native_index_type(index_type: &str) ->
Option<&'static str> {
#[derive(Debug)]
pub(crate) struct VindexVectorIndexOptions {
- pub config: VectorIndexConfig,
+ dimension: usize,
pub native_options: HashMap<String, String>,
pub train_sample_ratio: f64,
pub granule_build_enabled: bool,
@@ -132,205 +129,272 @@ impl VindexVectorIndexOptions {
})?;
validate_user_option_keys(user_options, index_type, field.name())?;
- validate_index_type_option(table_options, user_options,
native_index_type)?;
-
+ validate_index_type_option(user_options, native_index_type)?;
let mut native_options = HashMap::new();
- native_options.insert("index.type".to_string(),
native_index_type.to_string());
- native_options.insert(
- "dimension".to_string(),
- resolve_dimension(table_options, user_options, index_type, field)?,
- );
- if index_type != DISKANN_IDENTIFIER {
- native_options.insert(
- "nlist".to_string(),
- option_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- "nlist",
- "nlist",
- DEFAULT_NLIST,
- ),
- );
- }
- native_options.insert(
- "metric".to_string(),
- normalize_metric(&option_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- "metric",
- "distance.metric",
- DEFAULT_METRIC,
- )),
- );
-
- if index_type == IVF_PQ_IDENTIFIER {
- native_options.insert(
- "pq.m".to_string(),
- option_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- "pq.m",
- "pq.m",
- DEFAULT_PQ_M,
- ),
- );
- native_options.insert(
- "use-opq".to_string(),
- option_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- "use-opq",
- "pq.use-opq",
- DEFAULT_PQ_USE_OPQ,
- ),
- );
- }
- if index_type == IVF_RQ_IDENTIFIER {
- for key in ["rq.bits", "max-bytes-per-vector"] {
- if let Some(value) = optional_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- key,
- key,
- ) {
- native_options.insert(key.to_string(), value);
- }
- }
- }
- if index_type == DISKANN_IDENTIFIER {
- for &(native_key, paimon_suffix) in DISKANN_OPTION_KEYS {
- if let Some(value) = optional_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- native_key,
- paimon_suffix,
- ) {
- native_options.insert(native_key.to_string(), value);
- }
- }
- }
- for key in [
- "ivf.coarse-assignment",
- "ivf.pq-encoding",
- "ivf.train.max-points-per-centroid",
- "pq.train.max-points-per-centroid",
- ] {
- if is_allowed_native_key(key, index_type) {
- if let Some(value) = optional_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- key,
- key,
- ) {
- native_options.insert(key.to_string(), value);
- }
- }
- }
-
- let config =
VectorIndexConfig::from_options(&native_options).map_err(|e| {
- crate::Error::DataInvalid {
- message: format!("Invalid vindex options: {e}"),
- source: Some(Box::new(e)),
- }
- })?;
+ // Source precedence is build > table; within each source, field >
index
+ // > native keys. Table options follow Java; explicit builder options
+ // also accept the documented Rust/SQL native aliases.
+ collect_native_options(
+ &mut native_options,
+ table_options,
+ index_type,
+ field.name(),
+ false,
+ )?;
+ collect_native_options(
+ &mut native_options,
+ user_options,
+ index_type,
+ field.name(),
+ true,
+ )?;
+ native_options.insert("index.type".into(), native_index_type.into());
+ let dimension = match field.data_type() {
+ DataType::Vector(vector) => vector.length().to_string(),
+ _ => native_options
+ .get("dimension")
+ .cloned()
+ .unwrap_or_else(|| DEFAULT_DIMENSION.into()),
+ };
+ native_options.insert("dimension".into(), dimension);
+ let metric = native_options
+ .entry("metric".into())
+ .or_insert_with(|| DEFAULT_METRIC.into());
+ *metric = normalize_metric(metric);
+
+ // Like Java, compile the native configuration only when training
begins.
+ // A synthetic count can reject budgets satisfied by the real vector
count.
+ let dimension = native_options["dimension"]
+ .trim()
+ .parse::<usize>()
+ .ok()
+ .filter(|dimension| *dimension > 0)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "vindex vector dimension must be a positive
integer".into(),
+ source: None,
+ })?;
let train_sample_ratio =
resolve_train_sample_ratio(table_options, user_options,
index_type, field.name())?;
let granule_build_enabled =
resolve_granule_build_enabled(table_options, user_options)?;
Ok(Self {
- config,
+ dimension,
native_options,
train_sample_ratio,
granule_build_enabled,
})
}
+ pub(crate) fn needs_vector_count(&self) -> bool {
+ self.native_options
+ .get("index.type")
+ .is_some_and(|kind| kind.starts_with("ivf_"))
+ && self
+ .native_options
+ .get("nlist")
+ .is_none_or(|value| value.trim() == "auto")
+ && !self.native_options.contains_key("expected-vector-count")
+ }
+
+ pub(crate) fn training_config(&self, vector_count: usize) ->
crate::Result<VectorIndexConfig> {
+ compile_training_config(&self.native_options, vector_count)
+ }
+
pub fn dimension(&self) -> usize {
- self.config.dimension()
+ self.dimension
}
}
-fn validate_index_type_option(
- table_options: &HashMap<String, String>,
- user_options: &HashMap<String, String>,
- expected_native: &str,
-) -> crate::Result<()> {
- for options in [table_options, user_options] {
- if let Some(value) = options.get("index.type") {
- let normalized = value.trim().to_ascii_lowercase().replace('-',
"_");
- if normalized != expected_native {
- return Err(crate::Error::ConfigInvalid {
- message: format!(
- "Option 'index.type' is '{}', but procedure index_type
resolves to '{}'. \
- Remove 'index.type' from options or set it to '{}'.",
- value, expected_native, expected_native
- ),
- });
- }
- }
+fn compile_training_config(
+ native_options: &HashMap<String, String>,
+ vector_count: usize,
+) -> crate::Result<VectorIndexConfig> {
+ let mut options = native_options.clone();
+ // Java NativeVectorGlobalIndexWriter.trainingOptions uses valid vectors.
+ if options
+ .get("index.type")
+ .is_some_and(|kind| kind.starts_with("ivf_"))
+ && options
+ .get("nlist")
+ .is_none_or(|value| value.trim() == "auto")
+ {
+ options
+ .entry("expected-vector-count".into())
+ .or_insert_with(|| vector_count.to_string());
}
- Ok(())
+ VectorIndexConfig::from_options(&options).map_err(|error|
crate::Error::DataInvalid {
+ message: format!("Invalid vindex options: {error}"),
+ source: Some(Box::new(error)),
+ })
}
fn validate_user_option_keys(
- user_options: &HashMap<String, String>,
+ options: &HashMap<String, String>,
index_type: &str,
- field_name: &str,
+ field: &str,
) -> crate::Result<()> {
- let mut unknown = user_options
+ let mut unknown = options
.keys()
- .filter(|key| !is_supported_user_option_key(key, index_type,
field_name))
+ .filter(|key| {
+ if matches!(
+ key.as_str(),
+ "index.type"
+ | "global-index.row-count-per-shard"
+ | "global-index.build.parallelism"
+ ) {
+ return false;
+ }
+ if key.as_str() == VINDEX_BUILD_GRANULE_ENABLED {
+ return index_type == DISKANN_IDENTIFIER;
+ }
+ let suffix = key
+ .strip_prefix(&format!("{index_type}."))
+ .or_else(|| key.strip_prefix(&format!("fields.{field}.")))
+ .unwrap_or(key);
+ if suffix == "train.sample-ratio" {
+ return key.as_str() == suffix;
+ }
+ native_option_key(suffix).is_none_or(|key|
!native_option_applies(key, index_type))
+ })
.cloned()
.collect::<Vec<_>>();
if unknown.is_empty() {
return Ok(());
}
-
unknown.sort();
Err(crate::Error::ConfigInvalid {
message: format!(
- "Unknown vindex option(s) for index_type '{}': {}",
- index_type,
+ "Unknown vindex option(s) for index_type '{index_type}': {}",
unknown.join(", ")
),
})
}
-fn is_supported_user_option_key(key: &str, index_type: &str, field_name: &str)
-> bool {
- if key == "index.type" {
- return true;
- }
- if key == VINDEX_BUILD_GRANULE_ENABLED {
- return index_type != DISKANN_IDENTIFIER;
+fn native_option_applies(key: &str, index_type: &str) -> bool {
+ match key {
+ "dimension"
+ | "metric"
+ | "expected-vector-count"
+ | "target-recall"
+ | "deployment-profile" => true,
+ "nlist" => index_type != DISKANN_IDENTIFIER,
+ "use-opq" => index_type == IVF_PQ_IDENTIFIER,
+ "rq.bits" => index_type == IVF_RQ_IDENTIFIER,
+ "pq.m" | "pq.code-ratio" => matches!(index_type, IVF_PQ_IDENTIFIER |
DISKANN_IDENTIFIER),
+ "max-bytes-per-vector" => true,
+ key if is_native_build_option(key) => build_option_applies(key,
index_type),
+ key => {
+ index_type == DISKANN_IDENTIFIER
+ && DISKANN_OPTION_KEYS.iter().any(|(native, _)| key == *native)
+ }
}
- if is_allowed_native_key(key, index_type) {
- return true;
+}
+
+fn validate_index_type_option(
+ user_options: &HashMap<String, String>,
+ expected_native: &str,
+) -> crate::Result<()> {
+ for options in [user_options] {
+ if let Some(value) = options.get("index.type") {
+ let normalized = value.trim().to_ascii_lowercase().replace('-',
"_");
+ if normalized != expected_native {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Option 'index.type' is '{}', but procedure index_type
resolves to '{}'. \
+ Remove 'index.type' from options or set it to '{}'.",
+ value, expected_native, expected_native
+ ),
+ });
+ }
+ }
}
+ Ok(())
+}
- let index_prefix = format!("{index_type}.");
- if let Some(suffix) = key.strip_prefix(&index_prefix) {
- return is_allowed_paimon_suffix(suffix, index_type);
+fn native_option_key(key: &str) -> Option<&str> {
+ match key {
+ "index.dimension" | "dimension" => Some("dimension"),
+ "distance.metric" | "metric" => Some("metric"),
+ "pq.use-opq" | "use-opq" => Some("use-opq"),
+ "nlist"
+ | "expected-vector-count"
+ | "ivf.coarse-assignment"
+ | "ivf.pq-encoding"
+ | "ivf.train.max-points-per-centroid"
+ | "pq.train.max-points-per-centroid"
+ | "pq.m"
+ | "pq.code-ratio"
+ | "pq.bits"
+ | "rq.bits"
+ | "target-recall"
+ | "max-bytes-per-vector"
+ | "deployment-profile" => Some(key),
+ _ => DISKANN_OPTION_KEYS
+ .iter()
+ .find_map(|(native, suffix)| (key == *native || key ==
*suffix).then_some(*native)),
}
+}
- let field_prefix = format!("fields.{field_name}.");
- if let Some(suffix) = key.strip_prefix(&field_prefix) {
- return is_allowed_paimon_suffix(suffix, index_type);
+fn is_native_build_option(key: &str) -> bool {
+ matches!(
+ key,
+ "ivf.coarse-assignment"
+ | "ivf.pq-encoding"
+ | "ivf.train.max-points-per-centroid"
+ | "pq.train.max-points-per-centroid"
+ )
+}
+
+fn build_option_applies(key: &str, index_type: &str) -> bool {
+ match key {
+ "ivf.pq-encoding" => index_type == IVF_PQ_IDENTIFIER,
+ "pq.train.max-points-per-centroid" => {
+ matches!(index_type, IVF_PQ_IDENTIFIER | DISKANN_IDENTIFIER)
+ }
+ _ => index_type != DISKANN_IDENTIFIER,
}
+}
- false
+fn collect_native_options(
+ result: &mut HashMap<String, String>,
+ options: &HashMap<String, String>,
+ index_type: &str,
+ field_name: &str,
+ validate: bool,
+) -> crate::Result<()> {
+ for prefix in [
+ String::new(),
+ format!("{index_type}."),
+ format!("fields.{field_name}."),
+ ] {
+ for (key, value) in options {
+ let Some(suffix) = key.strip_prefix(&prefix) else {
+ continue;
+ };
+ let Some(native_key) = native_option_key(suffix) else {
+ continue;
+ };
+ // Schema options follow Java. Explicit Rust/SQL builder options
also
+ // accept the documented native aliases.
+ if prefix.is_empty()
+ && !validate
+ && (key != native_key || !is_native_build_option(native_key))
+ {
+ continue;
+ }
+ if is_native_build_option(native_key) &&
!build_option_applies(native_key, index_type) {
+ if validate {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Option '{key}' is not supported for index type
'{index_type}'."
+ ),
+ });
+ }
+ continue;
+ }
+ result.insert(native_key.into(), value.clone());
+ }
+ }
+ Ok(())
}
fn resolve_granule_build_enabled(
@@ -352,56 +416,6 @@ fn resolve_granule_build_enabled(
}
}
-fn is_allowed_native_key(key: &str, index_type: &str) -> bool {
- match key {
- "dimension" | "metric" => true,
- "nlist" => index_type != DISKANN_IDENTIFIER,
- "use-opq" => index_type == IVF_PQ_IDENTIFIER,
- "rq.bits" => index_type == IVF_RQ_IDENTIFIER,
- "max-bytes-per-vector" => {
- matches!(index_type, IVF_RQ_IDENTIFIER | DISKANN_IDENTIFIER)
- }
- "pq.m" if index_type == IVF_PQ_IDENTIFIER => true,
- "ivf.coarse-assignment" | "ivf.train.max-points-per-centroid" => {
- index_type != DISKANN_IDENTIFIER
- }
- "ivf.pq-encoding" => index_type == IVF_PQ_IDENTIFIER,
- "pq.train.max-points-per-centroid" => {
- matches!(index_type, IVF_PQ_IDENTIFIER | DISKANN_IDENTIFIER)
- }
- _ => {
- index_type == DISKANN_IDENTIFIER
- && DISKANN_OPTION_KEYS
- .iter()
- .any(|(native_key, _)| *native_key == key)
- }
- }
-}
-
-fn is_allowed_paimon_suffix(suffix: &str, index_type: &str) -> bool {
- match suffix {
- "dimension" | "distance.metric" => true,
- "nlist" => index_type != DISKANN_IDENTIFIER,
- "train.sample-ratio" => true,
- "pq.use-opq" => index_type == IVF_PQ_IDENTIFIER,
- "rq.bits" => index_type == IVF_RQ_IDENTIFIER,
- "max-bytes-per-vector" => {
- matches!(index_type, IVF_RQ_IDENTIFIER | DISKANN_IDENTIFIER)
- }
- "pq.m" if index_type == IVF_PQ_IDENTIFIER => true,
- "ivf.coarse-assignment"
- | "ivf.pq-encoding"
- | "ivf.train.max-points-per-centroid"
- | "pq.train.max-points-per-centroid" => is_allowed_native_key(suffix,
index_type),
- _ => {
- index_type == DISKANN_IDENTIFIER
- && DISKANN_OPTION_KEYS
- .iter()
- .any(|(_, paimon_suffix)| *paimon_suffix == suffix)
- }
- }
-}
-
fn resolve_train_sample_ratio(
table_options: &HashMap<String, String>,
user_options: &HashMap<String, String>,
@@ -428,6 +442,7 @@ fn resolve_train_sample_ratio(
return Ok(DEFAULT_TRAIN_SAMPLE_RATIO);
};
let ratio = value
+ .trim()
.parse::<f64>()
.map_err(|_| crate::Error::ConfigInvalid {
message: format!("Invalid vindex train.sample-ratio: '{value}'"),
@@ -442,69 +457,6 @@ fn resolve_train_sample_ratio(
Ok(ratio)
}
-fn resolve_dimension(
- table_options: &HashMap<String, String>,
- user_options: &HashMap<String, String>,
- index_type: &str,
- field: &DataField,
-) -> crate::Result<String> {
- if let DataType::Vector(vector) = field.data_type() {
- return Ok(vector.length().to_string());
- }
-
- Ok(option_value(
- table_options,
- user_options,
- field.name(),
- index_type,
- "dimension",
- "dimension",
- DEFAULT_DIMENSION,
- ))
-}
-
-fn option_value(
- table_options: &HashMap<String, String>,
- user_options: &HashMap<String, String>,
- field_name: &str,
- index_type: &str,
- native_key: &str,
- paimon_suffix: &str,
- default_value: &str,
-) -> String {
- optional_value(
- table_options,
- user_options,
- field_name,
- index_type,
- native_key,
- paimon_suffix,
- )
- .unwrap_or_else(|| default_value.to_string())
-}
-
-fn optional_value(
- table_options: &HashMap<String, String>,
- user_options: &HashMap<String, String>,
- field_name: &str,
- index_type: &str,
- native_key: &str,
- paimon_suffix: &str,
-) -> Option<String> {
- for options in [user_options, table_options] {
- for key in [
- format!("fields.{field_name}.{paimon_suffix}"),
- format!("{index_type}.{paimon_suffix}"),
- native_key.to_string(),
- ] {
- if let Some(value) = options.get(&key) {
- return Some(value.clone());
- }
- }
- }
- None
-}
-
fn normalize_metric(metric: &str) -> String {
metric.trim().to_ascii_lowercase().replace('-', "_")
}
@@ -540,7 +492,7 @@ mod tests {
)
.unwrap();
let n = 512;
- let dimension = options.config.dimension();
+ let dimension = options.training_config(512).unwrap().dimension();
let data = (0..n * dimension)
.map(|offset| {
let row = offset / dimension;
@@ -548,7 +500,8 @@ mod tests {
(row % 4) as f32 * 20.0 + column as f32 * 0.01 + row as f32 *
0.0001
})
.collect::<Vec<_>>();
- let training = VectorIndexTrainer::train(options.config, &data,
n).unwrap();
+ let training =
+ VectorIndexTrainer::train(options.training_config(n).unwrap(),
&data, n).unwrap();
let mut writer = VectorIndexWriter::new(training);
writer
.add_vectors(&(0..n as i64).collect::<Vec<_>>(), &data, n)
@@ -656,7 +609,7 @@ mod tests {
.map(String::as_str),
Some("canonical")
);
- let resolved = options.config.resolved();
+ let resolved = options.training_config(512).unwrap().resolved();
assert!(!resolved.use_approximate_coarse_assignment);
assert!(resolved.canonical_pq_encoding);
assert_eq!(resolved.ivf_train_max_points_per_centroid, Some(32));
@@ -673,7 +626,11 @@ mod tests {
)
.unwrap();
assert_eq!(
- diskann.config.resolved().pq_train_max_points_per_centroid,
+ diskann
+ .training_config(512)
+ .unwrap()
+ .resolved()
+ .pq_train_max_points_per_centroid,
Some(16)
);
}
@@ -698,7 +655,10 @@ mod tests {
options.native_options.get("rq.bits").map(String::as_str),
Some("3")
);
- assert_eq!(options.config.resolved().rq_bits, Some(3));
+ assert_eq!(
+ options.training_config(512).unwrap().resolved().rq_bits,
+ Some(3)
+ );
let defaults = VindexVectorIndexOptions::new(
&HashMap::new(),
@@ -707,7 +667,10 @@ mod tests {
&array_float_field(),
)
.unwrap();
- assert_eq!(defaults.config.resolved().rq_bits, Some(4));
+ assert_eq!(
+ defaults.training_config(512).unwrap().resolved().rq_bits,
+ Some(4)
+ );
assert!(!defaults.native_options.contains_key("rq.bits"));
let capacity_goal = HashMap::from([
@@ -722,7 +685,10 @@ mod tests {
&array_float_field(),
)
.unwrap();
- assert_eq!(inferred.config.resolved().rq_bits, Some(3));
+ assert_eq!(
+ inferred.training_config(512).unwrap().resolved().rq_bits,
+ Some(3)
+ );
assert_eq!(
inferred
.native_options
@@ -739,6 +705,8 @@ mod tests {
IVF_RQ_IDENTIFIER,
&array_float_field(),
)
+ .unwrap()
+ .training_config(512)
.expect_err("rq.bits outside 1..=8 must be rejected");
assert!(error.to_string().contains("rq.bits"));
}
@@ -790,7 +758,7 @@ mod tests {
&array_float_field(),
)
.unwrap();
- let resolved = options.config.resolved();
+ let resolved = options.training_config(512).unwrap().resolved();
assert_eq!(resolved.pq_m, Some(16));
assert_eq!(resolved.pq_bits, Some(4));
@@ -1083,6 +1051,8 @@ mod tests {
IVF_PQ_IDENTIFIER,
&array_float_field(),
)
+ .unwrap()
+ .training_config(512)
.expect_err("invalid native config should be rejected");
assert!(
@@ -1091,23 +1061,29 @@ mod tests {
}
#[test]
- fn test_vindex_options_reject_unknown_user_options() {
- let table_options = HashMap::new();
- let user_options = HashMap::from([
- ("ivf-flat.dimension".to_string(), "8".to_string()),
- ("ivf-flat.nlsit".to_string(), "4".to_string()),
- ]);
-
- let err = VindexVectorIndexOptions::new(
- &table_options,
- &user_options,
+ fn test_vindex_table_options_ignore_unprefixed_keys_like_java() {
+ let options = VindexVectorIndexOptions::new(
+ &HashMap::from([
+ ("dimension".into(), "7".into()),
+ ("metric".into(), "invalid".into()),
+ ("index.type".into(), "diskann".into()),
+ ("nlist".into(), "0".into()),
+ ("ivf-flat.nlsit".into(), "4".into()),
+ ("global-index.build.parallelism".into(), "2".into()),
+ ]),
+ &HashMap::new(),
IVF_FLAT_IDENTIFIER,
&array_float_field(),
)
- .expect_err("unknown user option should be rejected");
-
- assert!(
- matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("ivf-flat.nlsit"))
+ .unwrap();
+ assert_eq!(options.dimension(), 128);
+ assert_eq!(options.native_options["metric"], "inner_product");
+ assert_eq!(options.native_options["index.type"], "ivf_flat");
+ assert!(!options.native_options.contains_key("nlist"));
+ assert!(options.needs_vector_count());
+ assert_eq!(
+ options.training_config(1000).unwrap().nlist(),
+ paimon_vindex_core::autotune::infer_ivf_nlist(1000).unwrap()
);
}
@@ -1115,14 +1091,16 @@ mod tests {
fn test_vindex_options_reject_non_applicable_user_options() {
for (index_type, key) in [
(IVF_FLAT_IDENTIFIER, "ivf-flat.pq.m"),
+ (IVF_FLAT_IDENTIFIER, "ivf-pq.dimension"),
+ (IVF_FLAT_IDENTIFIER, "ivf-flat.nlsit"),
+ (IVF_FLAT_IDENTIFIER, "diskann.max-degree"),
+ (DISKANN_IDENTIFIER, "diskann.nlist"),
+ (DISKANN_IDENTIFIER, VINDEX_BUILD_GRANULE_ENABLED),
(IVF_FLAT_IDENTIFIER, "ivf-flat.ivf.pq-encoding"),
(
IVF_FLAT_IDENTIFIER,
"ivf-flat.pq.train.max-points-per-centroid",
),
- (IVF_FLAT_IDENTIFIER, "diskann.max-degree"),
- (DISKANN_IDENTIFIER, "diskann.nlist"),
- (DISKANN_IDENTIFIER, VINDEX_BUILD_GRANULE_ENABLED),
(DISKANN_IDENTIFIER, "diskann.ivf.coarse-assignment"),
(
DISKANN_IDENTIFIER,
@@ -1164,7 +1142,7 @@ mod tests {
);
assert_eq!(
options.native_options.get("nlist").map(String::as_str),
- Some("256")
+ None
);
}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6de93f0e..bd8d4a8f 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1342,13 +1342,17 @@ Supported vindex options:
|---|---:|---|---|
| `<index-type>.dimension` | `128` | all vindex types | Vector dimension for
`ARRAY<FLOAT>` columns. Existing `VECTOR<FLOAT,N>` columns use `N` from the
type. |
| `<index-type>.distance.metric` | `inner_product` | all vindex types |
Distance metric: `inner_product`, `cosine`, or `l2`. |
-| `<index-type>.nlist` | `256` | all IVF types | Number of IVF lists. DiskANN
rejects this option. |
+| `<index-type>.nlist` | automatic | all IVF types | Number of IVF lists. When
omitted or `auto`, infer it from `expected-vector-count`. DiskANN rejects this
option. |
+| `<index-type>.expected-vector-count` | unset, or inferred | all vindex types
| Expected number of vectors used for automatic sizing. When omitted, an
automatic IVF build counts the shard's non-NULL vectors before training. |
+| `<index-type>.target-recall` | unset | all vindex types | Optional tuning
objective in `[0, 1]`; IVF-PQ enables automatic OPQ at values of at least
`0.9`. |
+| `<index-type>.max-bytes-per-vector` | unset | all vindex types | Optional
persisted-size tuning objective; PQ and RQ can use it to infer encoding width. |
| `<index-type>.ivf.coarse-assignment` | `auto` | all IVF types | Build-time
list assignment mode: `auto` uses Vamana for large centroid matrices; `exact`
always uses exact assignment. |
| `<index-type>.ivf.train.max-points-per-centroid` | `256` | all IVF types |
Positive limit on coarse K-means training data: at most `nlist × value`
vectors. |
-| `<index-type>.train.sample-ratio` or `fields.<field>.train.sample-ratio` |
`1.0` | all vindex types | Fraction of shard rows selected evenly for training.
Must be in `(0, 1]`; all rows are still added to the index. The field-specific
option takes precedence. |
-| `vindex.build.granule.enabled` | `true` | all IVF types | Use the granule
build pipeline. Set to `false` to use the full-spill build path for future
index builds. |
-| `<index-type>.pq.m` | `16` | `ivf-pq` | Number of product-quantization
sub-vectors. The dimension must be divisible by this value. |
-| `<index-type>.pq.use-opq` | `false` | `ivf-pq` | Whether to enable OPQ
before PQ encoding. |
+| `<index-type>.train.sample-ratio` or `fields.<field>.train.sample-ratio` |
`1.0` | all vindex types | Fraction of non-NULL vectors selected for training.
Must be in `(0, 1]`; all non-NULL vectors are still added to the index. The
field-specific option takes precedence. |
+| `vindex.build.granule.enabled` | `true` | all IVF types | Use the granule
build pipeline when `nlist` or `expected-vector-count` is explicit. NULL
vectors switch to full spill. Set to `false` to always use full spill. |
+| `<index-type>.pq.m` | automatic | `ivf-pq` | Number of product-quantization
sub-vectors, inferred from dimension and the code budget when omitted. The
dimension must be divisible by an explicit value. |
+| `<index-type>.pq.code-ratio` | `0.0625` | `ivf-pq` | Ratio of PQ-code bytes
to raw `f32` vector bytes, in `(0, 0.25]`. An explicit `pq.m` overrides it. |
+| `<index-type>.pq.use-opq` | inferred | `ivf-pq` | Whether to enable OPQ
before PQ encoding. When omitted or `auto`, enable it if `target-recall` is at
least `0.9`; otherwise disable it. |
| `ivf-pq.ivf.pq-encoding` | `auto` | `ivf-pq` | Build-time PQ encoding mode:
`auto` selects an accelerated backend when supported; `canonical` uses the
canonical encoder. |
| `<index-type>.pq.train.max-points-per-centroid` | `256` | `ivf-pq`,
`diskann` | Positive limit on PQ training data: at most `2^pq.bits × value`
vectors per subquantizer. |
| `ivf-rq.rq.bits` | `4`, or inferred | `ivf-rq` | Residual-quantization width
in the range `1` to `8`. When omitted, `ivf-rq.max-bytes-per-vector` can select
it. |
@@ -1384,6 +1388,11 @@ vindex aliases are also accepted in the `options`
string: `dimension`, `metric`,
`diskann.*` build keys listed in the table. Build options for another index
family are rejected rather than ignored.
+NULL vector rows are skipped, while index manifest `row_count` and the global
+row range still include them. Non-NULL vectors retain their original relative
+row IDs. An all-NULL shard produces no index file. Full-spill training samples
+the compacted non-NULL vector stream at evenly spaced positions, matching Java.
+
Inspect committed index files with the `$table_indexes` system table:
```sql