Skip to content

feat(fts): score unindexed fragments in combined_fields - #9444

Open
sbrunk wants to merge 2 commits into
lance-format:mainfrom
sbrunk:combined-fields-bm25f-part2
Open

sbrunk wants to merge 2 commits into
lance-format:mainfrom
sbrunk:combined-fields-bm25f-part2

Conversation

@sbrunk

@sbrunk sbrunk commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Follow up to #7905, which added combined_fields (BM25F) but returned an error whenever a query reached a fragment that one of the target columns had not indexed. This PR replaces that error with a flat scan over those fragments, so combined_fields also works on rows added since the indexes were built.

What changes

Commit Files + − Subject
c13ec836a 15 1,899 115 feat(fts): score unindexed and partially indexed fragments in combined_fields
a10549942 4 2,374 8 test(fts): cover the combined_fields flat scan and overlay corpus statistics
Total 17 4,273 123 about 2,200 lines of it tests

The query API is untouched: query.rs, parser.rs, tokenizer.rs and traits.rs are unchanged. CombinedFieldColumn gains one field, stale_rows, which is public but internal to scoring. Most of the 123 deleted lines are in code from #7905, mainly the error path that the union plan replaces. One #7905 test changes meaning: in test_element_document_nested_lists_use_deepest_boundary, a normal scan over the appended fragment now returns all four rows instead of the coverage error. Its fast_search and post-optimize checks are unchanged.

Before, on a dataset with one appended fragment:

Invalid user input: combined_fields requires every target column to be indexed
over every scanned fragment, but 1 of 2 fragments are not fully covered.
Optimize the indexes on body, title and retry.

After, the same query returns those rows, ranked correctly against the indexed ones.

How it works

plan_combined_fields_query
        │
        └─ some fragment not covered → union of two children
             │
             ├── CombinedFieldsQueryExec  (restricted to the covered fragments)
             │
             └── FlatCombinedFieldsExec   (the remaining fragments)
                   • reads the column values and blends dl' and tf' per row
                   • publishes the corpus statistics that both children use
             │
             ▼
        UnionExec → SortExec(score DESC, row_id ASC) → fetch(k)

Both children score against the same corpus. Only the flat side can see the rows that no index covers, so it measures what they contribute and publishes the combined statistics. The indexed side waits for those rather than using only its own docCount', docFreq' and avgdl'. Getting this wrong would be hard to spot: the right rows still come back, but with scores that depend on which child happened to produce them.

A fragment counts as not covered if any target column's index is missing it, or if a newer data overlay made its entries stale. Coverage stays a per fragment decision, because a row has to be scored either entirely from the index or entirely from the scan.

A stale row needs one more step. The index holds its pre-overlay value until the index is optimized, and the flat scan folds in its current one, so counting both inflates docCount and docFreq. That is enough to reorder results after an overlay that changed no searchable value at all. InvertedIndex::bm25_row_stats_for_terms therefore takes the stale rows and subtracts what they still contribute, reading posting membership to do it. Only a column that actually has stale rows pays for that.

Correctness

  • Adds 15 test functions and 23 cases to dataset_fts_combined_fields.rs, taking it from 13 functions and 16 cases to 28 and 39, plus overlay coverage in dataset_overlay_index_masking.rs. 62 tests match the combined filter in lance --lib, and scalar::inverted is at 884 of 884.
  • Every dataset test checks exact scores against the brute force oracle reference, which recomputes every statistic from the raw text and shares no code with the scan. A wrong shared corpus still returns almost the right rows in almost the right order, so a test that only checked which rows came back would pass anyway.
  • Covered: mixed indexed and flat plans, per column index skew over both row id schemes, partially indexed columns, an unindexed row winning top k, fragments made stale by an overlay, deletions followed by optimize, external row masks, filters, nulls and empty strings on the flat path, list and large list and nested columns, a column under a list, and the plan shape itself, so a query that should union cannot answer from the index alone.
  • test_fts_combined_fields_overlay_preserves_every_score pins the stale row subtraction by comparing score bit patterns across a same-value overlay. Ids alone would not catch it, because the same rows come back either way.
  • test_fts_combined_fields_tied_scores_are_deterministic covers tie ordering for both plan shapes. A fully covered dataset returns the exec with no SortExec above it, while a mixed plan gets its order from the sort's second key, so the two shapes need separate checks.

Stack

The branches that build on this one. They live on a fork, and GitHub cannot stack PRs across forks, so each dependent PR also shows this PR's commits. The compare links give the real diff for each.

main (includes #7905)
└── combined-fields-bm25f-part2           #9444  ← this PR (flat scan)
     ├── fts-builder-doc-order
     │    └── combined-fields-maxscore
     │         └── combined-fields-block-skip
     │              └── combined-fields-bench
     ├── fts-json-stream-schema
     ├── combined-fields-python           #8550
     └── combined-fields-java             #8549
Branch (diff) Base PR Cmts Files + − Description
combined-fields-bm25f-part2 main #9444 2 17 4,273 123 flat scan for fragments the indexes do not cover
fts-builder-doc-order part 2 none yet 1 1 493 0 order inverted index docs by row_id at build time
combined-fields-maxscore builder-doc-order none yet 1 8 903 82 prune candidate scoring with MAXSCORE
combined-fields-block-skip maxscore none yet 1 10 1,091 106 skip posting blocks a row id seek jumps past
combined-fields-bench block-skip none yet 1 4 1,191 1 Lance vs Lucene BM25F validation harness
fts-json-stream-schema part 2 none yet 1 1 184 1 tests for flat scans over JSON columns (the JsonTextStream schema fix itself landed upstream in #9479)
combined-fields-python part 2 #8550 2 5 227 1 Python binding for combined_fields
combined-fields-java part 2 #8549 1 4 321 24 Java binding for combined_fields

@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer enhancement New feature or request labels Sep 20, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 20, 2026
@sbrunk
sbrunk force-pushed the combined-fields-bm25f-part2 branch from c09f32d to 29393b2 Compare September 21, 2026 05:23
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Sep 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 21, 2026
@sbrunk
sbrunk force-pushed the combined-fields-bm25f-part2 branch from 29393b2 to 4ff1ad0 Compare September 25, 2026 14:18
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 25, 2026
Xuanwo pushed a commit that referenced this pull request Sep 25, 2026
## TL;DR

Adds a `combined_fields` full text query that scores several text
columns as one virtual field. This is BM25F, the same thing
Elasticsearch calls
[`combined_fields`](https://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf)
and Lucene calls `CombinedFieldQuery`. Today the only option is
`MultiMatch`, which scores each column on its own and then keeps the
best one.

This PR covers the case where every target column is fully indexed. If a
query reaches a fragment that one of the target columns has not indexed,
it returns an error. Removing that restriction is the follow up PR,
#9444, which adds a flat scan over those fragments. See [Stack](#stack).

Language bindings are separate: #8549 (Java) and #8550 (Python).

@Xuanwo this is related to some of the work you've been doing on FTS so
it'd be great if you could have a look.

## Why

Lance can already search several columns with `MultiMatchQuery`, but
each column is scored against its own statistics, and the results are
combined by taking the highest score. That is what Elasticsearch calls
`best_fields`. There is no real cross field BM25. A term that is rare in
`title` but common in `body` ends up with two IDF values that cannot be
compared to each other, and a query like `john` in `first_name` plus
`smith` in `last_name` cannot be scored as if both columns were one
field. BM25F blends the statistics so the columns behave like a single
field, with a weight per column.

## What is in this PR

| Commit | Files | + | − | Subject |
|---|---:|---:|---:|---|
| `9f289302c` | 27 | 3,103 | 127 | feat(fts): add combined_fields
(BM25F) cross-field search |
| `30c7fc939` | 13 | 2,061 | 45 | test(fts): cover combined_fields end
to end |
| **Total** | **34** | **5,164** | **172** | 34 distinct files,
per-commit file counts overlap |

About 2,200 of the added lines are tests.

The whole public API is in this PR: `CombinedFieldsQuery`, its JSON form
in the FTS parser, tokenizer validation, and the row granularity rules.
The follow up PR leaves all of it alone. `query.rs`, `parser.rs`,
`tokenizer.rs` and `traits.rs` are unchanged there. So the API review
happens here, and the follow up is only about execution.

## How it works

For each query term `t` and each column `f` with weight `w_f`, following
Lucene's `CombinedFieldQuery`:

```
tf'(t,d)   = Σ_f w_f · tf_f(t,d)            docFreq'(t) = max_f docFreq_f(t)
dl'(d)     = Σ_f w_f · dl_f(d)              docCount'   = max_f docCount_f
sumTTF'    = Σ_f w_f · sumTotalTermFreq_f   avgdl'      = sumTTF' / docCount'
score(t,d) = idf'(t) · (k1+1)·tf' / (tf' + k1·(1 - b + b·dl'/avgdl'))
```

How a query runs:

```
CombinedFieldsQuery(cols, terms, weights)
        │
        ▼
plan_combined_fields_query ── which fragments does every target column's
        │                     index cover, and which ones have entries that
        │                     a newer data overlay made stale?
        │
        ├─ every target fragment covered
        │     │
        │     ▼
        │   CombinedFieldsQueryExec
        │     • opens every target column's FTS segments
        │     • looks up dl' per candidate via DocSet::doc_length_by_row_id
        │       instead of scanning all documents
        │     • scores the union of the terms' postings, bounded top-k heap
        │
        └─ some fragment not covered → return an error naming the columns
                                       that need optimizing
```

The design has two consequences.

Scores are per row, not per list element. BM25F has to join the target
columns on the row address. If two columns hold lists, their elements
have no correspondence, so there is no way to pair them up.
`combined_fields` reports itself as row granular everywhere the
granularity code asks, and rejects a target column that can only produce
element documents.

Corpus statistics have to use that same granularity. Releases before
#7656 indexed each `List<String>` element as its own document, so those
index files report `docCount` and `docFreq` per element, while this scan
counts per row. Mixing the two produces wrong `idf'` and `avgdl'`
values. That is what `bm25_row_stats_for_terms` is for. It counts
distinct rows, and on V3 it just delegates to the normal path, because
there one row is one document.

## When the query returns an error

Coverage is decided per fragment rather than per row. A BM25F score
needs `tf_f` and `dl_f` from every target column, so a row has to be
scored either entirely from the index or not at all. A fragment counts
as not covered if any target column's index is missing it, or if a newer
data overlay made its entries stale.

If such a fragment is in scope, a normal scan fails with:

```
Invalid user input: combined_fields requires every target column to be indexed
over every scanned fragment, but 1 of 2 fragments are not fully covered.
Optimize the indexes on body, title and retry.
```

`fast_search` is not affected. It only ever reads indexes, so it skips
the fragments that are not covered and returns what the indexes have.

The alternatives were to leave those rows out of the result, or to score
them with statistics that do not describe the data. An error naming the
columns to optimize seemed better than either. #9444 replaces it with a
flat scan over exactly those fragments, using one shared set of corpus
statistics for both sides.

## Correctness

- `scalar::inverted`: 850 of 850 tests pass. That includes the row
granularity statistics path, checked against real released V1 and V2
index files rather than hand built fixtures.
- 13 test functions and 16 cases in
`rust/lance/src/dataset/tests/dataset_fts_combined_fields.rs`. 24 tests
match the `combined` filter in `lance --lib`.
- Every dataset test checks exact scores against
`lance_index::scalar::inverted::oracle`, a brute force BM25F reference
that recomputes every statistic from the raw text and shares no code
with the scan it checks. A wrong corpus size still returns almost the
right rows in almost the right order, so a test that only checked which
rows came back would pass anyway.
- Covered: both row id schemes (row addresses and stable row ids),
nulls, cross field AND, the concatenation identity (a two column query
has to score the same as the same text in a single column), boost
ranking, tokenizer validation, released V1 and V2 format fixtures, and
the `fast_search` paths that skip uncovered fragments. The plan shape is
checked as well, so a query that should return an error cannot answer
from partial statistics instead.
- Tie ordering is fixed by the data. The scan walks candidates in
ascending row id order and sorts equal scores by score descending, then
row id ascending, through `RankedDoc`. That decides both which rows
survive a tie at the k-th score and the order they come back in. It has
to happen inside the search, because when everything is covered the exec
is returned directly with no `SortExec` above it.

## Performance

There are three performance layers in this work, and only the `dl'`
lookup is in this PR (`DocSet::doc_length_by_row_id`). It removes a pass
over all documents just to get their lengths, which is the main cost
when term frequencies are fairly uniform. MAXSCORE pruning and posting
block skipping are in the branches below, so on skewed data
`combined_fields` will be slower than `best_fields` until those land.

## Stack

```
combined-fields-bm25f                     #7905  ← this PR (part 1, indexed BM25F)
└── combined-fields-bm25f-part2           #9444 (part 2, flat scan)
     ├── fts-builder-doc-order
     │    └── combined-fields-maxscore
     │         └── combined-fields-block-skip
     │              └── combined-fields-bench
     ├── fts-json-stream-schema
     ├── combined-fields-python           #8550
     └── combined-fields-java             #8549
```

| Branch (diff) | Base | PR | Cmts | Files | + | − | Description |
|---|---|---|---:|---:|---:|---:|---|
| `combined-fields-bm25f` | main | #7905 | 2 | 34 | 5,164 | 172 | BM25F
over fully indexed data |
|
[combined-fields-bm25f-part2](sbrunk/lance@combined-fields-bm25f...combined-fields-bm25f-part2)
| part 1 | #9444 | 2 | 16 | 4,262 | 112 | flat scan for fragments the
indexes do not cover |
|
[fts-builder-doc-order](sbrunk/lance@combined-fields-bm25f-part2...fts-builder-doc-order)
| part 2 | none yet | 1 | 1 | 493 | 0 | order inverted index docs by
row_id at build time |
|
[combined-fields-maxscore](sbrunk/lance@fts-builder-doc-order...combined-fields-maxscore)
| builder-doc-order | none yet | 1 | 8 | 903 | 82 | prune candidate
scoring with MAXSCORE |
|
[combined-fields-block-skip](sbrunk/lance@combined-fields-maxscore...combined-fields-block-skip)
| maxscore | none yet | 1 | 10 | 1,091 | 106 | skip posting blocks a row
id seek jumps past |
|
[combined-fields-bench](sbrunk/lance@combined-fields-block-skip...combined-fields-bench)
| block-skip | none yet | 1 | 4 | 1,191 | 1 | Lance vs Lucene BM25F
validation harness |
|
[fts-json-stream-schema](sbrunk/lance@combined-fields-bm25f-part2...fts-json-stream-schema)
| part 2 | none yet | 1 | 2 | 223 | 21 | report the real schema from
`JsonTextStream` |
| `combined-fields-python` | part 2 | #8550 | 2 | 5 | 227 | 1 | Python
binding for `combined_fields` |
| `combined-fields-java` | part 2 | #8549 | 1 | 4 | 321 | 24 | Java
binding for `combined_fields` |

The performance and bench branches do not have PRs yet. I wanted to
agree the split first. These are all fork branches, and GitHub cannot
stack PRs across forks, so each dependent PR also shows the commits of
the ones below it. The compare links give the real diff for each.

---------

Co-authored-by: Yang Cen <bubble-cal@outlook.com>
@sbrunk
sbrunk force-pushed the combined-fields-bm25f-part2 branch from 4ff1ad0 to f45ae22 Compare September 25, 2026 16:57
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 25, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 25, 2026
…d_fields

The previous commit refuses a `combined_fields` query whose target columns do
not all cover every scanned fragment, so a default full-text search fails on
any dataset with rows appended since the indexes were built. `MatchQuery`
already unions in a flat scan for its unindexed fragments; this does the same
for BM25F.

Coverage is per column here, which makes it more than a copy of the
single-column path. `dl'` sums each column's document length and a row absent
from a column's `DocSet` contributes 0, so a fragment indexed for `title` but
not `body` cannot be scored from the index at all. The indexed scan is
therefore restricted to the intersection of per-column coverage and
everything else goes to the flat scan, rather than splitting on the union.

Both sides then score against one shared corpus. The flat side alone sees the
rows no index covers, so it measures their contribution and publishes the
blend; the indexed side waits for it instead of folding only its own
`docCount'`/`docFreq'`/`avgdl'`. Without that, a row reached through either
path would rank differently depending on which side happened to score it.

Data overlays are handled by measuring rather than patching. When a target
column carries an overlay-stale index entry, folding the flat row into the
index statistics would double count it against the entry it replaces, and the
flat scan cannot subtract what it replaced. So the corpus is measured from
current data instead: every target fragment is scanned, every row folded into
every column, and the index statistics left out. That costs a full scan of the
target columns, so it stays confined to the stale case. `fast_search` is
unchanged, being index-only by contract.
…tistics

The cases that matter here are the ones where a shared corpus is easy to
lose, since both scan sides must agree on `docCount'`/`docFreq'`/`avgdl'`:
unindexed and partially indexed fragments, per-column index skew over both
row-id schemes, a mixed indexed/flat plan, deletions followed by optimize,
and overlay-stale fragments. Each asserts exact scores against the
brute-force reference, because scoring the two sides against different
corpora still returns the right rows in almost the right order.

Also covers what only the flat path reaches: nulls and empty strings read
from the scan rather than an index, list and nested columns, a column under a
list, filters, and the plan shape itself, so a query that should union does
not silently answer from the index alone.

The external row-address prefilter is covered for the same reason, over both
plan shapes: the fully covered plan ANDs the mask into the indexed scan's
prefilter, while the mixed plan's flat child never reaches an index-side
prefilter and is masked on its output instead. Scores are asserted there too,
because the mask picks what is emitted and not the corpus it is measured
against, so a surviving row must keep the score the unmasked query gave it.
@sbrunk
sbrunk force-pushed the combined-fields-bm25f-part2 branch from f45ae22 to a105499 Compare September 25, 2026 17:19
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 25, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-risk Latest Gatekeeper recommendation includes a non-blocking risk. label Sep 25, 2026

@lance-gatekeeper lance-gatekeeper Bot 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.

⚠️ Gate recommendation: approve with a non-blocking risk.

The only change since the previous reviewed head is an intra-doc link. With the indexed core in #7905 merged, this flat-scan follow-up retains its verified behavior: appended and overlay-stale fragments are scored without double-counting stale index statistics, while covered fragments stay on the indexed path.

The author accepted the remaining candidate-scoped corpus behavior: filtering or fragment selection can make ranking depend on index coverage, including after optimization with unchanged text. #9058 tracks the broader invariant; #9243 addresses single-column Match only. No change requested for this risk here.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 25, 2026
@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. and removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 25, 2026

This branch has not been deployed

No deployments
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 enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant