From a4f66afc6998c9cb557609c36848f91e3631399b Mon Sep 17 00:00:00 2001 From: Bruno Ramirez Date: Wed, 16 Sep 2026 12:54:22 -0600 Subject: [PATCH] feat(rust): add zonemap index builder (#4199) Lance supports ZoneMap scalar indexes, but the LanceDB Rust API did not expose a first-class way to request one through `Table::create_index`. Users had builders for the other scalar index families, while ZoneMap was missing from the public `Index` model and remote create-index serialization. This PR adds ZoneMap as a supported scalar index option in LanceDB. This was accomplished with the following changes: - Added `ZoneMapIndexBuilder` in `rust/lancedb/src/index/scalar.rs`. - Added `Index::ZoneMap` and `IndexType::ZoneMap`, including display/from-string aliases for `ZONEMAP` and `ZONE_MAP`. - Mapped local index creation to `ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap)` and Lance `IndexType::ZoneMap` in `rust/lancedb/src/table/create_index.rs`. - Serialized remote create-index requests as `index_type: "ZONEMAP"` in `rust/lancedb/src/remote/table.rs`. - Added coverage for both local ZoneMap index creation and remote request serialization. Example: ```rust table .create_index(&["my_column"], Index::ZoneMap(Default::default())) .execute() .await?; ``` ### Testing Added `test_create_zonemap_index` for local index creation and extended the remote request body test matrix for `ZONEMAP`. --- rust/lancedb/src/index.rs | 15 ++++++- rust/lancedb/src/index/scalar.rs | 22 ++++++++++ rust/lancedb/src/remote/table.rs | 2 + rust/lancedb/src/table/create_index.rs | 57 +++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/rust/lancedb/src/index.rs b/rust/lancedb/src/index.rs index c693dc056..0611f95b1 100644 --- a/rust/lancedb/src/index.rs +++ b/rust/lancedb/src/index.rs @@ -13,7 +13,10 @@ use crate::index::vector::IvfRqIndexBuilder; use crate::{DistanceType, Error, Result, job::Job, table::BaseTable}; use self::{ - scalar::{BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder, LabelListIndexBuilder}, + scalar::{ + BTreeIndexBuilder, BitmapIndexBuilder, FmIndexBuilder, LabelListIndexBuilder, + ZoneMapIndexBuilder, + }, vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, IvfPqIndexBuilder, IvfSqIndexBuilder, @@ -54,6 +57,12 @@ pub enum Index { /// substrings of the raw bytes, unlike the tokenized [`Index::FTS`] index. Fm(FmIndexBuilder), + /// A `ZoneMap` index stores min/max summaries for ranges of rows. + /// + /// It can accelerate range filters by skipping zones whose min/max values + /// prove they cannot match the predicate. + ZoneMap(ZoneMapIndexBuilder), + /// Full text search index using BM25. /// /// The posting block size defaults to 128. Supported values are 128 and 256; @@ -341,6 +350,8 @@ pub enum IndexType { LabelList, #[serde(alias = "FM", alias = "FMINDEX", alias = "FMIndex")] Fm, + #[serde(alias = "ZONEMAP", alias = "ZONE_MAP")] + ZoneMap, // FTS #[serde(alias = "INVERTED", alias = "Inverted")] FTS, @@ -362,6 +373,7 @@ impl std::fmt::Display for IndexType { Self::Bitmap => write!(f, "BITMAP"), Self::LabelList => write!(f, "LABEL_LIST"), Self::Fm => write!(f, "FM"), + Self::ZoneMap => write!(f, "ZONEMAP"), Self::FTS => write!(f, "FTS"), Self::Unknown => write!(f, "UNKNOWN"), } @@ -377,6 +389,7 @@ impl std::str::FromStr for IndexType { "BITMAP" => Ok(Self::Bitmap), "LABEL_LIST" | "LABELLIST" => Ok(Self::LabelList), "FM" | "FMINDEX" => Ok(Self::Fm), + "ZONEMAP" | "ZONE_MAP" => Ok(Self::ZoneMap), "FTS" | "INVERTED" => Ok(Self::FTS), "IVF_FLAT" => Ok(Self::IvfFlat), "IVF_SQ" => Ok(Self::IvfSq), diff --git a/rust/lancedb/src/index/scalar.rs b/rust/lancedb/src/index/scalar.rs index dba05b776..af7407a21 100644 --- a/rust/lancedb/src/index/scalar.rs +++ b/rust/lancedb/src/index/scalar.rs @@ -60,6 +60,28 @@ pub struct LabelListIndexBuilder {} #[derive(Debug, Clone, Default, serde::Serialize)] pub struct FmIndexBuilder {} +/// Builder for a ZoneMap index. +/// +/// A ZoneMap index stores min/max summaries for ranges of rows and can +/// accelerate range predicates by pruning zones that cannot match. +/// +/// ``` +/// use lancedb::{ +/// index::{scalar::ZoneMapIndexBuilder, Index}, +/// Table, +/// }; +/// +/// # async fn create_zonemap_index(table: &Table) -> lancedb::Result<()> { +/// table +/// .create_index(&["timestamp"], Index::ZoneMap(ZoneMapIndexBuilder::default())) +/// .execute() +/// .await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct ZoneMapIndexBuilder {} + pub use lance_index::scalar::FullTextSearchQuery; pub use lance_index::scalar::InvertedIndexParams as FtsIndexBuilder; pub use lance_index::scalar::InvertedIndexParams; diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 89061c393..c1182f996 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -587,6 +587,7 @@ impl RemoteTable { Index::Bitmap(p) => ("BITMAP", Some(to_json(p)?)), Index::LabelList(p) => ("LABEL_LIST", Some(to_json(p)?)), Index::Fm(p) => ("FM", Some(to_json(p)?)), + Index::ZoneMap(p) => ("ZONEMAP", Some(to_json(p)?)), Index::FTS(p) => { let mut params = to_json(p)?; if p.get_document_granularity().is_list_element() { @@ -6376,6 +6377,7 @@ mod tests { // HNSW_PQ isn't yet supported on SaaS ("BTREE", json!({}), Index::BTree(Default::default())), ("BITMAP", json!({}), Index::Bitmap(Default::default())), + ("ZONEMAP", json!({}), Index::ZoneMap(Default::default())), ( "LABEL_LIST", json!({}), diff --git a/rust/lancedb/src/table/create_index.rs b/rust/lancedb/src/table/create_index.rs index e30c310ac..2cc66b8cd 100644 --- a/rust/lancedb/src/table/create_index.rs +++ b/rust/lancedb/src/table/create_index.rs @@ -259,6 +259,12 @@ impl NativeTable { BuiltinIndexType::Fm, ))) } + Index::ZoneMap(_) => { + Self::validate_index_type(field, "ZoneMap", supported_btree_data_type)?; + Ok(Box::new(ScalarIndexParams::for_builtin( + BuiltinIndexType::ZoneMap, + ))) + } Index::FTS(fts_opts) => { Self::validate_index_type(field, "FTS", supported_fts_data_type)?; Ok(Box::new(fts_opts)) @@ -418,6 +424,7 @@ impl NativeTable { Index::Bitmap(_) => IndexType::Bitmap, Index::LabelList(_) => IndexType::LabelList, Index::Fm(_) => IndexType::Fm, + Index::ZoneMap(_) => IndexType::ZoneMap, Index::FTS(_) => IndexType::Inverted, Index::IvfFlat(_) | Index::IvfSq(_) @@ -451,7 +458,8 @@ mod tests { use crate::connection::ConnectBuilder; use crate::index::Index; use crate::index::scalar::{ - BTreeIndexBuilder, BitmapIndexBuilder, DocumentGranularity, FmIndexBuilder, FtsIndexBuilder, + BTreeIndexBuilder, BitmapIndexBuilder, DocumentGranularity, FmIndexBuilder, + FtsIndexBuilder, ZoneMapIndexBuilder, }; use crate::index::vector::{ IvfHnswFlatIndexBuilder, IvfHnswPqIndexBuilder, IvfHnswSqIndexBuilder, @@ -1197,6 +1205,53 @@ mod tests { assert_eq!(stats.distance_type, None); } + #[tokio::test] + async fn test_create_zonemap_index() { + let conn = connect("memory://").execute().await.unwrap(); + let batch = record_batch!(("i", Int32, [1, 2, 3, 4, 5])).unwrap(); + let table = conn + .create_table("zonemap_table", batch) + .execute() + .await + .unwrap(); + + table + .create_index(&["i"], Index::ZoneMap(ZoneMapIndexBuilder::default())) + .execute() + .await + .unwrap(); + table + .wait_for_index(&["i_idx"], Duration::from_millis(10)) + .await + .unwrap(); + + let index_configs = table.list_indices().await.unwrap(); + assert_eq!(index_configs.len(), 1); + let index = index_configs.into_iter().next().unwrap(); + assert_eq!(index.index_type, crate::index::IndexType::ZoneMap); + assert_eq!(index.columns, vec!["i".to_string()]); + + let count = table + .query() + .only_if("i >= 2 AND i < 5") + .execute() + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(|b| b.num_rows()) + .sum::(); + assert_eq!(count, 3); + + let stats = table.index_stats("i_idx").await.unwrap().unwrap(); + assert_eq!(stats.num_indexed_rows, 5); + assert_eq!(stats.num_unindexed_rows, 0); + assert_eq!(stats.index_type, crate::index::IndexType::ZoneMap); + assert_eq!(stats.distance_type, None); + } + #[tokio::test] async fn test_create_index_nested_field_paths() { let tmp_dir = tempdir().unwrap();