Skip to content

feat(fts): compose boolean and phrase scorers - #8093

Merged
BubbleCal merged 1 commit into
mainfrom
yang/oss-1598-composable-fts-boolean-phrase
Jul 31, 2026
Merged

BubbleCal merged 1 commit into
mainfrom
yang/oss-1598-composable-fts-boolean-phrase

Conversation

@BubbleCal

@BubbleCal BubbleCal commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Feature

Review this PR as the Boolean/Phrase increment only.

What is the new feature?

Extend the composable FTS scorer tree with same-column BooleanQuery and PhraseQuery execution.

Why do we need this feature?

Boolean clauses need exact composition of required, optional, and prohibited matches without materializing join intermediates. Phrase clauses additionally require an approximation/confirmation split so posting iteration remains cheap while positional checks stay exact.

How does it work?

  • Boolean SHOULD clauses use score sums; MUST clauses intersect membership while preserving the existing first-MUST scoring contract; MUST NOT clauses filter confirmed matches.
  • Phrase leaves use the scorer's two-phase matches() hook for positional confirmation.
  • Sum and Boolean score bounds remain conservative before propagating competitive thresholds.
  • Phrase-containing compound queries require an index with positions.
  • Cross-column, incomplete-index, stale-overlay, and Boost-containing shapes continue to use the exact fallback in this layer.

Stack

  1. feat(fts): add composable scorer core for multimatch #8092: scorer core + same-column MultiMatch
  2. This PR: Boolean + Phrase
  3. feat(fts): compose boosted compound scorers #8094: Boost + nested composition

Related: https://linear.app/lancedb/issue/OSS-1598/introduce-a-composable-scorer-interface-for-compound-fts

Validation

  • cargo check -p lance-index -p lance --lib
  • cargo test -p lance-index scalar::inverted::compound::tests --lib
  • cargo test -p lance dataset::tests::dataset_index::test_same_column_compound_scorer_is_exact_and_bounded --lib
  • cargo test -p lance dataset::tests::dataset_index::test_nested_multimatch_limit_propagation --lib
  • cargo test -p lance io::exec::fts::tests::test_boolean_query_parts_searched_metrics --lib
  • cargo test -p lance dataset::scanner::test::test_fts_query_contract_rejects_invalid_values --lib
  • cargo clippy -p lance-index -p lance --lib --tests -- -D warnings
  • cargo fmt --all

@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer enhancement New feature or request labels Jul 30, 2026
@BubbleCal
BubbleCal force-pushed the yang/oss-1598-composable-fts-boolean-phrase branch from 4d9f7d1 to 5d11d3a Compare July 30, 2026 16:43
Base automatically changed from yang/oss-1598-composable-fts-core to main July 31, 2026 09:15
BubbleCal added a commit that referenced this pull request Jul 31, 2026
## Feature

### What is the new feature?

Introduce the posting-backed compound FTS scoring core and use it for
bounded same-column `MultiMatchQuery` execution. The change adds the
internal `ComposableScorer` protocol, a cross-partition `TopKCollector`,
incremental `WandCursor` support, and `CompoundQueryExec` integration.

### Why do we need this feature?

Compound FTS currently falls back to DataFusion plans that remove the
query limit and materialize intermediate results. Leaf FTS already has
WAND/MAXSCORE bounds; this PR establishes the composable scorer and
collector layer needed to preserve exact top-k semantics while applying
the limit inside posting-backed execution.

### How does it work?

- Scorers expose ordered document iteration, exact scores, shallow score
bounds, competitive-score feedback, and two-phase confirmation.
- One collector owns the competitive threshold across all index
partitions and applies deterministic score/row-id tie ordering.
- Same-column, fully indexed, fresh `MultiMatchQuery` shapes use DisMax
over posting-backed leaves.
- Cross-column, incomplete-index, stale-overlay, and unsupported query
shapes retain the existing exact fallback.

## Stack

1. This PR: scorer core + same-column MultiMatch
2. #8093: Boolean + Phrase
3. #8094: Boost + nested composition

Related:
https://linear.app/lancedb/issue/OSS-1598/introduce-a-composable-scorer-interface-for-compound-fts

## Validation

- `cargo check -p lance-index -p lance --lib`
- `cargo test -p lance-index scalar::inverted::compound::tests --lib`
- `cargo test -p lance
dataset::tests::dataset_index::test_same_column_multimatch_uses_compound_scorer
--lib`
- `cargo test -p lance
dataset::tests::dataset_index::test_nested_multimatch_limit_propagation
--lib`
- `cargo clippy -p lance-index -p lance --lib --tests -- -D warnings`
- `cargo fmt --all`

---------

Co-authored-by: Yang Cen <yang@lancedb.com>
@BubbleCal
BubbleCal marked this pull request as ready for review July 31, 2026 09:16
@BubbleCal
BubbleCal force-pushed the yang/oss-1598-composable-fts-boolean-phrase branch from 5d11d3a to bea5caa Compare July 31, 2026 09:25
@BubbleCal
BubbleCal force-pushed the yang/oss-1598-composable-fts-boolean-phrase branch from bea5caa to 952f7db Compare July 31, 2026 10:05

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.66667% with 98 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/scalar/inverted/compound.rs 76.45% 48 Missing and 33 partials ⚠️
rust/lance/src/dataset/scanner.rs 78.84% 6 Missing and 5 partials ⚠️
rust/lance-index/src/scalar/inverted/wand.rs 70.00% 3 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@BubbleCal
BubbleCal merged commit 662659f into main Jul 31, 2026
40 checks passed
@BubbleCal
BubbleCal deleted the yang/oss-1598-composable-fts-boolean-phrase branch July 31, 2026 11:19
BubbleCal added a commit that referenced this pull request Aug 1, 2026
## Feature

Stacked on #8093 (which is stacked on #8092). Review this PR as the
Boost increment only.

### What is the new feature?

Add posting-backed `BoostQuery` composition, including nested Boost
nodes inside same-column Boolean/Phrase scorer trees.

### Why do we need this feature?

Boost scoring subtracts a weighted negative score from the positive
score. This can produce signed results, so ordinary non-negative
upper-bound propagation is insufficient for exact bounded top-k
execution.

### How does it work?

- `BoostScorer` is driven by the positive scorer and lazily
confirms/scores the negative side on matching documents.
- Signed score bounds conservatively subtract the negative range without
underestimating the parent upper bound.
- Competitive thresholds propagate to the positive side only when the
negative scorer is known to be non-negative.
- Match and Boost multipliers are rejected unless finite and
non-negative.
- Nested Boolean + Phrase + Boost execution is covered by the
same-column end-to-end test.
- Cross-column, incomplete-index, and stale-overlay shapes retain the
exact fallback.

No performance improvement is claimed here; this PR validates exact
bounded execution semantics.

Related:
https://linear.app/lancedb/issue/OSS-1598/introduce-a-composable-scorer-interface-for-compound-fts

## Validation

- `cargo check -p lance-index -p lance --lib`
- `cargo test -p lance-index scalar::inverted::compound::tests --lib`
- `cargo test -p lance
dataset::tests::dataset_index::test_same_column_compound_scorer_is_exact_and_bounded
--lib`
- `cargo test -p lance
dataset::tests::dataset_index::test_nested_multimatch_limit_propagation
--lib`
- `cargo test -p lance
io::exec::fts::tests::test_boolean_query_parts_searched_metrics --lib`
- `cargo test -p lance
dataset::scanner::test::test_fts_query_contract_rejects_invalid_values
--lib`
- `cargo clippy -p lance-index -p lance --lib --tests -- -D warnings`
- `cargo fmt --all`

---------

Co-authored-by: Yang Cen <yang@lancedb.com>
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 2, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 5, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 6, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 10, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 10, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 12, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 14, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 14, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 16, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants