feat(index): support flattened JSON sub-doc indexing - #7377
wirybeaver wants to merge 5 commits into
Conversation
|
Important This PR touches the Lance format specification. Substantive changes to the format specification — the If this is a meaningful format change:
|
westonpace
left a comment
There was a problem hiding this comment.
This seems like a useful addition to the JSON full text search. I think disable_cross_array_unnest is going to be pretty technical for the user to judge. It might be nice to have some guardrails. For example, if a single document generates more than X subdocs (or maybe more than X subdocs per byte of the original doc) we could log a warning like "more than X subdocuments generated for a single document, if you have sibling arrays you may need to disable cross array unnesting to avoid a combinatorial explosion". Or we could have a "max_subdocs_per_doc" variable that, if exceeded, would fail the index build.
In the other direction we could potentially add some kind of conservative one-time warning. If the user disabled cross array unnesting and then queried the index using multiple constraints. We wouldn't easily know they were multiple sibling array constraints but the warning could be something like "you are querying a JSON text index with multiple constraints and the index was built with cross array unnesting disabled. If those constraints are on two different sibling arrays you may get false negatives. To disable this warning set the environment variable ..."
Ideally we will want some examples added to the docs as well. We could probably expand https://lance.org/guide/json/#full-text-search-on-json-documents
| // JSON document tokenization mode. Absent means SingleDocument JSON tokenization, | ||
| // which is how indexes written before flattened JSON sub-docs are interpreted. | ||
| optional string json_tokenizer_mode = 12; |
There was a problem hiding this comment.
Should we make this an enum? There are only two choices at the moment and I wouldn't expect there to be many.
| producing their Cartesian product. This reduces index build memory for | ||
| records with multiple arrays but can sacrifice result accuracy for | ||
| queries that constrain values across those arrays. | ||
| base_tokenizer: str, default "simple" |
There was a problem hiding this comment.
How does the user specify the json tokenizer mode? Is it through the base_tokenizer?
jja725
left a comment
There was a problem hiding this comment.
Thanks for this great contribution! this would help a lot with FTS
| } | ||
| false | ||
| } | ||
| Err(_) => false, |
There was a problem hiding this comment.
should we add some error trace here before return
| let mut token_texts = Vec::new(); | ||
| let mut tokens = tokenizer.token_stream(text); | ||
| while let Some(token) = tokens.next() { | ||
| token_texts.push(format!("{prefix},str,{}", token.text)); |
There was a problem hiding this comment.
maybe we can Add a max_sub_docs_per_row cap with a clear error or warning when exceeded
|
@jja725 @westonpace Sorry for the late reply. Somehow I miss the email notification about your folks' feedback during vacation. I will work on the improvement this week. Appreciate you folks compliment on this idea. In terms of adding the parameter "max_sub_docs_per_row", do you folks prefer failing the whole indexing process OR logging an error but keep the already flattened docs? |
Either approach is fine. Failing the entire operation is safest but could be quite annoying if there is only one bad row. In vector indexing, if we encounter NaN / NULL embeddings, we just exclude that row from the index (so it will never appear in search) so there is some precedence for just skipping too. |
|
Following up on
Default proposal: unset I'll wait for feedback from @westonpace and @jja725 before implementing this. |
4fde428 to
3b2df6c
Compare
Add explicit JsonTokenizerMode values, with SingleDocument for existing JSON indexes and FlattenedSubDocs for new JSON indexes. Flatten JSON arrays into multiple internal inverted-index sub-docs while mapping each sub-doc back to the original row id. Normalize bracketed JSON query paths into value tokens plus constraint tokens, and deduplicate flattened JSON search results by row id. Add persisted disable_cross_array_unnest index metadata, defaulting to false. When set, flattened JSON tokenization indexes sibling arrays independently instead of producing their Cartesian product, matching Pinot's memory-saving DisableCrossArrayUnnest behavior. Expose disable_cross_array_unnest through Rust, Python, and Java inverted-index params. Test Plan: PASS: cargo fmt --all --check PASS: cargo test -p lance-index scalar::inverted::tokenizer::document_tokenizer::tests --lib PASS: cargo test -p lance-index scalar::inverted::tokenizer::tests --lib PASS: cargo test -p lance test_json_inverted_ --lib PASS: cargo test -p lance test_auto_infer_lance_tokenizer --lib PASS: cargo check -p lance-index --tests PASS: cargo check -p lance --tests PASS: cargo clippy --all --tests --benches -- -D warnings PASS: PATH=/home/user/.cargo/bin:$PATH cargo fmt --manifest-path ./lance-jni/Cargo.toml --all --check (from java/) PASS: ./mvnw spotless:check (from java/) PASS: PATH=/home/user/.cargo/bin:$PATH ./mvnw test (from java/) PASS: PATH=/home/user/.local/bin:$PATH uv run pytest python/tests/test_scalar_index.py::test_json_inverted_match_query (from python/) PASS: PATH=/home/user/.local/bin:/home/user/.cargo/bin:$PATH uv run make lint (from python/) NOTE: PATH=/home/user/.local/bin:$PATH make install (from python/) built pylance and installed deps, then failed at pre-commit install because core.hooksPath is set to /etc/git-hooks.
3b2df6c to
243b5e1
Compare
75144ce to
ae2c627
Compare
Motivation
Lance JSON inverted indexes should preserve array/object structure so path queries over arrays of objects can distinguish exact positions from wildcards. Tantivy documents a known JSON-index flaw: because an array document is treated as a bag of terms,
cart.product_type:sneakers AND cart.attributes.color:redcan match a document wheresneakersandredlive in different array objects. Lance's current single-token-stream JSON index has the same class of problem.This PR introduces
FlattenedSubDocsmode for new JSON indexes, flattening each JSON row into one or more sub-documents so those terms must match inside the same flattened array element before the result is mapped back to the original Lance row id.It also keeps existing JSON indexes readable and provides two controls for documents that would produce many sub-docs:
disable_cross_array_unnest=truetrades cross-array query accuracy for lower growth, whilemax_sub_docs_per_rowplaces an explicit per-row ingestion limit.Summary
SingleDocumentfor existing JSON indexes andFlattenedSubDocsfor new JSON indexes.DocSet.$idxconstraint tokens, and deduplicate flattened JSON search results by row id using max score.disable_cross_array_unnest, defaultfalse. The default preserves exact Cartesian-product semantics. When set totrue, sibling arrays are indexed independently to avoid sub-doc explosion, matching Pinot'sDisableCrossArrayUnnestmemory tradeoff.max_sub_docs_per_row. It is unlimited when unset. When set, Lance checks the flattened count before allocating the full Cartesian product.max_sub_docs_per_row_exceed_action, defaultfail.failaborts ingestion and suggests increasing the cap or settingdisable_cross_array_unnest=true;skip_rowomits the offending source row and continues.Error Handling Implementation
max_sub_docs_per_rowbefore allocating the complete set of flattened sub-docs. No limit check is performed when the option is unset.JsonTokenizerboundary, so immutable index builds and MemWAL indexing use the same behavior.max_sub_docs_per_row_exceed_action=fail, tokenization returns an invalid-input error before any sub-doc from that source row is indexed. The error recommends increasingmax_sub_docs_per_rowor settingdisable_cross_array_unnest=true.max_sub_docs_per_row_exceed_action=skip_row, the tokenizer logs a warning and returns no token streams. Existing writer loops therefore omit the entire source row and continue indexing subsequent rows.Example 1 - Used to explain the idea
Raw Documents
doc-0
{"foo":[{"bar":["x","y"]}]}doc-1
{"foo":[{"bar":["y"]},{"bar":"z"}]}Flattened Documents
foo[0].bar[0]"x"foo[0].bar[1]"y"foo[0].bar[0]"y"foo[1].bar"z"Token Dictionary
Each token is represented as:
(path, type, value)foo..bar.x0foo..bar.y1, 2foo..barz3foo$idx00, 1, 2foo$idx13foo..bar$idx00, 2foo..bar$idx11Search Query A
Query:
Lookup tokens:
foo..bar.,str,"y"1, 2foo..bar$idx,num,00, 2foo$idx,num,00, 1, 2Flatten Posting Intersection:
Lookup original document:
Search Query B
Query:
Lookup tokens:
foo..bar.,str,"y"1, 2foo$idx,num,00, 1, 2Flatten Posting Intersection:
Lookup original document:
Example 2: sibling arrays
{"foo":[{"bar":["x","y"]},{"bar":["a","b"]}],"foo2":["u"]} {"foo":[{"bar":["y","z"]}],"foo2":["u"]}Expected flattened sub-docs:
Query behavior:
Example 3:
disable_cross_array_unnestInput:
{"a":["x","y"],"b":["u","v"],"c":1}Default
disable_cross_array_unnest=falseproduces exact Cartesian-product sub-docs:With
disable_cross_array_unnest=true, sibling arrays are indexed independently:This avoids combinatorial sub-doc growth. Queries that constrain values across multiple sibling arrays can sacrifice accuracy because no single flattened sub-doc contains terms from both sibling arrays.
Example 4: Tantivy nested-object false positive
Tantivy documents this pitfall for JSON arrays: a document is a bag of terms, so
cart.product_type:sneakers AND cart.attributes.color:redcan match even whensneakersandredcome from different objects in the samecartarray.FlattenedSubDocsmode avoids that by preserving each array element as a separate internal inverted-index document.Raw Documents
row0 should not match because
sneakersandredare in differentcartobjects:{"cart_id":3234234,"cart":[{"product_type":"sneakers","attributes":{"color":"white"}},{"product_type":"t-shirt","attributes":{"color":"red"}}]}row1 should match because both terms are in the same
cartobject:{"cart_id":3234235,"cart":[{"product_type":"sneakers","attributes":{"color":"red"}}]}Flattened Sub-Docs
Correct Query Shape For Nested-Object Semantics
Use one JSON
MatchQuerythat contains all related JSON triplets separated by;, and setOperator::Andon thatMatchQuery:Expected result:
Here
;only separates JSON triplets.Operator::Andis what requires all generated tokens to match, and because they are inside oneMatchQuery, the intersection happens on flattened sub-doc ids before Lance maps matches back to row ids.Anti-Example: Row-Level Boolean Composition
Do not express nested-object constraints as separate
BooleanQuery.mustchildren:Each child
MatchQueryruns independently and returns Lance row ids. The outer boolean query then composes those row ids, so row0 can still match at row level even though no singlecartobject contains both terms.Apache Pinot JSON Index References
disableCrossArrayUnnestconfig field: JsonIndexConfigTest Plan
Rust:
cargo fmt --all --checkcargo check --workspace --tests --benches(passed)cargo clippy --all --tests --benches -- -D warnings(passed)cargo test -p lance-index scalar::inverted::tokenizer:: --lib(52 passed)Python:
uv run pytest python/tests/test_scalar_index.py::test_json_inverted_match_queryfrompython/(1 passed)uv run make lintfrompython/(passed; existing missing-stub warnings only)Java/JNI:
./mvnw spotless:checkfromjava/(passed)InvertedIndexParamsTest(14 passed)