Search: whole words first, then as prefixes #340

Closed
kayg wants to merge 0 commits from issue/44-search-exact into dev
Owner

Part of #44 (search p95 under 50 ms at 100k). On top of #315: merge that first; this PR's own change is the last commit (80f7ff9). It is independent of #338 and the titles PR.

Searching for a typed word as a prefix (coffee*) makes FTS5 merge every note holding any word with that start into one list before it answers. When the word is common, that takes 7 to 25 ms per word at 100,000 notes, and a time budget can't interrupt it. A whole word is different: FTS5 walks its list from the newest end and stops once it has enough matches, in under half a millisecond.

So the newest-matches lookup now tries words of four letters or more as whole words first. If that fills the list (150), longer words that only start the same way (coffeehouse for coffee) aren't needed. If it doesn't, the prefix form runs as before, so a word still being typed (coff) is still found. Words of two or three letters stay prefixes, because the index keeps ready lists for them.

Measured, FTS5 alone, newest 150, on the 100k harness index:

expression prefix whole
morning, walk 25.5 ms 0.45 ms
garden, plan 13.9 0.36
coffee 7.0 0.25

With #338 and the titles PR, every harness query takes 0.7–3.9 ms of search time (it was 9–34). Perf-VM numbers will be posted on #44.

cargo test -p calternal-search and clippy are clean. The existing prefix and typo tests still pass: few matches still take the prefix path.

🤖 Generated with Claude Code

Part of #44 (search p95 under 50 ms at 100k). **On top of #315**: merge that first; this PR's own change is the last commit (80f7ff9). It is independent of #338 and the titles PR. Searching for a typed word as a prefix (`coffee*`) makes FTS5 merge every note holding any word with that start into one list before it answers. When the word is common, that takes 7 to 25 ms per word at 100,000 notes, and a time budget can't interrupt it. A whole word is different: FTS5 walks its list from the newest end and stops once it has enough matches, in under half a millisecond. So the newest-matches lookup now tries words of four letters or more as whole words first. If that fills the list (150), longer words that only start the same way (`coffeehouse` for `coffee`) aren't needed. If it doesn't, the prefix form runs as before, so a word still being typed (`coff`) is still found. Words of two or three letters stay prefixes, because the index keeps ready lists for them. **Measured**, FTS5 alone, newest 150, on the 100k harness index: | expression | prefix | whole | |---|---|---| | morning, walk | 25.5 ms | 0.45 ms | | garden, plan | 13.9 | 0.36 | | coffee | 7.0 | 0.25 | With #338 and the titles PR, every harness query takes 0.7–3.9 ms of search time (it was 9–34). Perf-VM numbers will be posted on #44. `cargo test -p calternal-search` and clippy are clean. The existing prefix and typo tests still pass: few matches still take the prefix path. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Profiling search on the 100,000-note fixture (perf VM p95 ~460 ms)
found three costs, none of them the index itself:

- Typo vocabulary: it was rebuilt on the query path whenever the index
  had changed, and building it reads the whole index (1.6 s at 100k).
  It is now asked for only when exact matches are few, and a big one is
  rebuilt on a thread of its own while searches use the one they have; a
  small one (quick to build, or under 5,000 items) is still rebuilt in
  place.
- Ranking with many matches: the 4 ms budget couldn't stop it, because
  FTS5 merges a common word's whole posting list in one step that never
  reaches the progress handler: 0.3 to 0.8 s. With many matches, titles,
  recency and frecency already cover the candidates, so it is skipped.
- Frecency: `MATCH ... AND rowid IN (200 ids)` runs the whole match
  again for every listed row (0.6 to 1.6 s for 30 rows). The recently
  used items are now checked on their text, stopping at the first words
  that satisfy the query.

The search tests' settle helper waits 250 ms (was 30) for the indexer to
pick up an emitted change; on a loaded CI host 30 ms was sometimes too
short (`the_index_follows_changes_live`).

Part of #44

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Phase timings of steady-state searches at 100,000 notes on the perf VM:
checking the most-used items took 25 to 45 ms (it reads their text, long
notes included), and ranking title matches about 30 ms (FTS5 scores every
note holding the words, in any column, before keeping titles).

- The most-used items are checked only when there are many matches:
  with few, the newest matches already hold all of them. And only the
  64 most recent of them.
- Title matches come newest first and the ranking that orders all
  candidates orders them too.

Part of #44

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Search: one letter looks at titles; short words aren't typo-fixed
Some checks failed
CI / server (pull_request) Successful in 23s
CI / rust (pull_request) Successful in 1m59s
CI / web (pull_request) Failing after 2m48s
29ef5cf68a
More phase timings at 100,000 notes on the perf VM:
- A one-letter query (the palette's first keystroke) took 75-100 ms: a
  one-letter prefix isn't in FTS5's prefix lists, so the body search
  merged every word with that letter. One letter now looks at titles.
- One or two letters with few matches went through typo correction,
  which has nothing to fix in them: now only words of three letters or
  more are corrected.
- Checking the most-used items read their whole text, 50,000-word notes
  included; now their first 16 KB, where what they are about usually is.

Steady state at 100k (index caught up), 110 queries: p50 58-62 ms, p95
79-85 ms (dev: p50 85-165, p95 135-560).

Part of #44

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Search: whole words first, then as prefixes
Some checks failed
CI / server (pull_request) Successful in 23s
CI / rust (pull_request) Successful in 1m42s
CI / web (pull_request) Failing after 2m27s
80f7ff9cd1
A typed word as a prefix (`coffee*`) makes FTS5 gather every note holding
any word that starts that way into one list before answering: 7 to 25 ms
a word at 100,000 notes when the word is common. As a whole word, FTS5
walks its list from the newest end and stops at enough matches: under
half a millisecond. So the newest matches are first looked up with words
of four letters or more whole; only if that finds too few does the
prefix form run, so a word still being typed is found as before. Words
of two or three letters stay prefixes: the index keeps their lists
ready. (#44)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Merged into dev via train j (#391).

Merged into dev via train j (#391).
kayg closed this pull request 2026-10-15 02:23:00 +00:00
Some checks failed
CI / server (pull_request) Successful in 23s
CI / rust (pull_request) Successful in 1m42s
CI / web (pull_request) Failing after 2m27s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!340
No description provided.