feat(mem_wal): evaluate cross-column predicates on the fresh tier - #9292
Merged
hamersaw merged 2 commits intoSep 16, 2026
Merged
Conversation
`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>
hamersaw
marked this pull request as ready for review
September 16, 2026 14:56
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>
Contributor
There was a problem hiding this comment.
✅ 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.
wkalt
approved these changes
Sep 16, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_searchbound the whole query to one column, so a boolean or boost whose leaves named different fields had nowhere to go and was refused: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_refusedpinned that.The committed path has no such gap:
CrossColumnCompoundQueryExecserves 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 subtractsnegative_boost * negative_scorefor a boost.search_booleanandsearch_booston the memtable already did exactly that, over aHashMap<DocumentKey, f32>.What was missing was the ability to reach two indexes.
FtsQueryExprcarried no column on its leaves, the scanner'sFtsQuerywas{ column, expr }, andFtsIndexExecresolved a singleFtsMemIndex.The change
The tree walk moves out of the index rather than the column moving into it.
FtsMemIndexkeeps its leaf scorers and growssearch_leaf_bounded;combine_compoundholds the clause algebra once, parameterized by a leaf evaluator, and both the single-index walk and the newsearch_cross_columngo through it. One implementation, two routings — rather than a second copy that has to agree with the first.FtsQueryExprleaves gain an optional column binding, andFtsQuery::newbinds 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, whilefilter_by_visibilityruns after the combine — so a MUST across columns could drop a row both columns actually contain.search_leaf_boundedtakes the exec'smax_readable_rowand 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_contractand are required to agree. Dropping the single-column rebinding inbuild_source_planis 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, matchingvalidate_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.CompoundQueryExecandCrossColumnCompoundQueryExecgainwith_limit. Their top-k lives inFtsSearchParamsrather 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_refusedis superseded — that restriction is what this lifts.🤖 Generated with Claude Code