Conversation
…h tier The fresh tier served exactly one multi-match shape: a top-level query naming two or more distinct columns, which `fts_plan_shape` decomposes into one arm per leaf before any validation runs. Every other shape was refused. A multi-match naming a single distinct column — `["body"]`, or `["body", "body"]` with two boosts — took the `Bound` early return and was rejected whole by `validate_lsm_fts_query`; a multi-match nested in a boolean or boost was rejected by the memtable's query mapping at any depth. The base table scores all of them as `DisjunctionScorer(children, DisjunctionScore::Max)`, so a WAL-backed table answered with a hard error where its base-only twin answered correctly. Two changes make the fresh tier score every multi-match the way the compound scorer does. `fts_plan_shape` checks for a top-level multi-match before counting columns, so it always plans one arm per leaf: one per column is the cross-column case, several on one column is the same union collapsed to the best hit per row, and the two mix freely. The single-arm case already short-circuits the union. The row-documents-only rule moves with it — collapsing per primary key drops list elements however many columns the leaves name. `FtsQueryExpr` gains a `MultiMatch` node scored as the best child per document, the same contract `combine_boolean` and `combine_boost` apply for their clause algebra, so the node evaluates identically whether its leaves all come from one index or route to one index per column. The memtable's query mapping and granularity visitor map an index-level multi-match onto it, and the LSM validator applies its per-leaf rule to the leaves instead of refusing the shape. Tests: the evaluator keeps the best child rather than a SHOULD's sum; a single-column two-leaf multi-match plans two arms and scores each row exactly as the dominating leaf alone; a MUST over a cross-column multi-match with a MUST_NOT reaches the active memtable and excludes what it names; the memtable mapping test that asserted rejection now asserts the mapped tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The best-child evaluator matches the committed scorer, but top-level single-column multi-match queries should stay on the bound path. Reserve per-leaf union/collapse for queries that actually span columns so the new support also works on MemWAL tables without a primary key and preserves single-index document semantics.
| /// `must: [a in title, b in body]` is a conjunction, not a union of per-column | ||
| /// results — so it stays whole and each source evaluates it across all of them. | ||
| fn fts_plan_shape(query: &IndexFtsQuery) -> Result<FtsPlanShape> { | ||
| if let IndexFtsQuery::MultiMatch(multi) = query { |
There was a problem hiding this comment.
Routing every top-level MultiMatch here to PerColumn makes even the one-arm MultiMatchQuery("lance", ["text"]) hit the cross-column primary-key guard in plan_search. MemWAL explicitly supports FTS without a primary key, and this query has nothing to collapse, so the advertised single-column shape still returns NotSupported. Determine distinct columns before this branch and keep single-column multi-match on Bound (where the new FtsQueryExpr::MultiMatch can evaluate it); use per-leaf plans only when the query truly spans columns.
Reproducer
In active_filtered_search_without_pk_applies_small_limit_after_filter, replace the existing Match query with:
FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new(
"lance".to_string(),
vec!["text".to_string()],
)
.unwrap(),
))Then run cargo test -p lance --lib active_filtered_search_without_pk_applies_small_limit_after_filter -- --nocapture. The existing test should still return IDs [1, 2]; instead planning fails at fts_search.rs:620 with cross-column full-text search requires a primary key.
Problem
The fresh tier served exactly one multi-match shape: a top-level query naming two or more distinct columns, which
fts_plan_shapedecomposes into one arm per leaf before any validation runs. Everything else was refused:MultiMatchQuery("alpha", ["body"]), or["body", "body"]with two boosts — took thecolumns.len() <= 1early return into theBoundpath and was rejected whole byvalidate_lsm_fts_query(mem_wal/scanner/fts_search.rs:243).memtable/scanner/builder.rs:350,:431) at any depth.The base table scores all of these as
DisjunctionScorer(children, DisjunctionScore::Max)(lance-index/src/scalar/inverted/compound.rs:714), so a WAL-backed table answered with a hard error where its base-only twin answered correctly. On the LanceDB side that surfaced as a retryable 503 for ordinary client calls such asMultiMatchQuery("alpha", ["body"])andMultiMatchQuery(...) & MatchQuery(...)(lancedb/sophon#7899).Changes
fts_plan_shapedecomposes every top-level multi-match per leaf. TheMultiMatchcheck moves ahead of the column count, so one leaf per column (cross-column), several leaves on one column, and any mix all plan as independent arms unioned and collapsed to the best hit per row (FirstByPkExec). The single-arm case already short-circuits the union. The row-documents-only rule moves with it: collapsing per primary key drops list elements however many columns the leaves name.FtsQueryExpr::MultiMatch— a best-child node evaluated bycombine_multi_match, alongsidecombine_boolean/combine_boostin the same clause algebra, so it evaluates identically whether its leaves all come from one index or route to one index per column viasearch_cross_column.to_local_exprand the granularity visitor map an index-level multi-match onto it;validate_lsm_fts_queryapplies its per-leaf fuzzy/ANDrule to the leaves instead of refusing the shape. The node participates incolumns(),has_unbound_leaf(),bind_unbound_leaves(), and the boost/column pass-throughs like the other compound nodes.Semantics
Best child per document, matching the compound scorer's
DisjunctionScore::Maxfor a multi-match — not the sum a SHOULD would give. A leaf repeated with a higher boost dominates; a row matching several leaves comes back once at its highest score. Ranking parity between the fresh tier and committed data for every multi-match shape.Tests
test_multi_match_scores_best_child— evaluator keeps the best child, not the sum.multi_match_plans_one_arm_per_leaf_even_on_one_column—fts_plan_shapeon["text", "text"].single_column_multi_match_scores_each_row_by_its_best_leaf— planner: two arms on one column, each row scored exactly as the dominating leaf alone.nested_multi_match_reaches_the_active_memtable—MUST [multi_match(title, body)] MUST_NOT [title:spam]returns rows matching in either column, once, minus the excluded one.cargo test -p lance --lib mem_wal: 727 passed. Clippy clean on the touched files.🤖 Generated with Claude Code