The rule
Nothing that answers a query may iterate a store. Not a service, not the record, not a page. A full scan is correct until it is slow, and by the time it is slow it is load-bearing in six places and the fix is a migration rather than a change.
This is not a new capability. internal/data/sqlite.go is a real FTS5 index with a LIKE fallback and relevance scoring, and half the services already use it. The half that do not are the two largest.
Where it stands
|
how it searches |
social, news, archive, chat (rooms) |
FTS5 via data.Search ✅ |
service/mail |
for _, msg := range messages — scans every message on the instance |
internal/thread (behind service/recall) |
strings.Contains over every message of every thread |
service/images |
userdb.List with a where clause — a query, at least |
service/chat store |
scans an account's history (bounded at 2000, but the same shape) |
Mail's is the worst. Search iterates the package-level messages slice — every account's mail — and filters by owner as it goes. That is the same O(whole store) shape as #1464, where delivery measured 71ms over 5,000 messages and got worse than linearly. Search has the same curve and nobody has measured it.
Recall's matters most. It is what an agent calls to remember, so it runs on a model's initiative rather than a person's, and it runs often.
Why FTS5 before embeddings
Vectors are the more interesting answer and the wrong first move.
- Pure vector search is bad at what a mailbox is searched for: a name, an order number, a Message-ID, "the DMARC report from Google". Every system that shipped vector-only added keyword search back. What works for recall is hybrid — FTS and vector merged — which means FTS is needed either way.
- Embeddings cost a model call per message. Indexing a mail store means embedding every arriving email: real money, and a latency spike on the delivery path.
- FTS5 is already here. Wired for four services, no new dependency, nothing per message, and it takes these from O(n) to an index.
So: index first, measure whether recall is actually failing on semantics, and only then add vectors — as a second index beside the same database (sqlite-vec), not instead of the first.
Scope
service/mail — index on write, search the index. Also fixes searching another account's mail out of the scan by construction rather than by a filter inside the loop.
internal/thread — same, which is what service/recall reads.
service/chat — the store added in b5445ca. Small today and the same shape.
- A test that a
Search method does not range over a package-level store, so the next one is caught when it is written rather than when it is slow.
- A line in AGENTS.md, since this is a rule and not a task.
Not in scope
Embeddings, until 1–3 land and there is a measurement saying keyword search is what is failing.
Done when
Searching a mailbox with 50,000 messages in it costs the same as searching one with 500, and a test fails if a new service reintroduces a scan.
The rule
Nothing that answers a query may iterate a store. Not a service, not the record, not a page. A full scan is correct until it is slow, and by the time it is slow it is load-bearing in six places and the fix is a migration rather than a change.
This is not a new capability.
internal/data/sqlite.gois a real FTS5 index with a LIKE fallback and relevance scoring, and half the services already use it. The half that do not are the two largest.Where it stands
social,news,archive,chat(rooms)data.Search✅service/mailfor _, msg := range messages— scans every message on the instanceinternal/thread(behindservice/recall)strings.Containsover every message of every threadservice/imagesuserdb.Listwith a where clause — a query, at leastservice/chatstoreMail's is the worst.
Searchiterates the package-levelmessagesslice — every account's mail — and filters by owner as it goes. That is the same O(whole store) shape as #1464, where delivery measured 71ms over 5,000 messages and got worse than linearly. Search has the same curve and nobody has measured it.Recall's matters most. It is what an agent calls to remember, so it runs on a model's initiative rather than a person's, and it runs often.
Why FTS5 before embeddings
Vectors are the more interesting answer and the wrong first move.
So: index first, measure whether recall is actually failing on semantics, and only then add vectors — as a second index beside the same database (
sqlite-vec), not instead of the first.Scope
service/mail— index on write, search the index. Also fixes searching another account's mail out of the scan by construction rather than by a filter inside the loop.internal/thread— same, which is whatservice/recallreads.service/chat— the store added in b5445ca. Small today and the same shape.Searchmethod does not range over a package-level store, so the next one is caught when it is written rather than when it is slow.Not in scope
Embeddings, until 1–3 land and there is a measurement saying keyword search is what is failing.
Done when
Searching a mailbox with 50,000 messages in it costs the same as searching one with 500, and a test fails if a new service reintroduces a scan.