# Search One query syntax, parsed once by `query.py`, serves the PWA's search box, its facets and Store health screen, and the MCP `search` tool. The index is SQLite FTS5 over page chunks; it is a cache that migrations rebuild, and the store-health checks are ordinary search filters ([](../decisions/0008-search-fts5-only-for-now.md), no embeddings). ## From query to results A query string becomes SQL conditions plus one FTS5 `MATCH`, ranked by bm25, one hit per page. ```mermaid flowchart LR Q["Query string"] --> P["parse
query.py"] P -->|"words, phrases, -word"| T["FTS5 terms"] P -->|"key:value"| F["Filters"] T --> W["_where
SQL conditions"] F --> W W --> M["chunk_fts MATCH
+ page filters"] M --> R["Rank tiers + bm25"] R --> G["Best chunk per page"] G --> S["Snippets
with mark"] S --> API["PWA
GET /api/search"] S --> MCP["MCP
search tool"] classDef store fill:#e8f1ff,stroke:#4a7bd0; classDef claude fill:#fff3e0,stroke:#e08a00; classDef user fill:#e9f7ef,stroke:#2e9d5b; class M,R store; class MCP claude; class API user; ``` | Step | What happens | | --- | --- | | Parse | One linear pass. Space means AND. `"phrase"`, `-word` / `!word` (NOT) and `key:value` (also `key:"two words"`, `-key:value`). Words with no letter or digit are dropped. At most 64 terms and 64 filters: a larger query is refused (HTTP 422, MCP tool error), never silently cut | | Match | Each word is a prefix (`vsc` finds `vscode`) wherever it stands, in the title, tags, summary, text, a photo's text or description, or a PDF's or text file's text. Phrases and NOT words are exact. Tokenizer `porter unicode61` | | Rank | Tiers: every word in the title, then every word in title, tags or summary, with a topic page first within each of those tiers; then bm25 with column weights 10, 5, 3, 1 (title, tags, summary, text) | | Group | A page's best chunk wins. `bm25()` and `snippet()` refuse `GROUP BY`, so it is done in Python | | Snippet | `snippet()` on the text column, HTML-escaped, hits in ``. With filters only: the summary, else the start of the text, newest first | | Facets | `GET /api/search/facets` runs the same `_where` and counts kinds, tags (`a/b` counts for `a`) and personal/work | An empty query finds nothing over HTTP; over MCP it lists the most recently changed pages. ## Filters Generated from `KEYS` in `query.py` and `Store._where` in `store/search.py`. A `-` before a filter excludes. The full per-filter wording is under `GET /api/search` in [](../../reference/api.md); the tool arguments are in [](../../reference/mcp.md); the user's view is in [](../../how-to/use-the-pwa.md). | Filter | Matches | | --- | --- | | `kind:K` | A kind. Aliases `later` / `read` (consume), `board` (whiteboard), `doc` (document). Two `kind:` mean either | | `tag:T` | A tag with `T` as whole `/` segments: `tag:a` and `tag:b` both find `a/b`. `tag:/` matches nothing ([](../decisions/0019-kinds-and-tags-editable-with-revisions.md)) | | `is:S` | Any status (`is:done`, `is:in_progress`; `_` for spaces) | | `is:personal`, `is:work` | `personal` field true / not true | | `is:claude`, `is:claude-edited` | Pages whose `made_by` is `claude` (written over MCP); text changed over MCP after the first revision | | `is:topic` | Claude's topic pages | | `has:image`, `has:drawing` | An image capture; a current drawing. Other `has:` values match nothing | | `image:K` | A photo Claude described as kind `K` (`photo`, `whiteboard`, `handwriting`, `diagram`, `screenshot`, `document`, `other`) | | `after:D`, `before:D` | Last edited on or after / before `D`: a UTC date `YYYY-MM-DD` or a time ago (`1d`, `2w`, `1m` = 30 days, `1y` = 365, up to `100`). With `date:created`, creation date instead | | `due:D` | A todo's due day. `D` is a date, `today`, `tomorrow`, `yesterday`, optionally after `<`, `<=`, `>`, `>=`, `=`; `due:none` has no due date. `due:summary, write-up"] --> IP["index_page"] IP --> C0["chunk ord 0
title, tags, summary, text"] IP --> C1["chunk ord 1
current write-up"] C0 --> TR["Triggers
chunk_ai, chunk_ad"] C1 --> TR TR --> FTS[("chunk_fts")] end DER["Photo ingest
derived row"] -->|"trigger derived_index"| CD["chunk with derived_id"] CD --> TR MIG["Baseline / a migration"] -->|"rebuild"| FTS classDef store fill:#e8f1ff,stroke:#4a7bd0; classDef claude fill:#fff3e0,stroke:#e08a00; class FTS,C0,C1,CD store; class DER claude; ``` - **One transaction.** `index_page` deletes the page's own chunks (`derived_id IS NULL`) and inserts a fresh set, so a saved page is searchable at once and a failed save leaves the old index. - **Chunk shape.** Chunk 0 holds title, tags, summary and the body as words (`markdown.py`: link text not addresses, code as typed, table cells). The summary is the page's `summary` field, else Claude's (current write-up, else enrichment). Chunk 1 is the current write-up: a board's write-up or a topic's synthesis. Control characters become spaces, since snippets mark hits with `\x02` and `\x03`. - **Photo text.** What ingest read (description, OCR) is its own chunk, made by a trigger when the `derived` row is inserted and removed when that row goes (a reading is deleted when a newer one replaces it; a drawing version when it is superseded). It fills only the text column. A PDF's or text file's text, which scrap reads itself ({ref}`ingest-files`), is such a chunk too. - **Disposable.** There is no `reindex` command. `index_page` rebuilds one page's own chunk, and a migration that changes what is indexed rebuilds every page's (the trash's too: undo brings a page back). ## Store health as search A health check is a search term, defined as one SQL fragment in `store/health.py`. Nothing is stored and no model is called, so a finding is always current. ```mermaid flowchart LR H["store/health.py
SQL fragments"] --> W["_where
search filter"] H --> CNT["counts()"] W --> SR["Search results
is:orphan ..."] CNT --> HA["GET /api/health
term + count"] HA --> SC["Store health screen"] SC -->|"tap a row"| SR classDef store fill:#e8f1ff,stroke:#4a7bd0; classDef user fill:#e9f7ef,stroke:#2e9d5b; class H store; class SC,SR user; ``` | Check | Finds (pages not in the trash) | | --- | --- | | `is:orphan` | No page link either way (text, current write-up, related pick, topic and source); not a `scrap gc` review page | | `is:untagged` | No tags; not a topic or gc review page | | `is:stale` | An open todo, not due today or later, unchanged for 60 days (`STALE_DAYS`) | | `is:duplicate` | A file of one of its captures is also another page's | | `has:dangling` | A page link in its text to no page or one in the trash | | `has:open-questions` | A page with a Markdown heading (any level, setext too; not in code, a quote or a list item) that reads "Open questions" in any case (emphasis and links in it are ignored, a trailing colon too) and whose section is open. The section runs to the next heading of the same or a higher level, so deeper sub-sections and their boxes are part of it. A section with task boxes is decided by them alone: any unticked `- [ ]` makes it open, all ticked is done even with prose left. With no boxes it is open when it has any content (a paragraph, list, table or code); an empty one, or one with only sub-headings, is not. Topic pages are skipped (a "Contradictions and open questions" heading is not one), and the page-wide `tasks` counts are not the test | | `is:outdated` | A board's write-up older than its inputs or of an older prompt version, or linking a page that is in the trash or gone | Related pages and a topic's synthesis have outdated definitions in the same module, but they are not findings: the nightly related pass and weekly topics pass refresh them ([](claude-pipeline.md)). `is:outdated` is for the owner to regenerate, or `scrap enrich --writeups --outdated`. ## Invariants - One parser: the PWA, facets, health screen and MCP never re-implement filtering. - The index is a cache: any migration may drop and rebuild it, and writes keep it in step inside the same transaction. - Ranking prefers title, tag and summary matches over body repetition; no embeddings today. - Query limits are refused loudly: a dropped NOT word would show more pages than asked for. ## Related - [](../decisions/0008-search-fts5-only-for-now.md), [](../decisions/0019-kinds-and-tags-editable-with-revisions.md), [](../decisions/0029-store-health-checks-as-search-filters.md) - [](data-model.md) for the `chunk`, `derived` and `tag` tables; [](mcp-and-access.md) for the tool; [](frontend-pwa.md) for the Search screen; [](../design.md) for the landing page.