--- name: Storage FTS5 transcript search type: architecture-map-page slice: 05-core-storage-hotkey-build last_verified: 2026/05/09 --- # Storage FTS5 transcript search > **Where you are:** [Architecture map](../README.md) → [Core, Storage, Hotkey, Build](README.md) → Storage FTS5 transcript search **Plain English summary.** Full-text search over transcripts. SQLite's built-in FTS5 module gives us tokenisation, stemming, and BM25 ranking for free. The `transcripts_fts` virtual table is kept in lock-step with `transcripts` by three triggers; `search_transcripts` is the only public read path. ## At a glance - Function: `search_transcripts(pool, query, limit) -> Result>` at `crates/storage/src/database.rs:270`. - Schema: virtual table + three triggers, all created by migration v2 and rebuilt in migration v9 (table rebuild). - Indexed columns: `text`, `title`. Other columns (engine, model, language) are not searchable today. - Tokenizer: `porter unicode61 remove_diacritics 2` — Porter stemmer, Unicode-normalised, diacritics stripped. - Consumers: slice 2 history command (the search box on the History page). ## Schema Created in migration v2, rebuilt in migration v9 against the new (FK-bearing) `transcripts` table: ```sql CREATE VIRTUAL TABLE transcripts_fts USING fts5( text, title, content='transcripts', content_rowid='rowid', tokenize='porter unicode61 remove_diacritics 2' ); CREATE TRIGGER transcripts_ai AFTER INSERT ON transcripts BEGIN INSERT INTO transcripts_fts(rowid, text, title) VALUES (new.rowid, new.text, COALESCE(new.title, '')); END; CREATE TRIGGER transcripts_ad AFTER DELETE ON transcripts BEGIN INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title) VALUES ('delete', old.rowid, old.text, COALESCE(old.title, '')); END; CREATE TRIGGER transcripts_au AFTER UPDATE ON transcripts BEGIN INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title) VALUES ('delete', old.rowid, old.text, COALESCE(old.title, '')); INSERT INTO transcripts_fts(rowid, text, title) VALUES (new.rowid, new.text, COALESCE(new.title, '')); END; ``` `content='transcripts'` makes `transcripts_fts` a "contentless" external-content index — the FTS table stores tokens but not the data itself, which lives in `transcripts`. This halves on-disk size compared with a self-contained FTS5 table. ## Query shape — `crates/storage/src/database.rs:275` ```sql SELECT t. FROM transcripts t JOIN transcripts_fts fts ON fts.rowid = t.rowid WHERE transcripts_fts MATCH ? ORDER BY fts.rank LIMIT ? ``` `fts.rank` is FTS5's negative BM25 score (most-relevant first). `MATCH` accepts standard FTS5 syntax: bare words AND together; `OR`, `NOT`, parentheses; quoted multi-word phrases; column-scoped queries (`text:foo title:bar`). ## Watch-outs - **`MATCH` requires the leading word to be a real query, not a malformed token.** A user typing a single quote into the search box will produce a `SQL: malformed match` error from sqlite. The frontend should sanitise or fall back; today it does not, so the error propagates to the toast. - **The triggers fire on every transcript insert / delete / update.** A bulk import of 1,000 transcripts walks the FTS path 1,000 times. Acceptable today (transcripts are user-authored, not imported). If bulk-import lands, consider a `transcripts_fts(transcripts_fts) VALUES('rebuild')` after the bulk insert. - **Rebuild on schema change is expensive.** Migration v9 rebuilt the FTS table and re-tokenised every existing transcript. On a heavy user's database this can take several seconds. v9 ran inside a transaction so a failed rebuild rolls back cleanly. - **`title` is `COALESCE`-d to `''`.** A null title becomes an empty FTS document, not a missing column. - **No FTS over `manual_tags` or `llm_tags`.** Those columns hold comma-joined values and are filtered with `LIKE` queries today. A future migration could add them to the FTS5 index. ## Tests No dedicated FTS test in `database.rs`. Coverage is via the migration tests (which assert the table and triggers exist post-migration) and via the integration test pattern `tx.execute("INSERT INTO transcripts ..."); search_transcripts("phrase")` used in the slice-2 command tests. Worth adding a unit test that asserts BM25 ranking actually picks the better match — today a regression in the `tokenize=` clause would not break compile or migration tests. ## See also - [Storage overview](storage-overview.md) - [Schema and migrations](storage-schema-and-migrations.md) — v2 and v9 are the relevant points. - [Transcripts CRUD](storage-crud-transcripts.md)