fix(mem_wal): build the transient FTS index with positions - #9386
Merged
hamersaw merged 1 commit intoSep 18, 2026
Merged
Conversation
A phrase query over a column with no FTS index returned nothing from the fresh tier while a plain match on the same rows succeeded. The base answers that query — `plan_phrase_query`'s no-index arm plans a flat scan, where token positions are implicit — but the transient index the fresh tier builds for an unindexed column took `InvertedIndexParams:: default()`, whose `with_position` is `false`, and `search_phrase_tokens` returns no hit for a multi-token phrase without positions. Every phrase hit in un-compacted rows was dropped, silently. When no persisted index covers the column, the transient index now carries positions. The persisted-index case still inherits the index's analyzer and positional settings, which is the contract that keeps the active rows agreeing with base and SSTable rows. The transient index lives for one query over the visible prefix, so the extra storage is bounded by that prefix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hamersaw
marked this pull request as ready for review
September 18, 2026 13:43
Contributor
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The fix aligns the transient fresh-tier index with the base table’s unindexed phrase-query semantics while leaving persisted-index analyzer and positional settings unchanged. The added regression covers the no-index path and distinguishes ordered phrases from reversed terms. Reusing the transient index also keeps phrase and compound queries on the existing evaluator instead of adding a separate memtable flat-scan path.
jackye1995
approved these changes
Sep 18, 2026
hamersaw
added a commit
to hamersaw/lance
that referenced
this pull request
Sep 18, 2026
Resolves the test-module conflict in fts_search.rs with lance-format#9386: both sides added tests at the same anchor; keep all of them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Problem
A phrase query over a column with no FTS index silently returns nothing from the fresh tier while a plain match on the same rows succeeds.
The base answers that query:
plan_phrase_query's no-index arm plans a flat scan, where token positions are implicit. The fresh tier serves an unindexed column through a transient index built over the visible prefix (transient_fts_index_store), and with no persisted index to inherit from it takesInvertedIndexParams::default()— whosewith_positionisfalse.search_phrase_tokensthen returnsVec::new()for any multi-token phrase (index/fts.rs, "without them phrase search is unsupported, as on disk"). So every phrase hit in un-compacted rows is dropped, silently, on exactly the tables where the fresh tier exists to make those rows visible.Verified against a live WAL pod (lancedb/sophon): unindexed column,
match body:alpha→[1, 2];phrase body:'alpha prose'→[].Change
When no persisted index covers the column, the transient index is built with positions. The persisted-index case is untouched — it still inherits the index's analyzer and positional settings, which is the contract that keeps active rows agreeing with base and SSTable rows. The transient index lives for one query over the visible prefix, so the extra position storage is bounded by that prefix.
Test
phrase_over_an_unindexed_column_reaches_the_active_memtable: an active memtable with a PK index and no FTS index; a match overalphareturns rows 1 and 2 (precondition: the transient path serves the column), and the phrasealpha prosereturns row 1 only — row 2 carries the terms reversed.cargo test -p lance --lib mem_wal::scanner::fts_search: 41 passed.Sibling of #9363 (multi-match shapes on the fresh tier); independent of it.
🤖 Generated with Claude Code