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 bf1dcdd Upgrade vindex and add create index support (#476)
bf1dcdd is described below
commit bf1dcddd265c33186f1815b89e910554170315dc
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 7 21:44:49 2026 +0800
Upgrade vindex and add create index support (#476)
---
bindings/c/DEPENDENCIES.rust.tsv | 2 +-
bindings/python/DEPENDENCIES.rust.tsv | 2 +-
crates/integration_tests/DEPENDENCIES.rust.tsv | 2 +-
.../integrations/datafusion/DEPENDENCIES.rust.tsv | 2 +-
crates/integrations/datafusion/src/procedures.rs | 33 +-
.../integrations/datafusion/tests/read_tables.rs | 106 +++
crates/paimon/Cargo.toml | 2 +-
crates/paimon/DEPENDENCIES.rust.tsv | 2 +-
crates/paimon/src/table/mod.rs | 6 +
.../paimon/src/table/vindex_index_build_builder.rs | 995 +++++++++++++++++++++
crates/paimon/src/vindex/mod.rs | 518 +++++++++++
docs/src/sql.md | 179 +++-
12 files changed, 1824 insertions(+), 25 deletions(-)
diff --git a/bindings/c/DEPENDENCIES.rust.tsv b/bindings/c/DEPENDENCIES.rust.tsv
index 0d136bc..ba66630 100644
--- a/bindings/c/DEPENDENCIES.rust.tsv
+++ b/bindings/c/DEPENDENCIES.rust.tsv
@@ -173,7 +173,7 @@ [email protected] X
[email protected]
X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
diff --git a/bindings/python/DEPENDENCIES.rust.tsv
b/bindings/python/DEPENDENCIES.rust.tsv
index 91ac670..c8bc735 100644
--- a/bindings/python/DEPENDENCIES.rust.tsv
+++ b/bindings/python/DEPENDENCIES.rust.tsv
@@ -285,7 +285,7 @@ [email protected]
X
[email protected]
X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected] X
diff --git a/crates/integration_tests/DEPENDENCIES.rust.tsv
b/crates/integration_tests/DEPENDENCIES.rust.tsv
index 0e65d0e..5933f8b 100644
--- a/crates/integration_tests/DEPENDENCIES.rust.tsv
+++ b/crates/integration_tests/DEPENDENCIES.rust.tsv
@@ -173,7 +173,7 @@ [email protected] X
[email protected]
X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
diff --git a/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
b/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
index b6dd6fe..7809a72 100644
--- a/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
+++ b/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
@@ -259,7 +259,7 @@ [email protected]
X
[email protected]
X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected] X
diff --git a/crates/integrations/datafusion/src/procedures.rs
b/crates/integrations/datafusion/src/procedures.rs
index 6587983..0778839 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -24,6 +24,7 @@
//! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
//! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...',
timestamp => ...)`
//! - `CALL sys.create_global_index(table => '...', index_column => '...',
index_type => 'btree')`
+//! - `CALL sys.create_global_index(table => '...', index_column => '...',
index_type => 'ivf-pq')`
//! - `CALL sys.drop_global_index(table => '...', index_column => '...',
index_type => 'btree')`
//! - `CALL sys.create_lumina_index(table => '...', index_column => '...')`
@@ -42,6 +43,7 @@ use datafusion::sql::sqlparser::ast::{
use paimon::catalog::{Catalog, Identifier};
use paimon::spec::Snapshot;
use paimon::table::{SnapshotManager, Table, TagManager};
+use paimon::vindex::is_vindex_index_type;
use crate::error::to_datafusion_error;
@@ -543,20 +545,29 @@ async fn proc_create_global_index(
.get("index_type")
.map(String::as_str)
.unwrap_or("btree");
- if !index_type.eq_ignore_ascii_case("btree") {
+ if index_type.eq_ignore_ascii_case("btree") {
+ if args.contains_key("options") {
+ return Err(DataFusionError::NotImplemented(
+ "create_global_index options are not supported for btree
yet".to_string(),
+ ));
+ }
+
+ let mut builder = table.new_btree_global_index_build_builder();
+ builder.with_index_column(index_column);
+ builder.execute().await.map_err(to_datafusion_error)?;
+ } else if is_vindex_index_type(index_type) {
+ let mut builder = table.new_vindex_index_build_builder(index_type);
+ builder.with_index_column(index_column);
+ if let Some(options) = args.get("options") {
+ builder.with_options(parse_key_value_options(options)?);
+ }
+ builder.execute().await.map_err(to_datafusion_error)?;
+ } else {
return Err(DataFusionError::NotImplemented(format!(
- "create_global_index only supports index_type => 'btree', got
'{index_type}'"
+ "create_global_index only supports index_type => 'btree' or vindex
types \
+ ('ivf-flat', 'ivf-pq', 'ivf-hnsw-flat', 'ivf-hnsw-sq'), got
'{index_type}'"
)));
}
- if args.contains_key("options") {
- return Err(DataFusionError::NotImplemented(
- "create_global_index options are not supported for btree
yet".to_string(),
- ));
- }
-
- let mut builder = table.new_btree_global_index_build_builder();
- builder.with_index_column(index_column);
- builder.execute().await.map_err(to_datafusion_error)?;
ok_result(ctx)
}
diff --git a/crates/integrations/datafusion/tests/read_tables.rs
b/crates/integrations/datafusion/tests/read_tables.rs
index 718a1cd..f2b8894 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -1501,6 +1501,7 @@ mod vector_search_tests {
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
+ register_vector_search(ctx.ctx(), catalog.clone(), "default");
(ctx, catalog, tmp)
}
@@ -1527,6 +1528,27 @@ mod vector_search_tests {
.expect("Failed to build table schema")
}
+ fn build_vindex_table_schema() -> Schema {
+ let mut options = std::collections::HashMap::new();
+ options.insert("row-tracking.enabled".to_string(), "true".to_string());
+ options.insert("data-evolution.enabled".to_string(),
"true".to_string());
+ options.insert("global-index.enabled".to_string(), "true".to_string());
+ options.insert(
+ "global-index.row-count-per-shard".to_string(),
+ "3".to_string(),
+ );
+
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .options(options)
+ .build()
+ .expect("Failed to build table schema")
+ }
+
fn build_vector_batch(ids: Vec<i32>, vectors: Vec<Vec<f32>>) ->
RecordBatch {
let element_field = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
let mut vector_builder =
@@ -1823,6 +1845,90 @@ mod vector_search_tests {
"one same-direction neighbor should be returned, got {ids:?}"
);
}
+
+ #[tokio::test]
+ async fn test_vindex_build_then_vector_search_query() {
+ let (ctx, catalog, _tmp) = create_empty_vector_search_context().await;
+ let identifier = Identifier::new("default", "vindex_build_query_e2e");
+ catalog
+ .create_table(&identifier, build_vindex_table_schema(), false)
+ .await
+ .expect("Failed to create table");
+ let table = catalog
+ .get_table(&identifier)
+ .await
+ .expect("Failed to load table");
+
+ let write_builder = table
+ .new_write_builder()
+ .with_commit_user("test-user")
+ .expect("Failed to configure write builder");
+ let mut table_write = write_builder
+ .new_write()
+ .expect("Failed to create table write");
+ table_write
+ .write_arrow_batch(&build_vector_batch(
+ vec![0, 1, 2, 3, 4, 5],
+ vec![
+ vec![1.0, 0.0],
+ vec![0.9, 0.1],
+ vec![0.0, 1.0],
+ vec![-1.0, 0.0],
+ vec![0.0, -1.0],
+ vec![0.7, 0.3],
+ ],
+ ))
+ .await
+ .expect("Failed to write vector batch");
+ let messages = table_write
+ .prepare_commit()
+ .await
+ .expect("Failed to prepare commit");
+ write_builder
+ .new_commit()
+ .commit(messages)
+ .await
+ .expect("Failed to commit vector data");
+
+ ctx.sql(
+ "CALL sys.create_global_index( \
+ table => 'default.vindex_build_query_e2e', \
+ index_column => 'embedding', \
+ index_type => 'ivf-flat', \
+ options =>
'ivf-flat.dimension=2,ivf-flat.nlist=1,ivf-flat.distance.metric=l2')",
+ )
+ .await
+ .expect("vindex index build SQL should parse")
+ .collect()
+ .await
+ .expect("vindex index build SQL should execute");
+
+ let index_batches = ctx
+ .sql("SELECT index_type, row_count, row_range_start,
row_range_end, index_field_name FROM
paimon.default.`vindex_build_query_e2e$table_indexes` WHERE index_type =
'ivf-flat'")
+ .await
+ .expect("index metadata SQL should parse")
+ .collect()
+ .await
+ .expect("index metadata query should execute");
+ let index_rows = extract_index_rows(&index_batches);
+ assert_eq!(
+ index_rows,
+ vec![
+ ("ivf-flat".to_string(), 3, 0, 2, "embedding".to_string()),
+ ("ivf-flat".to_string(), 3, 3, 5, "embedding".to_string()),
+ ]
+ );
+
+ let search_batches = ctx
+ .sql("SELECT id FROM
vector_search('paimon.default.vindex_build_query_e2e', 'embedding', '[1.0,
0.0]', 2)")
+ .await
+ .expect("vector_search SQL should parse")
+ .collect()
+ .await
+ .expect("vector_search query should execute");
+ let ids = extract_ids(&search_batches);
+ assert_eq!(ids, vec![0, 1]);
+ }
}
// ======================= Hybrid Search Tests =======================
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 7755a8e..72fbfe9 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -105,7 +105,7 @@ urlencoding = "2.1"
tantivy = { version = "0.22", optional = true }
tempfile = { version = "3", optional = true }
paimon-mosaic-core = { version = "0.1.0", optional = true }
-paimon-vindex-core = "0.1.0"
+paimon-vindex-core = "0.2.0"
vortex = { version = "0.75.0", features = ["tokio"], optional = true }
libloading = "0.9"
# Keep CI on the dependency set that passed before unicode-segmentation 1.13.3.
diff --git a/crates/paimon/DEPENDENCIES.rust.tsv
b/crates/paimon/DEPENDENCIES.rust.tsv
index 9e06a05..7762797 100644
--- a/crates/paimon/DEPENDENCIES.rust.tsv
+++ b/crates/paimon/DEPENDENCIES.rust.tsv
@@ -220,7 +220,7 @@ [email protected] X
[email protected]
X
[email protected]
X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index edc3719..9e6b623 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -66,6 +66,7 @@ pub(crate) mod table_write;
mod tag_manager;
pub(crate) mod time_travel;
mod vector_search_builder;
+mod vindex_index_build_builder;
mod write_builder;
use crate::Result;
@@ -99,6 +100,7 @@ pub use table_update::TableUpdate;
pub use table_write::TableWrite;
pub use tag_manager::TagManager;
pub use vector_search_builder::{BatchVectorSearchBuilder, VectorSearchBuilder};
+pub use vindex_index_build_builder::VindexIndexBuildBuilder;
pub use write_builder::WriteBuilder;
use crate::catalog::Identifier;
@@ -218,6 +220,10 @@ impl Table {
BTreeGlobalIndexDropBuilder::new(self)
}
+ pub fn new_vindex_index_build_builder(&self, index_type: &str) ->
VindexIndexBuildBuilder<'_> {
+ VindexIndexBuildBuilder::new(self, index_type)
+ }
+
/// Create a write builder for write/commit.
///
/// Reference: [pypaimon
FileStoreTable.new_write_builder](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/table/file_store_table.py).
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
new file mode 100644
index 0000000..7985411
--- /dev/null
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -0,0 +1,995 @@
+// 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 crate::spec::{
+ bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta,
DataType, FileKind,
+ GlobalIndexMeta, IndexFileMeta, IndexManifest, ROW_ID_FIELD_NAME,
+};
+use crate::table::{
+ CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table,
TableCommit,
+};
+use crate::vindex::{is_vindex_index_type, VindexVectorIndexOptions};
+use crate::{Error, Result};
+use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array,
ListArray, RecordBatch};
+use bytes::Bytes;
+use futures::TryStreamExt;
+use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer,
VectorIndexWriter};
+use paimon_vindex_core::io::PosWriter;
+use std::collections::HashMap;
+
+const INDEX_DIR: &str = "index";
+
+pub struct VindexIndexBuildBuilder<'a> {
+ table: &'a Table,
+ index_column: Option<String>,
+ index_type: String,
+ options: HashMap<String, String>,
+}
+
+impl<'a> VindexIndexBuildBuilder<'a> {
+ pub(crate) fn new(table: &'a Table, index_type: &str) -> Self {
+ Self {
+ table,
+ index_column: None,
+ index_type: index_type.to_string(),
+ options: HashMap::new(),
+ }
+ }
+
+ pub fn with_index_column(&mut self, column: &str) -> &mut Self {
+ self.index_column = Some(column.to_string());
+ self
+ }
+
+ pub fn with_options(&mut self, options: HashMap<String, String>) -> &mut
Self {
+ self.options = options;
+ self
+ }
+
+ pub async fn execute(&self) -> Result<usize> {
+ if !is_vindex_index_type(&self.index_type) {
+ return Err(Error::DataInvalid {
+ message: format!("Unsupported vindex index type: {}",
self.index_type),
+ source: None,
+ });
+ }
+
+ let index_column = self
+ .index_column
+ .as_deref()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex index column is required".to_string(),
+ source: None,
+ })?;
+
+ let core_options = CoreOptions::new(self.table.schema().options());
+ validate_table_options(self.table, &core_options)?;
+ let rows_per_shard = core_options.global_index_row_count_per_shard()?;
+
+ let index_field = find_index_field(self.table, index_column)?;
+ validate_vector_field(index_field)?;
+ let vindex_options = VindexVectorIndexOptions::new(
+ self.table.schema().options(),
+ &self.options,
+ &self.index_type,
+ index_field,
+ )?;
+ let dimension = checked_i32(
+ vindex_options.dimension() as u64,
+ "vindex dimension is too large for Rust builder",
+ )?;
+ let index_meta =
+ serde_json::to_vec(&vindex_options.native_options).map_err(|e|
Error::DataInvalid {
+ message: format!("Failed to serialize vindex options metadata:
{e}"),
+ source: Some(Box::new(e)),
+ })?;
+
+ let snapshot_manager = SnapshotManager::new(
+ self.table.file_io().clone(),
+ self.table.location().to_string(),
+ );
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await?
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Cannot build vindex index without a
snapshot".to_string(),
+ source: None,
+ })?;
+
+ let manifest_entries = self
+ .table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await?;
+ let shards = plan_vindex_shards(
+ self.table.location(),
+ self.table.schema().partition_keys(),
+ self.table.schema().fields(),
+ &core_options,
+ snapshot.id(),
+ manifest_entries,
+ rows_per_shard,
+ )?;
+ if shards.is_empty() {
+ return Ok(0);
+ }
+
+ validate_existing_index_overlap(
+ self.table,
+ snapshot.index_manifest(),
+ index_field.id(),
+ &shards,
+ )
+ .await?;
+
+ let shard_count = shards.len();
+ let mut messages = Vec::with_capacity(shard_count);
+ for shard in shards {
+ let vectors = extract_vectors(self.table, &shard, index_column,
dimension).await?;
+ let index_file = self
+ .build_index_file(
+ &shard,
+ &vectors,
+ dimension,
+ index_field.id(),
+ vindex_options.config.clone(),
+ index_meta.clone(),
+ )
+ .await?;
+ let mut message =
CommitMessage::new(shard.partition_bytes.clone(), 0, vec![]);
+ message.new_index_files = vec![index_file];
+ messages.push(message);
+ }
+
+ TableCommit::new(
+ self.table.clone(),
+ format!(
+ "global-index-{}-create-{}",
+ self.index_type,
+ uuid::Uuid::new_v4()
+ ),
+ )
+ .commit_if_latest_snapshot(messages, snapshot.id())
+ .await?;
+
+ Ok(shard_count)
+ }
+
+ async fn build_index_file(
+ &self,
+ shard: &VindexIndexShard,
+ vectors: &[f32],
+ dimension: i32,
+ index_field_id: i32,
+ config: VectorIndexConfig,
+ index_meta: Vec<u8>,
+ ) -> Result<IndexFileMeta> {
+ let row_count = checked_row_count(shard.row_range_start,
shard.row_range_end)?;
+ validate_vector_buffer(vectors, row_count, dimension)?;
+ let row_count_usize = usize::try_from(row_count).map_err(|e|
Error::DataInvalid {
+ message: format!("Invalid vindex row count: {row_count}"),
+ source: Some(Box::new(e)),
+ })?;
+ let ids = (0..i64::from(row_count)).collect::<Vec<_>>();
+
+ let training =
+ VectorIndexTrainer::train(config, vectors,
row_count_usize).map_err(|e| {
+ Error::DataInvalid {
+ message: format!("Failed to train vindex index: {e}"),
+ source: Some(Box::new(e)),
+ }
+ })?;
+ let mut writer = VectorIndexWriter::new(training);
+ writer
+ .add_vectors(&ids, vectors, row_count_usize)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to add vectors to vindex index: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ let mut bytes = Vec::new();
+ {
+ let mut output = PosWriter::new(&mut bytes);
+ writer.write(&mut output).map_err(|e| Error::DataInvalid {
+ message: format!("Failed to serialize vindex index: {e}"),
+ source: Some(Box::new(e)),
+ })?;
+ }
+
+ self.table
+ .file_io()
+ .mkdirs(&format!(
+ "{}/{INDEX_DIR}/",
+ self.table.location().trim_end_matches('/')
+ ))
+ .await?;
+ let file_name = format!(
+ "vector-{}-global-index-{}.index",
+ self.index_type,
+ uuid::Uuid::new_v4()
+ );
+ let index_path = format!(
+ "{}/{INDEX_DIR}/{}",
+ self.table.location().trim_end_matches('/'),
+ file_name
+ );
+ self.table
+ .file_io()
+ .new_output(&index_path)?
+ .write(Bytes::from(bytes))
+ .await?;
+
+ let status = self.table.file_io().get_status(&index_path).await?;
+ Ok(IndexFileMeta {
+ index_type: self.index_type.clone(),
+ file_name,
+ file_size: checked_i32(
+ status.size,
+ "Index file is too large for Rust IndexFileMeta",
+ )?,
+ row_count,
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: shard.row_range_start,
+ row_range_end: shard.row_range_end,
+ index_field_id,
+ extra_field_ids: None,
+ index_meta: Some(index_meta),
+ }),
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct VindexIndexShard {
+ pub partition: BinaryRow,
+ pub partition_bytes: Vec<u8>,
+ pub files: Vec<DataFileMeta>,
+ pub row_range_start: i64,
+ pub row_range_end: i64,
+ snapshot_id: i64,
+ source_bucket: i32,
+ total_buckets: i32,
+ bucket_path: String,
+}
+
+fn validate_table_options(table: &Table, core_options: &CoreOptions) ->
Result<()> {
+ if !core_options.row_tracking_enabled() {
+ return Err(Error::DataInvalid {
+ message: "vindex index build requires 'row-tracking.enabled' =
'true'".to_string(),
+ source: None,
+ });
+ }
+ if !core_options.data_evolution_enabled() {
+ return Err(Error::DataInvalid {
+ message: "vindex index build requires 'data-evolution.enabled' =
'true'".to_string(),
+ source: None,
+ });
+ }
+ if !core_options.global_index_enabled() {
+ return Err(Error::DataInvalid {
+ message: "vindex index build requires 'global-index.enabled' =
'true'".to_string(),
+ source: None,
+ });
+ }
+ if !table.schema().primary_keys().is_empty() {
+ return Err(Error::Unsupported {
+ message: "vindex index build does not support primary-key
tables".to_string(),
+ });
+ }
+ if core_options.deletion_vectors_enabled() {
+ return Err(Error::Unsupported {
+ message:
+ "vindex index build does not support tables with
deletion-vectors.enabled=true"
+ .to_string(),
+ });
+ }
+ Ok(())
+}
+
+fn find_index_field<'a>(table: &'a Table, column: &str) -> Result<&'a
DataField> {
+ table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == column)
+ .ok_or_else(|| Error::ColumnNotExist {
+ full_name: table.identifier().full_name(),
+ column: column.to_string(),
+ })
+}
+
+fn validate_vector_field(field: &DataField) -> Result<()> {
+ 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!(
+ "vindex index requires ARRAY<FLOAT> or VECTOR<FLOAT> column,
got {:?} for column '{}'",
+ field.data_type(),
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ Ok(())
+}
+
+fn plan_vindex_shards(
+ table_location: &str,
+ partition_keys: &[String],
+ schema_fields: &[DataField],
+ core_options: &CoreOptions,
+ snapshot_id: i64,
+ entries: Vec<crate::spec::ManifestEntry>,
+ rows_per_shard: i64,
+) -> Result<Vec<VindexIndexShard>> {
+ if rows_per_shard <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Option 'global-index.row-count-per-shard' must be greater
than 0, got: {rows_per_shard}"
+ ),
+ source: None,
+ });
+ }
+
+ let mut by_partition_bucket: HashMap<(Vec<u8>, i32, i32),
Vec<DataFileMeta>> = HashMap::new();
+ for entry in entries {
+ if *entry.kind() != FileKind::Add {
+ continue;
+ }
+ if entry.file().first_row_id.is_none() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete vindex index",
+ entry.file().file_name
+ ),
+ source: None,
+ });
+ }
+ let (partition, bucket, total_buckets, file) = entry.into_parts();
+ by_partition_bucket
+ .entry((partition, bucket, total_buckets))
+ .or_default()
+ .push(file);
+ }
+
+ let mut result = Vec::new();
+ for ((partition_bytes, source_bucket, total_buckets), files) in
by_partition_bucket {
+ let partition = if partition_keys.is_empty() {
+ BinaryRow::new(0)
+ } else {
+ BinaryRow::from_serialized_bytes(&partition_bytes)?
+ };
+ let bucket_path = bucket_path(
+ table_location,
+ partition_keys,
+ schema_fields,
+ core_options,
+ &partition,
+ source_bucket,
+ )?;
+ let mut files_by_shard: HashMap<i64, Vec<DataFileMeta>> =
HashMap::new();
+ for file in files {
+ let (file_start, file_end) = file.row_id_range().ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete vindex index",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ let start_shard = file_start / rows_per_shard;
+ let end_shard = file_end / rows_per_shard;
+ for shard_id in start_shard..=end_shard {
+ files_by_shard
+ .entry(shard_id * rows_per_shard)
+ .or_default()
+ .push(file.clone());
+ }
+ }
+
+ let mut shard_starts =
files_by_shard.keys().copied().collect::<Vec<_>>();
+ shard_starts.sort_unstable();
+ for shard_start in shard_starts {
+ let shard_end = shard_start + rows_per_shard - 1;
+ let mut shard_files =
files_by_shard.remove(&shard_start).unwrap_or_default();
+ shard_files.sort_by_key(|file| file.first_row_id);
+ let groups = group_contiguous_files(shard_files)?;
+ for group in groups {
+ let group_start = group
+ .first()
+ .and_then(|file| file.first_row_id)
+ .expect("planned groups are non-empty and row-id
assigned");
+ let group_end = group
+ .iter()
+ .map(|file| file.row_id_range().unwrap().1)
+ .max()
+ .unwrap();
+ let row_range_start = group_start.max(shard_start);
+ let row_range_end = group_end.min(shard_end);
+ result.push(VindexIndexShard {
+ partition: partition.clone(),
+ partition_bytes: partition_bytes.clone(),
+ files: group,
+ row_range_start,
+ row_range_end,
+ snapshot_id,
+ source_bucket,
+ total_buckets,
+ bucket_path: bucket_path.clone(),
+ });
+ }
+ }
+ }
+ result.sort_by(|a, b| {
+ a.partition
+ .to_serialized_bytes()
+ .cmp(&b.partition.to_serialized_bytes())
+ .then(a.source_bucket.cmp(&b.source_bucket))
+ .then(a.row_range_start.cmp(&b.row_range_start))
+ });
+ Ok(result)
+}
+
+fn group_contiguous_files(mut files: Vec<DataFileMeta>) ->
Result<Vec<Vec<DataFileMeta>>> {
+ if files.is_empty() {
+ return Ok(Vec::new());
+ }
+ files.sort_by_key(|file| file.first_row_id);
+ let mut groups = Vec::new();
+ let mut current = Vec::new();
+ let mut current_end = None;
+ for file in files {
+ let (file_start, file_end) = file.row_id_range().ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete vindex index",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ match current_end {
+ None => {
+ current.push(file);
+ current_end = Some(file_end);
+ }
+ Some(end) if file_start <= end + 1 => {
+ current.push(file);
+ current_end = Some(end.max(file_end));
+ }
+ Some(_) => {
+ groups.push(std::mem::take(&mut current));
+ current.push(file);
+ current_end = Some(file_end);
+ }
+ }
+ }
+ if !current.is_empty() {
+ groups.push(current);
+ }
+ Ok(groups)
+}
+
+fn bucket_path(
+ table_location: &str,
+ partition_keys: &[String],
+ schema_fields: &[DataField],
+ core_options: &CoreOptions,
+ partition: &BinaryRow,
+ bucket: i32,
+) -> Result<String> {
+ let base = table_location.trim_end_matches('/');
+ if partition_keys.is_empty() {
+ return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
+ }
+ let computer = crate::spec::PartitionComputer::new(
+ partition_keys,
+ schema_fields,
+ core_options.partition_default_name(),
+ core_options.legacy_partition_name(),
+ )?;
+ Ok(format!(
+ "{base}/{}{}",
+ computer.generate_partition_path(partition)?,
+ bucket_dir_name(bucket)
+ ))
+}
+
+async fn validate_existing_index_overlap(
+ table: &Table,
+ index_manifest_name: Option<&str>,
+ index_field_id: i32,
+ shards: &[VindexIndexShard],
+) -> Result<()> {
+ let Some(index_manifest_name) = index_manifest_name else {
+ return Ok(());
+ };
+ let path = format!(
+ "{}/manifest/{}",
+ table.location().trim_end_matches('/'),
+ index_manifest_name
+ );
+ let entries = IndexManifest::read(table.file_io(), &path).await?;
+ for entry in entries {
+ if entry.kind != FileKind::Add {
+ continue;
+ }
+ let Some(meta) = entry.index_file.global_index_meta else {
+ continue;
+ };
+ if meta.index_field_id != index_field_id {
+ continue;
+ }
+ if shards.iter().any(|shard| {
+ ranges_overlap(
+ meta.row_range_start,
+ meta.row_range_end,
+ shard.row_range_start,
+ shard.row_range_end,
+ )
+ }) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Existing global index file '{}' overlaps requested row
range for field {}",
+ entry.index_file.file_name, index_field_id
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+}
+
+async fn extract_vectors(
+ table: &Table,
+ shard: &VindexIndexShard,
+ index_column: &str,
+ dimension: i32,
+) -> Result<Vec<f32>> {
+ let split = DataSplitBuilder::new()
+ .with_snapshot(shard.snapshot_id)
+ .with_partition(shard.partition.clone())
+ .with_bucket(shard.source_bucket)
+ .with_bucket_path(shard.bucket_path.clone())
+ .with_total_buckets(shard.total_buckets)
+ .with_data_files(shard.files.clone())
+ .with_row_ranges(vec![RowRange::new(
+ shard.row_range_start,
+ shard.row_range_end,
+ )])
+ .build()?;
+
+ let mut read_builder = table.new_read_builder();
+ read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME])?;
+ let read = read_builder.new_read()?;
+ let batches = read.to_arrow(&[split])?.try_collect::<Vec<_>>().await?;
+ extract_vectors_from_batches(
+ &batches,
+ index_column,
+ dimension,
+ shard.row_range_start,
+ i64::from(checked_row_count(
+ shard.row_range_start,
+ shard.row_range_end,
+ )?),
+ )
+}
+
+fn extract_vectors_from_batches(
+ batches: &[RecordBatch],
+ index_column: &str,
+ dimension: i32,
+ row_range_start: i64,
+ expected_row_count: i64,
+) -> Result<Vec<f32>> {
+ let dimension = usize::try_from(dimension).map_err(|e| Error::DataInvalid {
+ message: format!("Invalid vindex dimension: {dimension}"),
+ source: Some(Box::new(e)),
+ })?;
+ let row_count = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
+ let mut vectors = Vec::with_capacity(row_count * dimension);
+ let mut expected_row_id = row_range_start;
+ for batch in batches {
+ let vector_index =
+ batch
+ .schema()
+ .index_of(index_column)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Vector column '{index_column}' not found
in read batch: {e}"),
+ source: None,
+ })?;
+ let row_id_index =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("_ROW_ID column not found in read batch:
{e}"),
+ source: None,
+ })?;
+ 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:
+ "vindex 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: "vindex vector extraction requires Float32 vector
elements".to_string(),
+ 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,
+ })?;
+
+ for row in 0..batch.num_rows() {
+ if row_ids.is_null(row) {
+ return Err(Error::DataInvalid {
+ message: "vindex vector extraction found null
_ROW_ID".to_string(),
+ source: None,
+ });
+ }
+ let row_id = row_ids.value(row);
+ if row_id != expected_row_id {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex vector extraction expected _ROW_ID {}, got {}",
+ expected_row_id, row_id
+ ),
+ source: None,
+ });
+ }
+ expected_row_id += 1;
+
+ 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: "vindex vector extraction found null vector
row".to_string(),
+ source: None,
+ });
+ }
+ 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!(
+ "vindex vector dimension mismatch: expected {}, got
{}",
+ dimension,
+ end - start
+ ),
+ source: None,
+ });
+ }
+ for value_index in start..end {
+ if values.is_null(value_index) {
+ return Err(Error::DataInvalid {
+ message: "vindex vector extraction found null vector
element".to_string(),
+ source: None,
+ });
+ }
+ vectors.push(values.value(value_index));
+ }
+ }
+ }
+ let actual_row_count = expected_row_id - row_range_start;
+ if actual_row_count != expected_row_count {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex vector extraction expected {} rows, got {}",
+ expected_row_count, actual_row_count
+ ),
+ source: None,
+ });
+ }
+ Ok(vectors)
+}
+
+fn checked_i32(value: u64, context: &str) -> Result<i32> {
+ i32::try_from(value).map_err(|_| Error::DataInvalid {
+ message: format!("{context}: {value}"),
+ source: None,
+ })
+}
+
+fn checked_row_count(row_range_start: i64, row_range_end: i64) -> Result<i32> {
+ if row_range_end < row_range_start {
+ return Err(Error::DataInvalid {
+ message: format!("Invalid vindex row range [{row_range_start},
{row_range_end}]"),
+ source: None,
+ });
+ }
+ i32::try_from(row_range_end - row_range_start + 1).map_err(|_|
Error::DataInvalid {
+ message: format!(
+ "vindex row count is too large for Rust IndexFileMeta:
[{row_range_start}, {row_range_end}]"
+ ),
+ source: None,
+ })
+}
+
+fn validate_vector_buffer(vectors: &[f32], row_count: i32, dimension: i32) ->
Result<()> {
+ if row_count <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!("vindex shard row count must be positive, got:
{row_count}"),
+ source: None,
+ });
+ }
+ if dimension <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!("vindex vector dimension must be positive, got:
{dimension}"),
+ source: None,
+ });
+ }
+ let row_count = row_count as usize;
+ let dimension = dimension as usize;
+ let expected_len = row_count
+ .checked_mul(dimension)
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "vindex vector buffer length overflows: row_count={row_count},
dimension={dimension}"
+ ),
+ source: None,
+ })?;
+ if vectors.len() != expected_len {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "vindex vector buffer length {} does not match row_count={}
and dimension={}",
+ vectors.len(),
+ row_count,
+ dimension
+ ),
+ source: None,
+ });
+ }
+ Ok(())
+}
+
+fn ranges_overlap(left_start: i64, left_end: i64, right_start: i64, right_end:
i64) -> bool {
+ left_start <= right_end && right_start <= left_end
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::catalog::Identifier;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{ArrayType, FloatType, IntType, ManifestEntry, Schema,
TableSchema};
+ use arrow_array::builder::{Float32Builder, Int64Builder, ListBuilder};
+ use arrow_array::ArrayRef;
+ use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
+ use chrono::{DateTime, Utc};
+ use std::sync::Arc;
+
+ fn data_file(name: &str, first_row_id: Option<i64>, row_count: i64) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count,
+ min_key: vec![],
+ max_key: vec![],
+ key_stats: BinaryTableStats::new(vec![], vec![], vec![]),
+ value_stats: BinaryTableStats::new(vec![], vec![], vec![]),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 0,
+ level: 0,
+ extra_files: vec![],
+ creation_time: Some(
+ "2024-09-06T07:45:55.039+00:00"
+ .parse::<DateTime<Utc>>()
+ .unwrap(),
+ ),
+ delete_row_count: None,
+ embedded_index: None,
+ first_row_id,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ fn manifest_entry(file: DataFileMeta) -> ManifestEntry {
+ ManifestEntry::new(FileKind::Add, vec![], 0, 1, file, 2)
+ }
+
+ fn table_options(rows_per_shard: &str) -> HashMap<String, String> {
+ HashMap::from([
+ ("row-tracking.enabled".to_string(), "true".to_string()),
+ ("data-evolution.enabled".to_string(), "true".to_string()),
+ ("global-index.enabled".to_string(), "true".to_string()),
+ (
+ "global-index.row-count-per-shard".to_string(),
+ rows_per_shard.to_string(),
+ ),
+ ])
+ }
+
+ fn test_table(options: HashMap<String, String>) -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ .options(options)
+ .build()
+ .unwrap();
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "test_table"),
+ "memory:/test_vindex_builder".to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+ }
+
+ fn plan(entries: Vec<ManifestEntry>, rows_per_shard: i64) ->
Result<Vec<VindexIndexShard>> {
+ let table = test_table(table_options(&rows_per_shard.to_string()));
+ let core = CoreOptions::new(table.schema().options());
+ plan_vindex_shards(
+ table.location(),
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ &core,
+ 1,
+ entries,
+ rows_per_shard,
+ )
+ }
+
+ #[test]
+ fn test_planner_splits_single_file_across_shards() {
+ let shards = plan(vec![manifest_entry(data_file("a", Some(0), 25))],
10).unwrap();
+
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (s.row_range_start, s.row_range_end))
+ .collect::<Vec<_>>(),
+ vec![(0, 9), (10, 19), (20, 24)]
+ );
+ }
+
+ #[test]
+ fn test_planner_rejects_missing_first_row_id() {
+ let err = plan(vec![manifest_entry(data_file("a", None, 5))], 10)
+ .expect_err("missing first_row_id should fail");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("missing first_row_id"))
+ );
+ }
+
+ #[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());
+ }
+
+ fn vector_batch(rows: Vec<Option<Vec<Option<f32>>>>, row_ids:
Vec<Option<i64>>) -> RecordBatch {
+ let mut vector_builder = ListBuilder::new(Float32Builder::new());
+ for row in rows {
+ match row {
+ Some(values) => {
+ for value in values {
+ match value {
+ Some(value) =>
vector_builder.values().append_value(value),
+ None => vector_builder.values().append_null(),
+ }
+ }
+ vector_builder.append(true);
+ }
+ None => vector_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::List(Arc::new(ArrowField::new(
+ "item",
+ ArrowDataType::Float32,
+ true,
+ ))),
+ true,
+ ),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(vector_builder.finish()) as ArrayRef,
+ Arc::new(row_id_builder.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn test_extract_vectors_accepts_list_float32_and_row_ids() {
+ let batch = vector_batch(
+ vec![
+ Some(vec![Some(1.0), Some(2.0)]),
+ Some(vec![Some(3.0), Some(4.0)]),
+ ],
+ vec![Some(10), Some(11)],
+ );
+
+ 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_rejects_dimension_mismatch() {
+ let batch = vector_batch(vec![Some(vec![Some(1.0)])], vec![Some(0)]);
+
+ 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"))
+ );
+ }
+}
diff --git a/crates/paimon/src/vindex/mod.rs b/crates/paimon/src/vindex/mod.rs
index aa4f7b8..c0d566a 100644
--- a/crates/paimon/src/vindex/mod.rs
+++ b/crates/paimon/src/vindex/mod.rs
@@ -17,11 +17,21 @@
pub mod reader;
+use crate::spec::{DataField, DataType};
+use paimon_vindex_core::index::VectorIndexConfig;
+use std::collections::HashMap;
+
pub const IVF_FLAT_IDENTIFIER: &str = "ivf-flat";
pub const IVF_PQ_IDENTIFIER: &str = "ivf-pq";
pub const IVF_HNSW_FLAT_IDENTIFIER: &str = "ivf-hnsw-flat";
pub const IVF_HNSW_SQ_IDENTIFIER: &str = "ivf-hnsw-sq";
+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";
+
pub fn is_vindex_index_type(index_type: &str) -> bool {
matches!(
index_type,
@@ -29,9 +39,294 @@ pub fn is_vindex_index_type(index_type: &str) -> bool {
)
}
+pub(crate) fn native_index_type(index_type: &str) -> Option<&'static str> {
+ match index_type {
+ IVF_FLAT_IDENTIFIER => Some("ivf_flat"),
+ IVF_PQ_IDENTIFIER => Some("ivf_pq"),
+ IVF_HNSW_FLAT_IDENTIFIER => Some("ivf_hnsw_flat"),
+ IVF_HNSW_SQ_IDENTIFIER => Some("ivf_hnsw_sq"),
+ _ => None,
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct VindexVectorIndexOptions {
+ pub config: VectorIndexConfig,
+ pub native_options: HashMap<String, String>,
+}
+
+impl VindexVectorIndexOptions {
+ pub fn new(
+ table_options: &HashMap<String, String>,
+ user_options: &HashMap<String, String>,
+ index_type: &str,
+ field: &DataField,
+ ) -> crate::Result<Self> {
+ let native_index_type =
+ native_index_type(index_type).ok_or_else(||
crate::Error::DataInvalid {
+ message: format!("Unsupported vindex index type:
{index_type}"),
+ source: None,
+ })?;
+
+ validate_user_option_keys(user_options, index_type, field.name())?;
+ validate_index_type_option(table_options, 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)?,
+ );
+ 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,
+ ),
+ );
+ }
+
+ for key in ["hnsw.m", "hnsw.ef-construction", "hnsw.max-level"] {
+ 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)),
+ }
+ })?;
+ Ok(Self {
+ config,
+ native_options,
+ })
+ }
+
+ pub fn dimension(&self) -> usize {
+ self.config.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
+ ),
+ });
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_user_option_keys(
+ user_options: &HashMap<String, String>,
+ index_type: &str,
+ field_name: &str,
+) -> crate::Result<()> {
+ let mut unknown = user_options
+ .keys()
+ .filter(|key| !is_supported_user_option_key(key, index_type,
field_name))
+ .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.join(", ")
+ ),
+ })
+}
+
+fn is_supported_user_option_key(key: &str, index_type: &str, field_name: &str)
-> bool {
+ if key == "index.type" {
+ return true;
+ }
+ if is_allowed_native_key(key, index_type) {
+ return true;
+ }
+
+ let index_prefix = format!("{index_type}.");
+ if let Some(suffix) = key.strip_prefix(&index_prefix) {
+ return is_allowed_paimon_suffix(suffix, index_type);
+ }
+
+ let field_prefix = format!("fields.{field_name}.");
+ if let Some(suffix) = key.strip_prefix(&field_prefix) {
+ return is_allowed_paimon_suffix(suffix, index_type);
+ }
+
+ false
+}
+
+fn is_allowed_native_key(key: &str, index_type: &str) -> bool {
+ match key {
+ "dimension" | "nlist" | "metric" => true,
+ "pq.m" | "use-opq" => index_type == IVF_PQ_IDENTIFIER,
+ "hnsw.m" | "hnsw.ef-construction" | "hnsw.max-level" => {
+ index_type == IVF_HNSW_FLAT_IDENTIFIER || index_type ==
IVF_HNSW_SQ_IDENTIFIER
+ }
+ _ => false,
+ }
+}
+
+fn is_allowed_paimon_suffix(suffix: &str, index_type: &str) -> bool {
+ match suffix {
+ "dimension" | "nlist" | "distance.metric" => true,
+ "pq.m" | "pq.use-opq" => index_type == IVF_PQ_IDENTIFIER,
+ "hnsw.m" | "hnsw.ef-construction" | "hnsw.max-level" => {
+ index_type == IVF_HNSW_FLAT_IDENTIFIER || index_type ==
IVF_HNSW_SQ_IDENTIFIER
+ }
+ _ => false,
+ }
+}
+
+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('-', "_")
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use crate::spec::{ArrayType, FloatType, VectorType};
+
+ fn array_float_field() -> DataField {
+ DataField::new(
+ 7,
+ "embedding".to_string(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )
+ }
#[test]
fn test_vindex_index_type_identifier_helper() {
@@ -44,4 +339,227 @@ mod tests {
assert!(!is_vindex_index_type("lumina"));
assert!(!is_vindex_index_type("IVF-FLAT"));
}
+
+ #[test]
+ fn test_vindex_options_map_java_prefixed_keys_to_native_config() {
+ let table_options = HashMap::new();
+ let user_options = HashMap::from([
+ ("ivf-pq.dimension".to_string(), "8".to_string()),
+ ("ivf-pq.nlist".to_string(), "4".to_string()),
+ ("ivf-pq.distance.metric".to_string(), "cosine".to_string()),
+ ("ivf-pq.pq.m".to_string(), "2".to_string()),
+ ("ivf-pq.pq.use-opq".to_string(), "true".to_string()),
+ ]);
+
+ let options = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_PQ_IDENTIFIER,
+ &array_float_field(),
+ )
+ .unwrap();
+
+ assert_eq!(options.dimension(), 8);
+ assert_eq!(
+ options.native_options.get("index.type").map(String::as_str),
+ Some("ivf_pq")
+ );
+ assert_eq!(
+ options.native_options.get("metric").map(String::as_str),
+ Some("cosine")
+ );
+ assert_eq!(
+ options.native_options.get("pq.m").map(String::as_str),
+ Some("2")
+ );
+ assert_eq!(
+ options.native_options.get("use-opq").map(String::as_str),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn test_vindex_options_field_options_override_shared_table_options() {
+ let table_options = HashMap::from([
+ ("ivf-flat.dimension".to_string(), "8".to_string()),
+ ("ivf-flat.nlist".to_string(), "4".to_string()),
+ ("fields.embedding.nlist".to_string(), "2".to_string()),
+ ]);
+ let user_options = HashMap::new();
+
+ let options = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_FLAT_IDENTIFIER,
+ &array_float_field(),
+ )
+ .unwrap();
+
+ assert_eq!(
+ options.native_options.get("nlist").map(String::as_str),
+ Some("2")
+ );
+ }
+
+ #[test]
+ fn test_vindex_options_vector_type_uses_type_dimension() {
+ let field = DataField::new(
+ 8,
+ "embedding".to_string(),
+ DataType::Vector(
+ VectorType::try_new(true, 16,
DataType::Float(FloatType::new())).unwrap(),
+ ),
+ );
+ let table_options = HashMap::from([
+ ("ivf-flat.dimension".to_string(), "128".to_string()),
+ ("ivf-flat.nlist".to_string(), "4".to_string()),
+ ]);
+ let user_options = HashMap::new();
+
+ let options = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_FLAT_IDENTIFIER,
+ &field,
+ )
+ .unwrap();
+
+ assert_eq!(options.dimension(), 16);
+ assert_eq!(
+ options.native_options.get("dimension").map(String::as_str),
+ Some("16")
+ );
+ }
+
+ #[test]
+ fn test_vindex_options_reject_mismatched_native_index_type() {
+ let table_options = HashMap::new();
+ let user_options = HashMap::from([
+ ("index.type".to_string(), "ivf_flat".to_string()),
+ ("dimension".to_string(), "8".to_string()),
+ ("nlist".to_string(), "4".to_string()),
+ ]);
+
+ let err = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_PQ_IDENTIFIER,
+ &array_float_field(),
+ )
+ .expect_err("mismatched index.type should be rejected");
+
+ assert!(matches!(err, crate::Error::ConfigInvalid { .. }));
+ }
+
+ #[test]
+ fn test_vindex_options_reject_invalid_pq_config() {
+ let table_options = HashMap::new();
+ let user_options = HashMap::from([
+ ("ivf-pq.dimension".to_string(), "7".to_string()),
+ ("ivf-pq.nlist".to_string(), "4".to_string()),
+ ("ivf-pq.pq.m".to_string(), "2".to_string()),
+ ]);
+
+ let err = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_PQ_IDENTIFIER,
+ &array_float_field(),
+ )
+ .expect_err("invalid native config should be rejected");
+
+ assert!(
+ matches!(err, crate::Error::DataInvalid { message, .. } if
message.contains("dimension 7 must be divisible by m 2"))
+ );
+ }
+
+ #[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,
+ 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"))
+ );
+ }
+
+ #[test]
+ fn test_vindex_options_reject_non_applicable_user_options() {
+ let table_options = HashMap::new();
+ let user_options = HashMap::from([
+ ("ivf-flat.dimension".to_string(), "8".to_string()),
+ ("ivf-flat.nlist".to_string(), "4".to_string()),
+ ("ivf-flat.pq.m".to_string(), "2".to_string()),
+ ]);
+
+ let err = VindexVectorIndexOptions::new(
+ &table_options,
+ &user_options,
+ IVF_FLAT_IDENTIFIER,
+ &array_float_field(),
+ )
+ .expect_err("non-applicable user option should be rejected");
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("ivf-flat.pq.m"))
+ );
+ }
+
+ #[test]
+ fn test_vindex_options_defaults_align_java_docs() {
+ let options = VindexVectorIndexOptions::new(
+ &HashMap::new(),
+ &HashMap::new(),
+ IVF_FLAT_IDENTIFIER,
+ &array_float_field(),
+ )
+ .unwrap();
+
+ assert_eq!(
+ options.native_options.get("dimension").map(String::as_str),
+ Some("128")
+ );
+ assert_eq!(
+ options.native_options.get("metric").map(String::as_str),
+ Some("inner_product")
+ );
+ assert_eq!(
+ options.native_options.get("nlist").map(String::as_str),
+ Some("256")
+ );
+ }
+
+ #[test]
+ fn test_native_index_type_helper() {
+ assert_eq!(native_index_type(IVF_FLAT_IDENTIFIER), Some("ivf_flat"));
+ assert_eq!(native_index_type(IVF_PQ_IDENTIFIER), Some("ivf_pq"));
+ assert_eq!(
+ native_index_type(IVF_HNSW_FLAT_IDENTIFIER),
+ Some("ivf_hnsw_flat")
+ );
+ assert_eq!(
+ native_index_type(IVF_HNSW_SQ_IDENTIFIER),
+ Some("ivf_hnsw_sq")
+ );
+ assert_eq!(native_index_type("btree"), None);
+ }
+
+ #[test]
+ fn test_array_field_helper_is_not_vector() {
+ assert!(matches!(
+ array_float_field().data_type(),
+ DataType::Array(_)
+ ));
+ }
}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 70b8099..b97a654 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -105,6 +105,12 @@ The following SQL data types are supported in CREATE TABLE
and mapped to their c
| `MAP(key, value)` | MapType | e.g. `MAP(STRING, INT)` |
| `STRUCT<field TYPE, ...>` | RowType | e.g. `STRUCT<city STRING, zip INT>` |
+For vector search tables created from SQL, use `ARRAY<FLOAT>` for embedding
+columns. Existing Paimon tables may also expose logical `VECTOR<FLOAT,N>`
+columns; DataFusion reads those as Arrow `FixedSizeList<Float32>`, and vindex
+index creation uses `N` as the vector dimension. `SHOW CREATE TABLE` currently
+does not round-trip `VECTOR` columns.
+
### Variant Usage
`VARIANT` stores semi-structured data using the same logical value + metadata
binary shape as Paimon Java. Use it for JSON-like fields whose schema may
differ row by row.
@@ -632,6 +638,106 @@ Rollback a table to a specific timestamp:
CALL sys.rollback_to_timestamp(table => 'paimon.my_db.my_table', timestamp =>
1234567890000);
```
+### create_global_index
+
+Build and commit a global index for a table column:
+
+```sql
+CALL sys.create_global_index(
+ table => 'paimon.my_db.my_table',
+ index_column => 'id',
+ index_type => 'btree'
+);
+```
+
+`index_type` defaults to `btree`. BTree indexes support scalar columns and do
+not accept the `options` argument yet.
+
+The current global-index builders require a row-tracking data-evolution table
+with global indexes enabled. They do not support primary-key tables or tables
+with deletion vectors enabled:
+
+```sql
+CREATE TABLE paimon.my_db.items (
+ id INT,
+ embedding ARRAY<FLOAT>
+) WITH (
+ 'bucket' = '1',
+ 'row-tracking.enabled' = 'true',
+ 'data-evolution.enabled' = 'true',
+ 'global-index.enabled' = 'true',
+ 'global-index.row-count-per-shard' = '100000'
+);
+```
+
+For vector indexes backed by vindex, set `index_type` to one of `ivf-flat`,
+`ivf-pq`, `ivf-hnsw-flat`, or `ivf-hnsw-sq`:
+
+```sql
+CALL sys.create_global_index(
+ table => 'paimon.my_db.items',
+ index_column => 'embedding',
+ index_type => 'ivf-flat',
+ options =>
'ivf-flat.dimension=4,ivf-flat.nlist=256,ivf-flat.distance.metric=inner_product'
+);
+```
+
+The `options` argument is a comma-separated `key=value` string. User options
+override table options. Use keys prefixed by the selected index type, or set
+field-level table options with `fields.<column>.<option>`:
+
+```sql
+CREATE TABLE paimon.my_db.image_items (
+ id INT,
+ embedding ARRAY<FLOAT>
+) WITH (
+ 'bucket' = '1',
+ 'row-tracking.enabled' = 'true',
+ 'data-evolution.enabled' = 'true',
+ 'global-index.enabled' = 'true',
+ 'fields.embedding.dimension' = '768',
+ 'fields.embedding.distance.metric' = 'cosine',
+ 'fields.embedding.nlist' = '1024'
+);
+```
+
+Supported vindex options:
+
+| Option | Default | Applies To | Description |
+|---|---:|---|---|
+| `<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 vindex types | Number of IVF lists. |
+| `<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>.hnsw.m` | native default | HNSW vindex types | HNSW graph
connectivity. |
+| `<index-type>.hnsw.ef-construction` | native default | HNSW vindex types |
HNSW construction beam width. |
+| `<index-type>.hnsw.max-level` | native default | HNSW vindex types | Maximum
HNSW graph level. |
+
+Native vindex aliases are also accepted in the `options` string: `dimension`,
+`metric`, `nlist`, `pq.m`, `use-opq`, and `hnsw.*`.
+
+Inspect committed index files with the `$table_indexes` system table:
+
+```sql
+SELECT index_type, index_field_name, row_count, row_range_start, row_range_end
+FROM paimon.my_db.items$table_indexes;
+```
+
+### drop_global_index
+
+Drop a committed BTree global index:
+
+```sql
+CALL sys.drop_global_index(
+ table => 'paimon.my_db.my_table',
+ index_column => 'id',
+ index_type => 'btree'
+);
+```
+
+Only BTree indexes can be dropped through this procedure currently.
+
### create_lumina_index
Build and commit a Lumina global vector index for a table column:
@@ -775,7 +881,11 @@ CROSS JOIN LATERAL vector_search(
## Vector Search
-Paimon supports approximate nearest neighbor (ANN) vector search via the
Lumina vector index. The `vector_search` table-valued function is registered as
a UDTF on the DataFusion session context.
+Paimon supports approximate nearest neighbor (ANN) vector search through global
+vector indexes. DataFusion can search vindex indexes created by
+`CALL sys.create_global_index` and Lumina indexes created by
+`CALL sys.create_lumina_index`. The `vector_search` table-valued function is
+registered as a UDTF on the DataFusion session context.
### Registration
@@ -808,7 +918,9 @@ Example:
SELECT * FROM vector_search('paimon.my_db.items', 'embedding', '[1.0, 0.0,
0.0, 0.0]', 10);
```
-The function performs ANN search across all Lumina vector index files for the
target column, merges results, and returns the top-k rows ordered by relevance
score. If no matching index is found, an empty result is returned.
+The function performs ANN search across all matching vector index files for the
+target column, merges results, and returns the top-k rows ordered by relevance
+score. If no matching index is found, an empty result is returned.
### Lateral Joins
@@ -838,17 +950,25 @@ The distance metric is configured at index creation time
via table options:
| `cosine` | Cosine similarity |
| `l2` | Euclidean (L2) distance |
-### Vector Index Options
+### Vindex Index Options
-Vector index behavior is configured via table options prefixed with `lumina.`:
+For vindex-backed search, build the index with
+`CALL sys.create_global_index` and an index type such as `ivf-flat` or
+`ivf-pq`. See [create_global_index](#create_global_index) for the supported
+index types, table requirements, and option keys.
+
+### Lumina Index Options
+
+Lumina index behavior is configured via table options prefixed with `lumina.`:
| Option | Description |
|---|---|
-| `lumina.dimension` | Vector dimension |
-| `lumina.metric` | Distance metric (`inner_product`, `cosine`, `l2`) |
-| `lumina.index-type` | Index type (default: `diskann`) |
+| `lumina.index.dimension` | Vector dimension |
+| `lumina.distance.metric` | Distance metric (`inner_product`, `cosine`, `l2`)
|
+| `lumina.index.type` | Index type (default: `diskann`) |
+| `lumina.encoding.type` | Encoding type (default: `pq`) |
-### Environment
+### Lumina Environment
The Lumina native library must be available at runtime. Set the
`LUMINA_LIB_PATH` environment variable to the path of the shared library, or
place it in the platform default location.
@@ -1249,6 +1369,31 @@ Columns:
| `total_buckets` | INT | Total bucket count for the partition (0 unless
catalog-tracked) |
| `done` | BOOLEAN | Whether the partition is marked done (false unless
catalog-tracked) |
+### $table_indexes
+
+View committed global index files, including BTree indexes, vector indexes, and
+deletion-vector metadata:
+
+```sql
+SELECT * FROM paimon.default.my_table$table_indexes;
+```
+
+Columns:
+
+| Column | Type | Description |
+|---|---|---|
+| `partition` | STRING | Partition spec for the indexed data, or `NULL` for
unpartitioned tables |
+| `bucket` | INT | Bucket id covered by the index file |
+| `index_type` | STRING | Index type, such as `btree`, `ivf-flat`, `lumina`,
or `DELETION_VECTORS` |
+| `file_name` | STRING | Index file name under the table index directory |
+| `file_size` | BIGINT | Index file size in bytes |
+| `row_count` | BIGINT | Number of rows covered by the index file |
+| `dv_ranges` | ARRAY | Deletion-vector ranges, only populated for
deletion-vector metadata |
+| `row_range_start` | BIGINT | First row id covered by the index file |
+| `row_range_end` | BIGINT | Last row id covered by the index file |
+| `index_field_id` | INT | Field id of the indexed column |
+| `index_field_name` | STRING | Name of the indexed column |
+
### $physical_files_size
Scan the table directory recursively and compute the total size of recognized
physical files on disk, categorized by file type. This table is a diagnostic
size summary; orphan cleanup needs file-level candidates and retention checks,
not just aggregate size differences.
@@ -1357,6 +1502,22 @@ rows (`DELETE` / `UPDATE_BEFORE`), deletion vectors,
cross-partition dynamic
bucket writes, or advanced aggregation options such as `ignore-retract`,
`distinct`, `nested-key`, `count-limit`, and sequence groups.
+### Global Index Options
+
+Set these options when building global indexes with
+`CALL sys.create_global_index`. The current DataFusion builders require
+row-tracking and data evolution, and reject primary-key tables and tables with
+deletion vectors enabled.
+
+| Option | Default | Description |
+|---|---:|---|
+| `row-tracking.enabled` | `false` | Enables stable row ids required by global
index files. |
+| `data-evolution.enabled` | `false` | Enables row-id-aware table evolution
and partial-column writes. |
+| `global-index.enabled` | `false` | Enables global index metadata and
global-index-aware reads. |
+| `global-index.row-count-per-shard` | `100000` | Maximum row count per vector
global-index shard. |
+| `sorted-index.records-per-range` | `100000` | Maximum row count per BTree
range. |
+| `global-index.search-mode` | `fast` | Global index coverage mode for reads:
`fast`, `full`, or `detail`. |
+
### Variant Shredding Options
Set these as table options when writing `VARIANT` columns to Parquet. The
@@ -1383,7 +1544,9 @@ the normal physical format without wrapping the writer.
| Option | Description |
|---|---|
| `'sequence.field' = 'col'` | Sequence field used to determine which record
wins during deduplication |
+| `'row-tracking.enabled' = 'true'` | Enable stable row ids |
| `'data-evolution.enabled' = 'true'` | Enable data evolution (partial-column
writes, row-level UPDATE/MERGE/DELETE) |
+| `'global-index.enabled' = 'true'` | Enable global index metadata and reads |
| `'deletion-vectors.enabled' = 'true'` | Enable deletion vectors |
| `'cross-partition-update.enabled' = 'true'` | Allow cross-partition updates |
| `'changelog-producer' = 'input'` | Changelog producer (PK tables with input
mode reject writes) |