1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# Search keys
2026-10-17. Every request the live search makes used to build the
whole book. It now reads a short folded key for each card, matches
those keys, and builds only the contacts it found.
## What was slow
Before, each request to `GET /search/results?q=` built every contact
from the index (`Store#listed_by_id`). It then parsed each one and
folded its values for `Admin::Search`. That cost grew with the size of
the book and hardly depended on the query. The book was the 600-card
seed from `2026-10-14-lists-read-the-index.md`, scaled to 1,000 cards,
with requests sent in process:
| Query | Before | After |
|---|---|---|
| `zzz` (no hits) | 510 ms | 18 ms |
| `ada` (66 hits) | 380 ms | 48 ms |
| `ad` (134 hits) | 420 ms | 67 ms |
| `a` (every card) | 457 ms | 364 ms |
| blank (10 recent) | 168 ms | 17 ms |
The browser was never the cause, because the results always came from
the server (`2026-09-30-live-search.md`).
A query that matches every card still had to build and render every
card, at 364 ms for "a". So the contacts now stop at 50, followed by a
line counting the rest and asking for a longer query. This reverses
the live-search plan's "uncapped, with no see all": a list of hundreds
was never read, and the next letter typed narrows it anyway.
## Shape
- `search_keys` holds one row per card, an `Admin::Search::Key` as
JSON: the folded text fields, each number's digits, and the words of
the name and nickname, for ranking. `Store#reindex` writes the row
next to the card's other index rows, and `#rebuild_index` derives it
again. Migration 014 fills it for the cards that are already stored.
The migration uses the app's own `Search.key`, unlike 012, which
spells its rule out so it cannot drift. This table is index, not
data, so what it has to agree with is the search that reads it today.
- `Admin::Search` is still the one place that decides a match. It
builds the key (`Search.key`) and reads it. The matching rules have
not changed.
- A group's contribution is keyed at search time from its label and
from what it lends (`Group#reading`). It is never copied into its
members' keys, because then a rename or a change to a lent line
would have to rewrite every member's row. A group lends no name, so
a match on a group's label never ranks as a name hit.
- A blank query and the dashboard read the 10 newest rows. They no
longer build the whole book in order to show 10 contacts.
## Why not FTS5
The trigram tokenizer handles substrings and, in the bundled SQLite
3.53, folds diacritics, so "zoe" finds Zoë. It cannot match a word
shorter than three characters, though, and every search starts with
those keystrokes. Phones would still need their own digit column. The
ranking would stay in Ruby, and group labels would have to be copied
into every member's row. At family size, walking the stored keys
already takes milliseconds. If the book ever grows enough that the
walk becomes slow, these same columns can feed an FTS5 table.