DocumentationDecision recordsADR-0041
Full-text content search over a rebuildable FTS5 projection
Workspace search matched note titles from hydrated renderer state. Note bodies were indexed into an FTS5 virtual table, but the table stored raw Markdown, its…
accepted
Context
Workspace search matched note titles from hydrated renderer state. Note bodies were indexed into an FTS5 virtual table, but the table stored raw Markdown, its ranking gave a title hit no advantage over an incidental body hit, no component could tell whether the index still described the workspace, and nothing could rebuild it. Full-text search is table stakes for a notes application and the prerequisite for semantic search, so the index has to be a first-class, verifiable projection rather than a side effect of saving.
Decision
The index is a rebuildable projection
documents_fts holds one row per document: the note identity (unindexed), the
title, and the projected body text. It is derived state — every row can be
recomputed from documents and workspace_nodes — and it is written inside the
same serialized, immediate transaction as the durable save, subtree trash,
rename, or archive import that caused it. It never participates in editing or
navigation reads beyond the search query itself.
SEARCH_INDEX_VERSION in skriuw-domain names the shape of the projected text.
The workspace records the version its index was built with under the app_state
key search_index_version. SearchIndexMaintenance::search_index_status
reports drift — a stale version, or an index row count that no longer matches
the document count — and rebuild_search_index discards and reprojects the
whole index in one transaction. Rebuilding is idempotent: running it twice
yields the same index and the same status.
Recording the projection version in app_state rather than in a schema
migration is deliberate. Changing the text projection is not a schema change:
the table shape is unchanged, existing rows still match, and only their quality
improves. A version bump therefore costs a background rebuild instead of a
destructive migration, and search keeps working throughout — the old rows stay
queryable until the new ones replace them. For the same reason the body column
keeps the name markdown it was given in migration 0001: FTS5 cannot rename a
column, and recreating the virtual table would empty the index at boot in
exchange for a better label.
Nothing rebuilds on the startup path. The renderer schedules one reconciliation on an idle callback after the first paint; it reads the status and rebuilds only on drift. A rebuild it starts is serialized behind the storage worker like any other durable write, so it cannot block navigation, and the browser runtime serves the same two commands through its worker protocol.
Indexed text is prose, not Markdown
skriuw_domain::index_text projects a note's canonical Markdown to the text a
reader would search for. It is a pure function in the domain crate with no
storage, filesystem, or framework dependency, so the desktop adapter, the
browser worker, and the rebuild path all produce byte-identical rows.
- Headings, emphasis, list bullets, blockquote markers, table delimiters, and escapes are removed; the words survive.
- Link, image, and media labels survive; their targets do not. A note is no
longer found by the UUID inside
images/9f3c-…. #design,$ada, and[[Roadmap]]index asdesign,ada, andRoadmap, so chips are found by their plain names. A sigil that does not open a chip —C#,US$— is left alone. An aliased wikilink keeps both its target and its visible label.- HTML tags and editor markers (
<!--skriuw-align:…-->,<!--skriuw-task:…-->, highlight and annotation<mark>) are removed. A bare autolink is not markup and keeps its URL. - Code fences keep their body: code is text people search for. Fences whose body
is a serialized payload —
drawing,excalidraw— are dropped entirely, because a scene is kilobytes of coordinates that match nothing a reader searches for while dominating both term statistics and snippets.
Removing markup matters less for matching than it looks, because unicode61
already treats punctuation as a separator. It matters for three things that are
not cosmetic: opaque payloads leave the index, identifiers in link targets stop
producing false hits, and the snippet() output reads as prose.
Tokenization
The tokenizer is unicode61 remove_diacritics 2, chosen for Dutch and English
and unchanged from migration 0001.
unicode61 classifies by Unicode category rather than ASCII, so Dutch text
tokenizes correctly and CJK or Cyrillic notes are at least tokenized on
punctuation and whitespace rather than dropped. remove_diacritics 2 folds all
combining diacritics — not only the Latin-1 subset that mode 1 covers — in
both the indexed text and the query. A reader finds geërfde by typing
geerfde and café by typing cafe, in either direction, which is what a
Dutch or English keyboard layout demands. Its cost is that a language where an
accent distinguishes two words cannot separate them; no supported locale does.
Query terms are quoted and turned into prefix terms joined by AND, so typing
narrows results as the reader types, and a term containing a quote cannot escape
into FTS5 query syntax.
Ranking is bm25(documents_fts, 0.0, 8.0, 1.0): the identity column contributes
nothing, a title match weighs eight body matches, and body matches still rank
against each other by term frequency. The palette also keeps title matches in a
separate group listed above content matches, so this weighting orders content
matches among themselves rather than deciding the whole list.
Query surface
WorkspaceStorage::search and search_filtered return SearchHit { noteId, title, snippet, score }, where the snippet delimits matched spans with
<mark>. SearchIndexMaintenance is a separate, narrow trait carrying only
search_index_status and rebuild_search_index. Both reach the renderer as
Tauri commands and as browser worker commands, and their result types are
generated contracts.
The renderer parses the snippet's delimiters into runs and renders matched runs emphasized. Ranking still reads the plain text, so a highlight can never change which rows appear.
Search stays off the navigation path in the renderer too: the palette debounces input by 120 ms, discards out-of-order responses with a monotonic request counter, and issues no query at all until the free text reaches its minimum length.
Consequences
Content search returns snippets from note bodies, ranked below title matches, without a schema migration and without a startup cost on a current index. Existing workspaces keep searching against their old rows until the idle reconciliation replaces them.
Every future change to the projected text or the tokenizer must bump
SEARCH_INDEX_VERSION in the same commit, or workspaces will keep serving text
the build no longer produces. The tests in crates/skriuw-domain/src/search.rs
pin the projection and the tests in crates/skriuw-sqlite/src/tests.rs pin
tokenization, ranking, idempotent rebuild, and recovery of a lost index across
restart.
The index still stores note text in the clear inside the workspace database,
exactly like documents does; it adds no new exposure and no new encryption
boundary. Semantic search, when it lands, gets its own projection alongside this
one rather than overloading the FTS5 table.
On this page