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
(8. Search is SQLite FTS5 with a shared query syntax; embeddings are deferred, no embeddings).
From query to results#
A query string becomes SQL conditions plus one FTS5 MATCH, ranked by bm25, one hit per page.
flowchart LR
Q["Query string"] --> P["parse<br/>query.py"]
P -->|"words, phrases, -word"| T["FTS5 terms"]
P -->|"key:value"| F["Filters"]
T --> W["_where<br/>SQL conditions"]
F --> W
W --> M["chunk_fts MATCH<br/>+ page filters"]
M --> R["Rank tiers + bm25"]
R --> G["Best chunk per page"]
G --> S["Snippets<br/>with mark"]
S --> API["PWA<br/>GET /api/search"]
S --> MCP["MCP<br/>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. |
Match |
Each word is a prefix ( |
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. |
Snippet |
|
Facets |
|
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 HTTP API and CLI;
the tool arguments are in MCP server; the user’s view is in
Capture from your phone (the PWA).
Filter |
Matches |
|---|---|
|
A kind. Aliases |
|
A tag with |
|
Any status ( |
|
|
|
Pages whose |
|
Claude’s topic pages |
|
An image capture; a current drawing. Other |
|
A photo Claude described as kind |
|
Last edited on or after / before |
|
A todo’s due day. |
|
|
|
Order, not a condition: |
|
A Later page’s medium ( |
|
Not a condition: puts results in sections ( |
Health checks |
|
“Today” is the server’s local day, so set TZ (default UTC in the image) for due:today. The web app only mirrors this syntax to add and remove tokens
(web/src/terms.ts, filters.ts); parsing and matching stay on the server.
The index lifecycle#
The index is derived data in the same database file, never a source of truth.
flowchart TD
subgraph write ["Write path, one transaction"]
SV["Page save, tag or kind edit,<br/>summary, write-up"] --> IP["index_page"]
IP --> C0["chunk ord 0<br/>title, tags, summary, text"]
IP --> C1["chunk ord 1<br/>current write-up"]
C0 --> TR["Triggers<br/>chunk_ai, chunk_ad"]
C1 --> TR
TR --> FTS[("chunk_fts")]
end
DER["Photo ingest<br/>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_pagedeletes 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’ssummaryfield, 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\x02and\x03.Photo text. What ingest read (description, OCR) is its own chunk, made by a trigger when the
derivedrow 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 (Files: read locally, for search), is such a chunk too.Disposable. There is no
reindexcommand.index_pagerebuilds 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.
flowchart LR
H["store/health.py<br/>SQL fragments"] --> W["_where<br/>search filter"]
H --> CNT["counts()"]
W --> SR["Search results<br/>is:orphan ..."]
CNT --> HA["GET /api/health<br/>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) |
|---|---|
|
No page link either way (text, current write-up, related pick, topic and source); not a |
|
No tags; not a topic or gc review page |
|
An open todo, not due today or later, unchanged for 60 days ( |
|
A file of one of its captures is also another page’s |
|
A page link in its text to no page or one in the trash |
|
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 |
|
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
(Job queue and Claude features). 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.