Search: walk the few used things, not every item, to find them #338

Closed
kayg wants to merge 0 commits from issue/44-search-frecent 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 (30bfbed).

Finding the 64 things used most recently joined every item to the used ones. Without table stats, SQLite walked all 100,000 items and looked each one up among the used ones. That cost 16 ms on every search with many matches, even when nothing had been used yet. CROSS JOIN makes it walk the used things first. A new index, frecency(at), gives the newest without sorting them all. CREATE INDEX IF NOT EXISTS adds it to existing indexes on open, with no rebuild.

The empty-search list (browse) used the same join and gets the same fix.

Measured on the 100k harness index, the harness's queries, each run warm. The numbers are search time only, without HTTP:

query before after
ga 10.0 ms 1.3 ms
garden 20.8 9.6
morning walk 28.6 17.8
coffee 34.4 10.7
#work budget 30.5 14.4

(frecent alone went from 8–12 ms to under 0.1 ms. The rest is the title and prefix lookups, fixed in the two sibling PRs.) Perf-VM numbers for all three together will be posted on #44.

Checked: cargo test -p calternal-search and clippy are clean.

🤖 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 (30bfbed). Finding the 64 things used most recently joined every item to the used ones. Without table stats, SQLite walked all 100,000 items and looked each one up among the used ones. That cost 16 ms on every search with many matches, even when nothing had been used yet. `CROSS JOIN` makes it walk the used things first. A new index, `frecency(at)`, gives the newest without sorting them all. `CREATE INDEX IF NOT EXISTS` adds it to existing indexes on open, with no rebuild. The empty-search list (`browse`) used the same join and gets the same fix. **Measured** on the 100k harness index, the harness's queries, each run warm. The numbers are search time only, without HTTP: | query | before | after | |---|---|---| | ga | 10.0 ms | 1.3 ms | | garden | 20.8 | 9.6 | | morning walk | 28.6 | 17.8 | | coffee | 34.4 | 10.7 | | #work budget | 30.5 | 14.4 | (`frecent` alone went from 8–12 ms to under 0.1 ms. The rest is the title and prefix lookups, fixed in the two sibling PRs.) Perf-VM numbers for all three together will be posted on #44. Checked: `cargo test -p calternal-search` and clippy are clean. 🤖 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: walk the few used things, not every item, to find them
Some checks failed
CI / server (pull_request) Successful in 21s
CI / rust (pull_request) Successful in 2m29s
CI / web (pull_request) Failing after 2m53s
30bfbed85b
Finding the 64 things used most recently joined every item to the used
ones: SQLite, left to choose, walked all 100,000 items. That was 16 ms of
every search at 100,000 notes, even with nothing used yet. CROSS JOIN
walks the used things first, and an index on when they were used finds
the newest without sorting them all. (#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:22:56 +00:00
Some checks failed
CI / server (pull_request) Successful in 21s
CI / rust (pull_request) Successful in 2m29s
CI / web (pull_request) Failing after 2m53s

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!338
No description provided.