Repository navigation
feat(python): expose LABEL_LIST segment index build #7884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3252,8 +3252,12 @@ def _prepare_scalar_index_request( | |
| ", float, bool, str, large_str, fixed-size-binary, or temporal", | ||
| ) | ||
| elif index_type == "LABEL_LIST": | ||
| if not pa.types.is_list(field_type): | ||
| raise TypeError(f"LABEL_LIST index column {column} must be a list") | ||
| if not ( | ||
| pa.types.is_list(field_type) or pa.types.is_large_list(field_type) | ||
| ): | ||
| raise TypeError( | ||
| f"LABEL_LIST index column {column} must be a list or large list" | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| elif index_type == "NGRAM": | ||
| if not pa.types.is_string(field_type) and not pa.types.is_large_string( | ||
| field_type | ||
|
|
@@ -3311,6 +3315,7 @@ def _is_segment_native_scalar_index_type( | |
| "RTREE", | ||
| "ZONEMAP", | ||
| "BLOOMFILTER", | ||
| "LABEL_LIST", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Making LABEL_LIST segment-native exposes caller-supplied On this head I published an index for This creates both false negatives and false positives without calling
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a Core guard to reject caller-supplied UUIDs for distributed LABEL_LIST builds before any I/O. |
||
| } | ||
|
|
||
| @classmethod | ||
|
|
@@ -3325,6 +3330,7 @@ def _requires_uncommitted_scalar_index( | |
| "RTREE", | ||
| "ZONEMAP", | ||
| "BLOOMFILTER", | ||
| "LABEL_LIST", | ||
| } | ||
|
|
||
| def create_scalar_index( | ||
|
|
@@ -4281,10 +4287,9 @@ def create_index_uncommitted( | |
|
|
||
| This is the public distributed-build API for vector, BTREE scalar, | ||
| canonical bitmap scalar, INVERTED scalar, NGRAM scalar, RTREE scalar, | ||
| ZONEMAP scalar, | ||
| and BLOOMFILTER scalar index construction. Unlike | ||
| :meth:`create_index`, this method does not publish the index into the | ||
| dataset manifest. Instead, it writes one segment under | ||
| ZONEMAP scalar, BLOOMFILTER scalar, and LABEL_LIST scalar index construction. | ||
| Unlike :meth:`create_index`, this method does not publish the index into | ||
| the dataset manifest. Instead, it writes one segment under | ||
| ``_indices/<segment_uuid>/`` and returns the resulting | ||
| :class:`Index` metadata. | ||
|
|
||
|
|
@@ -4298,10 +4303,11 @@ def create_index_uncommitted( | |
| 4. commit the final segment list with | ||
| :meth:`commit_existing_index_segments` | ||
|
|
||
| BTREE, BITMAP, INVERTED, NGRAM, RTREE, ZONEMAP, and BLOOMFILTER segments may | ||
| be merged with :meth:`merge_existing_index_segments` before commit. | ||
| NGRAM segments built before a deferred compaction must be merged before | ||
| commit so their postings can be rebuilt against current row addresses. | ||
| BTREE, BITMAP, INVERTED, NGRAM, RTREE, ZONEMAP, BLOOMFILTER, and | ||
| LABEL_LIST segments may be merged with | ||
| :meth:`merge_existing_index_segments` before commit. NGRAM segments | ||
| built before a deferred compaction must be merged before commit so | ||
| their postings can be rebuilt against current row addresses. | ||
| Parameters are the same as :meth:`create_index`, with one additional | ||
| requirement: | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ use tokio::try_join; | |
|
|
||
| use super::{ | ||
| AnyQuery, BloomFilterQuery, LabelListQuery, MetricsCollector, SargableQuery, ScalarIndex, | ||
| SearchResult, TextQuery, TokenQuery, | ||
| SearchResult, TextQuery, TokenQuery, label_list::validate_label_list_data_type, | ||
| }; | ||
| #[cfg(feature = "geo")] | ||
| use super::{GeoQuery, RelationQuery}; | ||
|
|
@@ -774,6 +774,11 @@ impl ScalarQueryParser for LabelListQueryParser { | |
| if args.len() != 2 { | ||
| return None; | ||
| } | ||
| // LABEL_LIST stores unnested items as bitmap keys. Nested items cannot be | ||
| // ordered by the bitmap lookup, so legacy indexes must fall back to a scan. | ||
| if validate_label_list_data_type(data_type).is_err() { | ||
| return None; | ||
| } | ||
| // DataFusion normalizes array_contains to array_has | ||
| if func.name() == "array_has" { | ||
| let inner_type = match data_type { | ||
|
|
@@ -796,34 +801,34 @@ impl ScalarQueryParser for LabelListQueryParser { | |
| } | ||
|
|
||
| let label_list = maybe_scalar(&args[1], data_type)?; | ||
| if let ScalarValue::List(list_arr) = label_list { | ||
| let list_values = list_arr.values(); | ||
| if list_values.is_empty() { | ||
| return None; | ||
| } | ||
| let mut scalars = Vec::with_capacity(list_values.len()); | ||
| for idx in 0..list_values.len() { | ||
| scalars.push(ScalarValue::try_from_array(list_values.as_ref(), idx).ok()?); | ||
| } | ||
| if func.name() == "array_has_all" { | ||
| let query = LabelListQuery::HasAllLabels(scalars); | ||
| Some(IndexedExpression::index_query( | ||
| column.to_string(), | ||
| self.index_name.clone(), | ||
| self.index_type.clone(), | ||
| Arc::new(query), | ||
| )) | ||
| } else if func.name() == "array_has_any" { | ||
| let query = LabelListQuery::HasAnyLabel(scalars); | ||
| Some(IndexedExpression::index_query( | ||
| column.to_string(), | ||
| self.index_name.clone(), | ||
| self.index_type.clone(), | ||
| Arc::new(query), | ||
| )) | ||
| } else { | ||
| None | ||
| } | ||
| let list_values = match label_list { | ||
| ScalarValue::List(list_arr) => list_arr.values().clone(), | ||
| ScalarValue::LargeList(list_arr) => list_arr.values().clone(), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new exact On this head, a v0 artifact over rows A bounded compatibility fix could keep nullable v0 segments on scan/recheck, or reject them, before enabling exact LargeList planning. A historical-format regression covering both negated predicates would lock this down.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the concrete repro. I agree that nullable v0 LabelList indices lack list-level NULL metadata, so NOT queries can be incorrect. However, this limitation predates this PR: array_has already uses the index for both List and LargeList, while array_has_any/all already use it for List. This PR only extends the same behavior to LargeList array_has_any/all. Handling v0 differently would require a broader change across index loading and all LabelList predicates, rather than a local fix for this LargeList arm. Could we keep the broader v0 handling out of scope for this PR and rely on the existing rebuild warning for legacy indices? |
||
| _ => return None, | ||
| }; | ||
| if list_values.is_empty() { | ||
| return None; | ||
| } | ||
| let mut scalars = Vec::with_capacity(list_values.len()); | ||
| for idx in 0..list_values.len() { | ||
| scalars.push(ScalarValue::try_from_array(list_values.as_ref(), idx).ok()?); | ||
| } | ||
| if func.name() == "array_has_all" { | ||
| let query = LabelListQuery::HasAllLabels(scalars); | ||
| Some(IndexedExpression::index_query( | ||
| column.to_string(), | ||
| self.index_name.clone(), | ||
| self.index_type.clone(), | ||
| Arc::new(query), | ||
| )) | ||
| } else if func.name() == "array_has_any" { | ||
| let query = LabelListQuery::HasAnyLabel(scalars); | ||
| Some(IndexedExpression::index_query( | ||
| column.to_string(), | ||
| self.index_name.clone(), | ||
| self.index_type.clone(), | ||
| Arc::new(query), | ||
| )) | ||
| } else { | ||
| None | ||
| } | ||
|
|
@@ -2606,6 +2611,7 @@ mod tests { | |
| use lance_datafusion::exec::{LanceExecutionOptions, get_session_context}; | ||
| use lance_select::result::IndexExprResultWireFormat; | ||
| use roaring::RoaringBitmap; | ||
| use rstest::rstest; | ||
|
|
||
| use crate::scalar::json::{JsonQuery, JsonQueryParser}; | ||
|
|
||
|
|
@@ -2667,6 +2673,16 @@ mod tests { | |
| Field::new("price", DataType::Float32, false), | ||
| Field::new("json", DataType::LargeBinary, false), | ||
| ]); | ||
| check_with_schema(index_info, expr, expected, optimize, schema); | ||
| } | ||
|
|
||
| fn check_with_schema( | ||
| index_info: &dyn IndexInformationProvider, | ||
| expr: &str, | ||
| expected: Option<IndexedExpression>, | ||
| optimize: bool, | ||
| schema: Schema, | ||
| ) { | ||
| let df_schema: DFSchema = schema.try_into().unwrap(); | ||
|
|
||
| let ctx = get_session_context(&LanceExecutionOptions::default()); | ||
|
|
@@ -2756,6 +2772,72 @@ mod tests { | |
| ) | ||
| } | ||
|
|
||
| #[rstest] | ||
| #[case::list(DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))))] | ||
| #[case::large_list(DataType::LargeList(Arc::new(Field::new("item", DataType::Utf8, true))))] | ||
| fn test_label_list_query_parser(#[case] label_type: DataType) { | ||
| let index_info = MockIndexInfoProvider::new(vec![( | ||
| "labels", | ||
| ColInfo::new( | ||
| label_type.clone(), | ||
| Box::new(LabelListQueryParser::new( | ||
| "labels_idx".to_string(), | ||
| "LabelList".to_string(), | ||
| )), | ||
| ), | ||
| )]); | ||
| let schema = Schema::new(vec![Field::new("labels", label_type, true)]); | ||
|
|
||
| check_with_schema( | ||
| &index_info, | ||
| "array_has_any(labels, ['distributed'])", | ||
| Some(IndexedExpression::index_query( | ||
| "labels".to_string(), | ||
| "labels_idx".to_string(), | ||
| "LabelList".to_string(), | ||
| Arc::new(LabelListQuery::HasAnyLabel(vec![ScalarValue::Utf8(Some( | ||
| "distributed".to_string(), | ||
| ))])), | ||
| )), | ||
| true, | ||
| schema, | ||
| ); | ||
| } | ||
|
|
||
| #[rstest] | ||
| #[case::list(DataType::List(Arc::new(Field::new( | ||
| "item", | ||
| DataType::List(Arc::new(Field::new("item", DataType::Int64, true))), | ||
| true, | ||
| ))))] | ||
| #[case::large_list(DataType::LargeList(Arc::new(Field::new( | ||
| "item", | ||
| DataType::List(Arc::new(Field::new("item", DataType::Int64, true))), | ||
| true, | ||
| ))))] | ||
| fn test_label_list_nested_item(#[case] label_type: DataType) { | ||
| let index_info = MockIndexInfoProvider::new(vec![( | ||
| "labels", | ||
| ColInfo::new( | ||
| label_type.clone(), | ||
| Box::new(LabelListQueryParser::new( | ||
| "labels_idx".to_string(), | ||
| "LabelList".to_string(), | ||
| )), | ||
| ), | ||
| )]); | ||
| let schema = Schema::new(vec![Field::new("labels", label_type, true)]); | ||
|
|
||
| // Nested item types must not produce a scalar index query. | ||
| check_with_schema( | ||
| &index_info, | ||
| "array_has_any(labels, [[1]])", | ||
| None, | ||
| true, | ||
| schema, | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_expressions() { | ||
| let index_info = MockIndexInfoProvider::new(vec![ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accepting every
LargeListhere expands the supported schema beyond what LabelList bitmap keys can order, so index creation can succeed and a later query can panic.LargeList<List<Int64>>passes this check; the first indexed query reaches the(List, List) => todo!()comparator inbtree.rsand surfacesArrowInvalid: Task was aborted. The flat query returns the correct row.The following ran on this head and fails at the final call:
The comparator limitation predates this PR for nested
List, but this change newly accepts the equivalent LargeList schema and makes its any/all path reachable. A bounded fix could validate supported element types in Rust before writing the artifact, mirror that check in Python, and retain scan fallback for existing unsupported indexes.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. LABEL_LIST now rejects nested item types for both
ListandLargeListin Rust Core. The query parser applies the same check so legacy invalid indexes fall back to a scan instead of reaching the unsupported bitmap comparison. Both paths are covered by parameterized tests.