Search in Text2Store started as a linear scan written in Rust, and for most people it still is. That deserves an explanation before the optimization does, because the scan is not naivety, it is a consequence of where Apple keeps the text.
Why the scan is not a SQL query
You cannot search these messages with a LIKE clause. A real fraction of message text, roughly a tenth in the archives we have measured, does not live in the text column at all; it lives in attributedBody, a serialized typedstream blob that has to be decoded before there is anything to match against. So search decodes each message the same way the reader does, assembles the same combined text the thread displays, and checks it in Rust. On a ten-thousand-message backup that is single-digit milliseconds, and single-digit milliseconds is not a problem worth solving.
Then you meet an archive with 84,000 messages in one thread, and it is.
An index that is only allowed to narrow
The design constraint we set before writing any of it: the index may decide what we bother to decode, and it may never decide what matches. It is a pure pre-filter. SQLite's FTS5 answers one question, "which message row IDs contain this substring," and hands back a candidate set. The original scan loop then runs, unchanged, over only those candidates, applying the same exact lowercase-contains check and every other filter exactly as before.
That means results are identical to the unindexed scan by construction rather than by hope, and it means the failure mode of the index is wasted work rather than a missing message. If the index is stale, absent, mid-build, or broken, search falls back to scanning everything and returns the right answer more slowly. Correctness never depends on the accelerator being present or correct.
A parity test pins it down: the same corpus is searched both ways and the result sets must match, including the cases most likely to diverge, mid-word substrings and mixed case. That test is the reason this optimization is allowed to exist.
Trigrams, because users expect substrings
FTS5's default tokenizer is word and prefix based. Under it, searching esc does not find escrow, and nothing at all finds a match in the middle of a word. Our existing search matched substrings anywhere, which is what people actually expect from a message search box, so switching tokenizers would have been a silent behavior change dressed up as a performance improvement.
The trigram tokenizer preserves substring matching, at the cost of a larger index and a three-character minimum. Queries shorter than that skip the index entirely and scan, which is fine, because a one or two character query matches so much that narrowing was never the bottleneck. The user's text is also quoted into the FTS query with internal quotes doubled, so a message search can never accidentally become FTS query syntax.
Why there is a threshold
The index only builds for backups above fifty thousand messages, and that number is doing real work. Building the index costs about six times a single scan, because the build pays the typedstream decode for every message once. Below the threshold, the scan is already imperceptible, so indexing would make the first search noticeably slower in exchange for a saving nobody can feel, plus a temp file and memory per backup.
Above it, the arithmetic inverts. On a 60,000-message text-only backup, a repeat search measured about 8 ms against roughly 35 ms for the scan. On real archives the gap is wider than that benchmark suggests, because the scan pays the attributedBody decode on every message on every search, while the index pays it once at build time and never again.
This is the general shape of an honest optimization: it is not free, so it should only run where it wins. A threshold with a measurement behind it is worth more than a clever heuristic.
Building it before anyone asks
A lazily built index has an ugly first search, the one where the user types and waits for a build they did not ask for. So the build is fired in the background the moment a backup is opened for viewing, on its own thread, while the user is still reading a conversation. By the time anyone types, it is ready.
Search-as-you-type creates the obvious hazard: several searches arriving while the build is still running, each tempted to start its own. A per-index lock handles it, so the first search through builds and the rest simply wait for it. If the prewarm never ran, the first search builds the index itself. Every path converges on the same answer; they differ only in how long they take.
Where a plaintext index is allowed to live
This is the part that took the most thought, and it is not a performance question at all. A full-text index contains the message text in the clear. Write one next to an encrypted archive and you have quietly undone the encryption, in a file the user does not know exists and which could travel inside an exported bundle.
So the index is never written into the archive. Archives are frozen snapshots with integrity checksums, and mutating one to add an index would break both the guarantee and the checksums (the certification chapter covers why that matters). It is always a sidecar, and where that sidecar goes depends on what it holds. For an encrypted backup, the index is built inside the per-session decrypted scratch directory, which is already plaintext, already understood to be temporary, and already wiped on launch and on lock. For a plaintext backup, it goes to local temp. Never on the storage drive, which may be exFAT, may be read-only to the bundled SQLite, and is the one place the user thinks of as permanent.
The index is stamped with the source database's length and modification time, so a re-sync invalidates it automatically, and every index is wiped at startup along with the rest of the decrypted scratch. It is disposable on purpose. The archive is the thing that lasts; the index is just an opinion about it that we are willing to rebuild at any time.