Skip to content

feat(mem_wal): evaluate cross-column predicates on the fresh tier - #9292

Merged
hamersaw merged 2 commits into
lance-format:mainfrom
hamersaw:feat/memwal-fts-cross-column-predicate
Sep 16, 2026
Merged

hamersaw merged 2 commits into
lance-format:mainfrom
hamersaw:feat/memwal-fts-cross-column-predicate

Conversation

@hamersaw

Copy link
Copy Markdown
Contributor

Follow-up to #9172. That PR gave the fresh tier cross-column multi-match by decomposing it into per-column searches; this one gives it cross-column predicates, which cannot be decomposed.

The problem

plan_search bound the whole query to one column, so a boolean or boost whose leaves named different fields had nowhere to go and was refused:

// must: [alpha in title, beta in body]
FtsQuery::Boolean(BooleanQuery::new(vec![
    (Occur::Must, MatchQuery::new("alpha").with_column(Some("title"))),
    (Occur::Must, MatchQuery::new("beta").with_column(Some("body"))),
]))

That is one predicate over both fields — an intersection — so the per-column decomposition #9172 uses for multi-match would answer a different question. It refused rather than guess, and cross_column_boolean_is_refused pinned that.

The committed path has no such gap: CrossColumnCompoundQueryExec serves the bounded fully-indexed shape and the DataFusion fallback serves the rest. Only the fresh tier refused.

The clause algebra was never missing

The committed compound scorer sums MUST (RequiredConjunctionScorer), sums SHOULD into the surviving set (DisjunctionScore::Sum), excludes MUST_NOT, and subtracts negative_boost * negative_score for a boost. search_boolean and search_boost on the memtable already did exactly that, over a HashMap<DocumentKey, f32>.

What was missing was the ability to reach two indexes. FtsQueryExpr carried no column on its leaves, the scanner's FtsQuery was { column, expr }, and FtsIndexExec resolved a single FtsMemIndex.

The change

The tree walk moves out of the index rather than the column moving into it. FtsMemIndex keeps its leaf scorers and grows search_leaf_bounded; combine_compound holds the clause algebra once, parameterized by a leaf evaluator, and both the single-index walk and the new search_cross_column go through it. One implementation, two routings — rather than a second copy that has to agree with the first.

FtsQueryExpr leaves gain an optional column binding, and FtsQuery::new binds unbound leaves to its column, so every existing caller behaves exactly as before.

Leaves from different indexes need one visibility cut. Each index snapshots its own {partitions, tail} view and those can disagree about how far the memtable has advanced, while filter_by_visibility runs after the combine — so a MUST across columns could drop a row both columns actually contain. search_leaf_bounded takes the exec's max_readable_row and clamps each leaf before the clauses meet. This is the one part of the change that is not mechanical.

The planner generalizes to a column slice. Per-column granularity and index settings move into resolve_column_index_contract and are required to agree. Dropping the single-column rebinding in build_source_plan is what lets the base and SSTable arms use the dataset scanner's own cross-column path unchanged — no new code there at all. Row documents only, matching validate_row_leaf_granularities.

One uncovered column sends every queried column through the transient store. The clauses meet on row_position, and positions are only comparable within one store, so a mixed maintained/transient routing would compare across two. Re-indexing a maintained column costs one tokenize pass over a memtable already paying for the missing one; the alternative buys that pass and costs the invariant.

CompoundQueryExec and CrossColumnCompoundQueryExec gain with_limit. Their top-k lives in FtsSearchParams rather than an enclosing fetch node and their segment selections are private, so a caller over-fetching to survive a later dedup had no way to raise it without rebuilding the node over a wider domain.

Ranking

A cross-column MUST sums two per-column BM25 scores, each local to its own index and its own tier, so the local-scoring divergence this module documents compounds rather than merely applies. Matching stays exact; only order moves. Same trade #9172 recorded for multi-match, one step wider.

Tests

cargo test -p lance --lib mem_wal: 720 passed.

New in fts_search: a cross-column MUST returns only the row matching in both columns; a cross-column MUST_NOT drops the row carrying the excluded term in the other field; a cross-column boost demotes rather than drops, and the demoted row's score is strictly lower; a column with no maintained in-memory index still contributes through the transient store; an unbound leaf is refused.

cross_column_boolean_is_refused is superseded — that restriction is what this lifts.

🤖 Generated with Claude Code

`plan_search` bound the whole query to one column, so a boolean or boost
whose leaves named different fields had nowhere to go and was refused.
`must: [alpha in title, beta in body]` is one predicate over both fields —
an intersection — so the per-column decomposition that serves a multi-match
would answer a different question, and 3A refused it rather than guess.

The clause algebra was never the problem. The committed compound scorer
sums MUST (`RequiredConjunctionScorer`), sums SHOULD into the surviving set
(`DisjunctionScore::Sum`), excludes MUST_NOT, and subtracts
`negative_boost * negative_score` for a boost; `search_boolean` and
`search_boost` on the memtable already did exactly that. What was missing
was the ability to reach two indexes: `FtsQueryExpr` carried no column on
its leaves, the scanner's `FtsQuery` was `{ column, expr }`, and
`FtsIndexExec` resolved a single `FtsMemIndex`.

So the tree walk moves out of the index rather than the column moving into
it. `FtsMemIndex` keeps its leaf scorers and grows `search_leaf_bounded`;
`combine_compound` holds the clause algebra once, parameterized by a leaf
evaluator, and both the single-index walk and the new `search_cross_column`
go through it. `FtsQueryExpr` leaves gain an optional column binding, and
`FtsQuery::new` binds unbound leaves to its column so every existing caller
behaves as before.

Leaves from different indexes need one visibility cut. Each index snapshots
its own `{partitions, tail}` view and those can disagree about how far the
memtable has advanced, while `filter_by_visibility` runs *after* the
combine — so a MUST across columns could drop a row both columns contain.
`search_leaf_bounded` takes the exec's `max_readable_row` and clamps each
leaf before the clauses meet.

The planner generalizes to a column slice. Per-column granularity and index
settings move into `resolve_column_index_contract` and are required to
agree; the single-column rebinding in `build_source_plan` goes away, which
is what lets the base and SSTable arms use the dataset scanner's own
cross-column path unchanged. Row documents only, matching
`validate_row_leaf_granularities`. When any queried column lacks a
maintained in-memory index, the transient store covers *every* queried
column: the arm routes leaves within one store, and re-indexing a
maintained column costs one tokenize pass over a memtable already paying
for the missing one.

`CompoundQueryExec` and `CrossColumnCompoundQueryExec` gain `with_limit`.
Their top-k lives in `FtsSearchParams` rather than an enclosing fetch node
and their segment selections are private, so a caller over-fetching to
survive a later dedup had no way to raise it.

Ranking note for the sophon side: a cross-column MUST sums two per-column
BM25 scores, each local to its own index and its own tier, so the existing
local-scoring divergence compounds rather than merely applies. Matching
stays exact; only order moves.

Supersedes `cross_column_boolean_is_refused` — that restriction is what
this lifts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the enhancement New feature or request label Sep 16, 2026
@hamersaw
hamersaw marked this pull request as ready for review September 16, 2026 14:56
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 16, 2026
Both constructors set `FtsQuery::columns` to exactly `expr.columns()`, so
it was a cached copy of what the leaves already carry — and `expr` is
public, so any reassignment would leave the copy stale. The on-disk path
never kept one: `collect_query_columns` and `compound_leaf_columns` walk
the tree on demand.

`columns()` replaces the field and subsumes `is_cross_column()`, whose
only caller now branches on the list it already resolved. The walk runs
at plan time and once per `execute()`, never per batch.

A tree with no leaves at all (an empty boolean) now names no column
rather than falling back to the column passed to `new`, so it reaches the
existing "names no column to search" error instead of resolving an index
and returning nothing. No caller constructs one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 16, 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.

The new head removes the cached column list and derives routing directly from the query tree, preventing the public expression and planner state from diverging. The existing cross-column clause algebra, shared readable-row bound, transient-index fallback, and single-column behavior remain intact; focused verification found no blocking issue.

Please mark this PR with the breaking-change label.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 16, 2026
@hamersaw
hamersaw merged commit ed7b559 into lance-format:main Sep 16, 2026
39 checks passed
@hamersaw
hamersaw deleted the feat/memwal-fts-cross-column-predicate branch September 16, 2026 20:31
xuanyu-z added a commit to xuanyu-z/lance that referenced this pull request Sep 16, 2026
lance-format#9292 gave the fresh tier cross-column predicates, generalizing the FTS
planner from one column to a slice; this branch routes every sealed
generation through one resolution so a rename is followed. They meet in
`build_source_plan`'s SSTable arm and in the per-column index contract.

The arm now resolves every queried column to the name the generation
stores it under, rather than just the one. Three cases follow: nothing
moved, so the tree reaches the scanner untouched and a cross-column
predicate keeps its own leaf bindings; one column moved, so the whole
tree binds to the stored name as before; several columns with one moved,
which `with_column` cannot express -- it rebinds the whole tree and would
collapse the predicate onto a single field -- so that is refused rather
than ranked on the wrong column.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants