Dialogue.  · Omni search

Omni search — three groups on the Chats tab

SHIPPED v753, 2026-08-11. All seven steps below are built: the table, the backfill (lib/migrate_search_index.py), the write hook, the redact hook, GET /api/search/history, the three-group client, and six smoketest gates. Live numbers from the dev corpus — 24,098 lines indexed from 1,632 rooms in 4.1 s; a query answers in 3 ms in the database and 17 ms round-trip. Two things moved while building, both noted in place below: a head-slice snippet hid the very match it was there to show (§5), and the index hook reached for the wrong database handle and the safety try swallowed it (§6).

Today the Chats field narrows the chat list and nothing more (v751). The ask is WeChat's: one field that searches everything you have, and answers in three named groups — group chats, chat histories, friends. Two of those three the app can already answer in a keystroke. The third has never existed, and it is the whole engineering story: the app has never searched what was said.

The thesis, in one line. Two of the three groups are free and the third is not, because the text worth searching is 1.3% of what you must read to find it. That single ratio — 3.15 MB of conversation inside 233.5 MB of room state — is what turns「search your history」from a filter into an index.

1 · The shape

ONE FIELD the Chats tab header 1 · FRIENDS 联系人 /api/friends?q= — already there one call · already built 2 · GROUP CHATS 群聊 the rooms already in memory instant · no wire 3 · CHAT HISTORIES 聊天记录 what was SAID — new needs an index · answers last ONE LIST, THREE HEADINGS left pane on wide · the page on phone a row does what its own row does chat → right pane · person → profile
fig 1 · one field, three sources, one list. Only the last one is new work — and it is last for a reason: see §5.
The order came from the screenshot, not from the brief. The ask listed group chats · chat histories · friends; WeChat's own answer runs 联系人 → 群聊 → 聊天记录 — friends first, history last. That is worth following, because it also happens to dissolve the one layout problem this feature has: the two instant groups land immediately and the slow one grows onto the bottom of the list, where it cannot shove anything already on screen. The brief's order put the slow group in the middle. Same three groups either way — say the word if you want yours.

2 · Why three groups — and why a 1-on-1 is not one of them

The groups are not three filters over one pile; they are three different kinds of thing, and each already has a row that knows how to draw itself and what to do when tapped. That is the rule the whole feature hangs on: a result looks exactly like the row it stands for, and does exactly what that row does.

GroupWhat is in itThe row saysTapping itSource
1 · Friends
联系人
people and personas face · name, with the hit highlighted opens the profile/api/friends?q=
2 · Group chats
群聊
rooms that are not a 1-on-1 — a panel of personas, or more than one human the chat-list row · member count · and why it matched:「contains: 高玮丽」when the hit was a member rather than the nameopens the chat client, ALL_ROOMS
3 · Chat histories
聊天记录
lines that were said, in any chat you can see one row per chat, not per line: the newest matching line + date + 「62 matching messages」opens that chat NEW endpoint
Two things the screenshot settles that a brief would not have. First, a result explains itself: a group that matched on a member says so on its second line, because otherwise a row whose visible title has nothing to do with what you typed looks like a bug. Second, history is grouped by chat, not by line — one row per conversation carrying its newest hit and a count, so a word said sixty-two times in one chat is one answer, not sixty-two. Each group is also capped with a「more」 link rather than running long: three groups only stay readable if none of them can push the others off the screen.
A 1-on-1 chat is not a search result — the person is. This is WeChat's rule and it is why three groups are enough. A DM has no name of its own: it is titled from who is in it, so searching「Lincoln」and offering both「Lincoln」the friend and 「Lincoln-ish」the chat is the same answer twice, and makes you read before you can pick. The person is the door; their profile has Send message on it, and their Shared chats row lists every room you are in together. So only multi-party rooms appear under Group chats, and everything 1-on-1 is reached through Friends.

3 · The measurement that decides the architecture

Groups 1 and 3 are free. Group 2 is the question, and it is not close. Measured on the dev corpus, 2026-08-11:

 AmountWhat it means
Rooms on disk1,755each a directory with state.json
Total room state233.5 MBwhat a naive search must open
Naive full scan15.3 sper query. Not a feature — a hang
Lines actually said30,066the visible conversation
Text worth searching3.15 MB1.3% of what you had to read to find it

The 74× gap is history — the raw model messages, megaprompt and all — which nobody searches and which dwarfs the transcript sitting beside it. So the text gets pulled out once and kept where it can be asked a question: a msgs table in app.db, backfilled once, appended as turns land. At 30k short rows a query is single-digit milliseconds, and it grows with what was said rather than with what the model was told.

Which index — the three candidates, run against the real corpus

The obvious reach is SQLite's full-text index. It is the wrong tool here, and the corpus says so rather than the argument. All three were built from the same 30,057 lines and asked the same two questions — a real two-character Chinese word (个人, which 2,434 lines genuinely contain) and an English one:

CandidateBuildOn diskCJK queryCJK hits Latin hitsVerdict
plain table + LIKE0.1 s9.7 MB 6–12 ms2,434 ✓17 ✓ correct
FTS5, default (unicode61)0.3 s15.7 MB 0 ms0 ✗17 ✓ finds nothing
FTS5, trigram1.4 s34.1 MB 0 ms0 ✗17 ✓ finds nothing
Both full-text options return ZERO, and they do it silently. unicode61 tokenizes on spaces and punctuation, and Chinese has neither — 中海恒昌玖里 is one token, so「恒昌」is not a word inside it and never matches. Trigram fixes CJK substring search in principle, but it can only answer queries of three characters or more, and Chinese search terms are routinely two —「恒昌」in the screenshot is two. Below its floor it does not error; it returns an empty result, which is the worst failure available: a search that quietly says「nothing」about a word said 2,434 times. Recommend the plain table with a substring scan. It is the only one that is correct, it is the cheapest to build and the smallest on disk, and — the reason that settles it beyond the numbers — it is the same rule the chat list already uses (includes), so one word cannot find one thing in the list and a different thing in the history.

What it costs, and when it stops working

A substring scan is linear in total text, so the honest question is not「is it fast」but 「how much room is there before it isn't」. Measured: 1.9 ms per MB of conversation. The whole 3.15 MB corpus answers in 6 ms; a single user's own rooms — the only scope a real query has — answer in under 1 ms. Ten times the text is ~60 ms and a hundred times is ~600 ms, so the ceiling is around 50–100× today's corpus, at which point the upgrade is a hand-rolled bigram column (every adjacent pair indexed as a token) — which is what CJK full-text setups do anyway, and can be added beside the same table without changing a single caller.

4 · The three filters an index must not lose

This is the part that can leak. Today every line a user sees passes through one read path — replay(floor) then _hide_filter — and that path is where all privacy is enforced — the floor exists only in the read path; nothing is deleted underneath it. An index sits beside that path, so each filter has to be carried across explicitly or it is simply gone:

FilterWhat it hidesCarried across by
The floor
member_see_from
everything said before you joined, when history was privatestore each line's ts; drop candidates older than the caller's floor
Delete for everyone
a redact event
a line the room retracted — replay renders a tombstonestore each line's lid; delete it from the index when the redact lands
Delete for me
hidden_msgs
a line one person put a lid onsame lid, filtered per caller — already a DB table
The rule this earns. An index is a second read path, and a second read path is a second place to forget. So it stores ts and lid on every row — neither is needed to match anything; both exist so the three filters can still be applied to a candidate without loading the room. A row that cannot be filtered must not be returned.

5 · What it looks like, and where a tap goes

The field and its behaviour are unchanged — the magnifier grows into the bar, the retracts it, the word is the tab's (v748–v752). What changes is what the list under it shows.

On a phone

The results replace the chat list in place, under three headings. A chat opens the chat; a person opens their profile. Both are the end of the loop — entering a chat already sweeps the search (v751), and it should keep doing exactly that.

On wide

Results fill the left pane, and the right pane answers:

Which is not a new rule at all — it is the one the app already has: pressing a row in the left pane replaces the right pane. The search list is a left-pane list like any other, so it inherits that for free. On wide the search is not swept when a result is opened: the list stays beside the chat, and sweeping it would pull the other results out from under the hand that just picked one.

Why the order is load-bearing. Friends and group chats answer in a keystroke; history needs the wire. In WeChat's order the slow group is last, so it grows onto the bottom of a list that has already settled and nothing on screen moves. Put it in the middle and every arrival shoves Friends down under the reading eye. The order is not decoration — it is the whole reason this does not need a spinner, a reserved slot, or a second settle.
Built, and one thing had to change: the SNIPPET IS CENTRED ON THE MATCH. The first cut showed the first 80 characters of the matching line — and in a long message the hit is rarely near the front, so three of the first three results came back showing a line where the word was nowhere to be seen. A row that matched something you cannot see reads as a wrong result, not a right one. It now takes a window around the hit with about a third of it as lead-in, and elides both ends — which is what the screenshot was doing all along(…中海恒昌玖里…)and I had read as a truncation.

The field itself

Unchanged from v748–v752 — the magnifier grows into the bar, the retracts it, the word belongs to the tab. One addition from the screenshot: an ✕ clear button at the right end of the field once there is something to clear, which empties the word without putting the bar away. The puts the bar away; the ✕ only empties it. Two different intentions, two different controls.

6 · What building it is, in order

  1. The table. msgs(room_id, lid, ts, who, text) in db.py, indexed on room_id.
  2. The backfill. A migrate_* that walks every room once and fills it — ~15 s once, on the box, at deploy.
  3. The write hook. Where a turn is appended, mirror it in. Idempotent on (room_id, lid) so a re-persist cannot double it.
  4. The redact hook. Delete the lid on「delete for everyone」— the one place the index can go stale in a way that leaks.
  5. The endpoint. GET /api/search/history?q= — scope to the caller's rooms, apply the three filters, return chat + line + count.
  6. The client. Three groups in the Chats results list, in WeChat's order: friends from the existing call, group chats from ALL_ROOMS restricted to multi-party, histories from the new one. Each group capped with a「more」link, the hit highlighted in coral, and a group row carrying「contains: name」when it matched on a member.
  7. The gates. Six, in the smoketest: no floor finds everything · a floored member cannot find a pre-floor line · a line you hid stops being findable by you · a redacted line leaves the index entirely · re-indexing replaces rather than duplicates · another room's lines are never candidates.
The bug this build produced, because it is the shape to watch for. The index hook reached for db — which is local to build_app(); module-level Room methods read the _DB alias the app publishes for exactly this. So the hook was calling a module where an instance was wanted, and the try/except that exists so a search index can never fail a user's turn swallowed the error completely. Every gate passed, the backfill was full, the UI worked — and nothing said after the deploy would ever have been findable. A safety net that hides a defect is a defect, and the only thing that caught it was exercising the live path directly: say a line, persist, look. Now verified end to end — a line indexes itself on persist, a redact removes it, and a rehydrated room re-syncs whole without duplicating it or resurrecting the redacted one.
Not in scope, deliberately. Ranking (results come back newest first, not scored); searching inside one chat; highlighting the hit in the chat once opened; and searching the public persona library from this field — Discover already owns that and its answers are a different kind of thing from what is on your screen.
Dialogue · omni search · designed 2026-08-11 · measurements and the index bake-off run against the dev corpus (1,755 rooms · 30,057 lines · 3.15 MB) · siblings: the style guide (the field itself, v747–v752) · friends · room language