Skip to content

fix(index): sort JSON-path values before btree training (#7485)#7493

Closed
pjdurden wants to merge 1 commit into
lance-format:mainfrom
pjdurden:fix/gh-7485-json-float-index-unsorted-pages
Closed

fix(index): sort JSON-path values before btree training (#7485)#7493
pjdurden wants to merge 1 commit into
lance-format:mainfrom
pjdurden:fix/gh-7485-json-float-index-unsorted-pages

Conversation

@pjdurden

Copy link
Copy Markdown

What

Fixes #7485. A JSON-path scalar index (target_index_type=btree) over a float path returns wrong results once the path contains negative values: range predicates and boundary equality return empty even though the rows exist.

rows = [{"v": 10.5}, {"v": 40.1}, {"v": -3.2}]
# ... write + create json/btree index on path "v" ...
ds.to_table(filter="json_get_float(data,'v') > 0")   # -> []   (expected [1, 2])
ds.to_table(filter="json_get_float(data,'v') = 40.1")# -> []   (expected [2])

Diagnosis (differs from the issue)

The issue describes this as a non-exact-float problem. It isn't. After isolating the variables, the trigger is negative values, not float precision. Same value set, only the sign changes:

data predicate > 0 result
[0.5, 1.5, 2.5] (positive, exact) [1,2,3] correct
[10.5, 40.1, 3.2] (positive, non-exact) [1,2,3] correct
[0.5, 1.5, -2.5] (negative, exact) [] wrong
[10.5, 40.1, -3.2] (negative, non-exact) [] wrong

A non-exact but all-positive path indexes fine; an all-exact path with one negative is broken. The issue's "exact" control case happened to be all-positive, which hid the real variable.

Two more facts that pin it down:

  • A plain Float64 btree index (no JSON) over the exact same values [0.5, 1.5, -2.5] returns correct results. So the btree itself is fine; the problem is specific to the JSON training path.
  • With [0.5, 1.5, -2.5], interior equality (= 0.5) works, but the page min (= -2.5) and page max (= 1.5) both miss, and wide ranges (< 100) still return everything. That is the signature of corrupted page min/max stats, not bad values.

Root cause

train_btree_index assumes its input stream is globally sorted by value: analyze_batch records each page's stats as min = values[0], max = values[values.len()-1] (the first and last element of the page). The btree plugin's train_index does not sort; the caller is expected to pass pre-sorted data, and the normal scalar-index path sorts the column upstream.

JsonIndexPlugin::train_index extracts the typed values from the JSON column at training time and passes that stream straight to the target index trainer without sorting. The extracted stream is in storage order, not value order. When storage order matches numeric order (all-positive data) the first/last elements still bracket the page, so it happens to work. Introduce a negative and the page's recorded max can end up smaller than real values the page contains, so range and boundary lookups skip the page and return nothing.

Fix

Sort the extracted (value, row_id) stream ascending by value (nulls first) before handing it to the target index trainer, mirroring what the normal column path does upstream. Implemented as sort_stream_by_value and called between value conversion and target_plugin.train_index.

Tests

Added test_json_index_negative_floats in python/tests/test_scalar_index.py. It builds a json/btree float index over data with negatives and asserts the indexed result equals the un-indexed scan for >, <, >=, = (including equality on the negative values and on the page min/max). Fails on main, passes with this change.

Note

cargo fmt and cargo clippy -p lance-index --lib -D warnings are clean; the new regression test is Python-side, since cargo test -p lance-index currently fails to compile on main for an unrelated reason (FailNewFileStore mock in fmindex.rs missing IndexStore::with_io_priority).

…#7485)

Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
Copilot AI review requested due to automatic review settings June 26, 2026 04:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added A-python Python bindings A-index Vector index, linalg, tokenizer bug Something isn't working labels Jun 26, 2026
@wjones127

Copy link
Copy Markdown
Contributor

Closing in favor of #7835, which avoids the double-sort.

@wjones127 wjones127 closed this Jul 20, 2026
wjones127 added a commit that referenced this pull request Jul 21, 2026
…column (#7835)

## Problem

A JSON-path scalar index (`target_index_type=btree`) returns wrong
results when the
indexed path holds float64 values that are not exactly representable
(e.g. `40.1`,
`-3.2`): ranges come back empty and equality misses rows.

Fixes #7485.

## Root cause

The btree trainer requires its input sorted by value (page min/max are
taken from the
first/last row of each page). `scan_training_data` sorts by the *raw*
JSON column when
the target requests `TrainingOrdering::Values`, but `JsonIndexPlugin`
extracts a
different value (the value at `path`) from that column downstream, so
the extracted
stream is not actually sorted by value. Silently corrupted page stats
then cause range
and equality lookups to skip pages that hold matching rows.

## Relationship to #7493, #7771, #7819

All three open PRs diagnose this correctly and fix it by adding a second
`SortExec`
after JSON extraction, on top of the raw-column sort the scanner already
performs. That
raw-column sort is wasted work — it sorts a key nothing downstream uses.

This PR instead has `JsonTrainingRequest::criteria()` override the
target's
`TrainingOrdering::Values` to `TrainingOrdering::None` when delegating
to the scanner
(the scanner's sort can't help the extracted value anyway), and sorts
the extracted
`(value, row_id)` stream once, in `train_index`, right before handing it
to the target
trainer. Same correctness fix, one sort instead of two.

## Testing

- `test_json_float_btree_index_unsorted_input`
(rust/lance-index/src/scalar/json.rs):
builds a real JSON/btree index through the plugin trainer/registry with
the issue's
repro values fed in raw storage order, and asserts range/equality
results across 5
cases. Also asserts `JsonTrainingRequest::criteria().ordering ==
TrainingOrdering::None`.
- `test_json_index_non_exact_floats`
(python/python/tests/test_scalar_index.py): the
issue's repro end to end via `create_scalar_index` + `json_get_float`
filters.

`cargo fmt --all`, `cargo clippy -p lance-index --tests -- -D warnings`,
and
`uv run make lint` are clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer A-python Python bindings bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON-path scalar index returns wrong results for non-exact float values (ranges empty; equality misses)

3 participants