lance-format / lance-format/lance
bug: FTS on a JSON subfield — path-scoped inverted index is silently inert, and non-triple queries panic
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 7.1k
- Forks
- 852
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 272
Description
Summary
FTS over a string nested inside a JSON column is possible and gives accurate, field-scoped answers — but only through a direct INVERTED index queried with an undocumented field,type,value triple syntax. The two things a user would naturally try both fail:
- A JSON-path inverted index is accepted, listed, and completely inert.
IndexConfig(index_type="json", parameters={"target_index_type": "inverted", "path": "$.title"})builds without error and shows up inlist_indices(), but the planner never uses it — the plan isFlatMatchQuery, byte-for-byte identical to having no index at all. Queries fall back to a brute-force scan of the raw JSON text, so they match JSON key names and values from other fields, i.e. they ignore thepaththe index was declared on. - Any non-triple query against a whole-column
INVERTEDindex panics.flatten_triplet(query_text, ...).unwrap()atrust/lance-index/src/scalar/inverted/tokenizer/document_tokenizer.rs:147unwraps aResultwhose error is driven purely by user input, so the ordinary FTS query"brown"panics a background thread and surfaces asRuntimeError: Task was aborted.
Repro
pylance 10.0.0, Linux x86_64.
import os, shutil, tempfile, json, logging
import pyarrow as pa, lance
from lance.indices import IndexConfig
from lance.query import MatchQuery
logging.disable(logging.WARNING)
DOCS = [{"title": "quick brown fox", "author": "alice"},
{"title": "lazy dog sleeps", "author": "bob"},
{"title": "brown bear roars", "author": "carol"},
{"title": "fox and hound", "author": "alice"}]
docs = [json.dumps(d) for d in DOCS]
jf = pa.field("doc", pa.string(), metadata={b"ARROW:extension:name": b"arrow.json"})
schema = pa.schema([pa.field("id", pa.int32()), jf])
def mk(cfg=None, name="fts"):
uri = os.path.join(tempfile.mkdtemp(), "f.lance"); shutil.rmtree(uri, ignore_errors=True)
ds = lance.write_dataset(pa.table({"id": pa.array(range(4), pa.int32()),
"doc": pa.array(docs, pa.string())}, schema=schema), uri)
if cfg is not None:
ds.create_scalar_index("doc", cfg, name=name)
return lance.dataset(uri)
def search(ds, q):
try:
return sorted(ds.scanner(columns=["id"], full_text_query=MatchQuery(q, column="doc")
).to_table().column("id").to_pylist())
except Exception as e:
return f"ERR {type(e).__name__}: {str(e)[:55]}"
def plan(ds, q="brown"):
return next((l.strip() for l in ds.scanner(columns=["id"],
full_text_query=MatchQuery(q, column="doc")).explain_plan(True).splitlines()
if "MatchQuery" in l), "?")
NONE = mk()
PATH = mk(IndexConfig(index_type="json",
parameters={"target_index_type": "inverted", "path": "$.title"}), "fts_title")
print("Defect 1: JSON-path inverted index (path=$.title)")
print(" created:", [(i["name"], i["type"]) for i in PATH.list_indices()])
for term, want in [("brown", [0,2]), ("fox", [0,3]), ("alice", []), ("title", []), ("author", [])]:
print(f" {term:<8} {str(search(NONE, term)):<14} {str(search(PATH, term)):<16} {want}")
print(" plan, no index :", plan(NONE))
print(" plan, path index:", plan(PATH))
WHOLE = mk("INVERTED", "fts_all")
print("\nDefect 2: direct INVERTED on the JSON column")
print(" plan:", plan(WHOLE))
for q in ["brown", "title,string,brown", "title,str,brown"]:
print(f" {q!r:<24} -> {search(WHOLE, q)}")
for q, want in [("title,str,brown", [0,2]), ("title,str,alice", []), ("author,str,alice", [0,3])]:
got = search(WHOLE, q)
print(f" {q!r:<24} -> {got} want {want} {got == want}")
Actual
Defect 1: JSON-path inverted index (path=$.title)
created: [('fts_title', 'Json')]
term no index with path index want (for $.title)
brown [0, 2] [0, 2] [0, 2]
fox [0, 3] [0, 3] [0, 3]
alice [0, 3] [0, 3] []
title [0, 1, 2, 3] [0, 1, 2, 3] []
author [0, 1, 2, 3] [0, 1, 2, 3] []
plan, no index : FlatMatchQuery: column=doc, query=brown
plan, path index: FlatMatchQuery: column=doc, query=brown
Defect 2: direct INVERTED on the JSON column
plan: MatchQuery: column=doc, query=[brown]
'brown' -> ERR ArrowInvalid: External error: RuntimeError: Task was aborted
'title,string,brown' -> ERR ArrowInvalid: External error: RuntimeError: Task was aborted
'title,str,brown' -> [0, 2]
'title,str,alice' -> [] want [] True
'author,str,alice' -> [0, 3] want [0, 3] True
Defect 1 detail
alice appears only in $.author, yet matches rows 0 and 3. title and author are JSON key names, not content, yet each matches all four rows. Every column is identical to the no-index run, and both plans are FlatMatchQuery, so the index contributes nothing — the answers come from a flat scan over the serialized JSON text.
So a user who builds this index gets no acceleration and no path scoping, with no indication that either is missing. Note also that list_indices() reports the index type as Json rather than Inverted, which matches the existing TODO on JsonIndex::index_type().
Defect 2 detail
Both panics come from the same .unwrap():
fn token_stream_for_search<'a>(&'a mut self, query_text: &'a str) -> BoxTokenStream<'a> {
let tokens = flatten_triplet(query_text, &mut self.tokenizer).unwrap(); // :147
"brown"→InvalidInput { source: "Invalid triple format: brown" }(:180)"title,string,brown"→InvalidInput { source: "Invalid triple type: string" }(:214)
The second is worth calling out: the type token is str, not string, and getting it wrong panics rather than erroring. Accepted tokens are str, number, bool, null; field names are dotted paths built by flatten_json (so meta.tag,str,nature works for a nested object).
Expected
- A JSON-path inverted index should either be used by the planner and scoped to its
path, or be rejected at creation time with a clear "not supported" error. Silently creating an index that is never consulted, and answering with whole-document semantics under a$.titledeclaration, is the worst outcome. - A malformed FTS query should return an
InvalidInputerror to the caller, not panic a background thread. The error type is already there; it just needs to propagate instead of being unwrapped.
Notes
- Verified against a control: a plain
stringcolumn withINVERTEDplans asMatchQuery: column=titleand returns correct results, so theFlatMatchQueryfallback is specific to the JSON path index. - The triple syntax appears to be the intended query interface for the JSON inverted index, but I could not find it documented anywhere. Given #7445 is already reworking flattened JSON sub-doc indexing, it may be worth settling the user-facing query surface there — ideally so that
MatchQuery("brown", column="doc.title")or similar works instead of requiring callers to hand-assembletitle,str,brown. - Related: #4749 (add full text json index), #7445 (flattened JSON sub-doc indexing), #8806 (JSON path scalar index returns incorrect results for
json_extractfilters).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the provided Python reproduction, then inspect rust/lance-index/src/scalar/inverted/tokenizer/document_tokenizer.rs around line 147 and the JSON-index planning path. Verify how malformed triple queries and JSON-path indexes are handled. Done means malformed input returns InvalidInput without aborting a background task, and JSON-path indexes are either used with path scoping or rejected clearly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- databases, search
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100