Blog
How I took Gravity's search from 0 to 198 out of 200
Gravity has a new search on iPhone, iPad, Android and Mac, in the command line tool, and in the connector that Claude and ChatGPT use to read your notes. I rebuilt it against a test of 200 searches that the old search failed every time. The new one finds the right note in the top 10 for 198 of them. On a second set of 200 that was written blind, it goes from 17 to 198. Typing a search is as fast as before, and the slowest keystroke in a 100,000-note library went from 149 ms to 14 ms.
This post covers how I built the test, what was wrong, what the new search does, and the four searches it still gets wrong.
Search that worked until you misremembered
The old search was SQLite's FTS5 full-text index with the unicode61 tokenizer. Typed words matched the beginnings of words in your notes, and if that found nothing, a trigram index matched words of three letters or more anywhere inside longer words. Results were ordered by BM25. A first word ending in a colon, like todo:, became a tag and showed only notes starting with it.
That works whenever you search with the words you wrote. Search for dentist and you get the note with "dentist" in it. The trouble starts when you remember a note differently from how you typed it, and with a notes app you mostly do. You wrote "Mum's birthday" on one phone and search mums on another that has no apostrophe on the first keyboard screen. You wrote "Wi-Fi" and search wifi. You wrote "couch" and search sofa. You wrote "Thurs" and search thursday. None of those found anything.
A library built to break search
Testing search needs a library, and I wasn't going to use anyone's real notes. So I wrote a seeded generator that makes one person's library: 40,000 notes from six years of capture across 12 parts of life (work, family, health, money, food, the house, travel, reading, code, ideas, a journal and odds and ends), about 680,000 words. Around 20% of the notes are in 18 other languages, 27% are archived, 2% are in the trash and 6% have been rescued at least once, roughly what a long-running Gravity library looks like.
Then I went looking for failures. I wrote 314 probes aimed at likely weak spots and ran them through the real search code. 246 failed. I grouped the failures by cause, kept 20 distinct causes, and wrote 10 cases for each.
Every case has three parts: a note, a search a person would reasonably type to find it, and a control search using the note's own words. A case passes when its note shows up in the top 10, which is a phone screen and a short scroll. Each case's note is added to the 40,000 through Gravity's own note-saving code.
The controls are there so a zero means something. All 200 controls passed, so every note was indexed and findable. The 200 real searches scored 0 of 200 in the top 10, and only 7 appeared anywhere in the first 200 results, at ranks 57 to 137.
I ran the same cases through the real gravity binary, through the phone app's own search code on the SQLite version the phone uses, and through the plainer trigram index that Macs on macOS 15 fall back to. Every one gave the same ranks.
The 20 ways it failed
| Failure | The note says | The search |
|---|---|---|
| A colon in the search turned it into a tag | Cottage wifi password: bluebell42 |
password: bluebell42 |
| Short tags found nothing | w: the kitchen tap washer size |
w: |
| Tags compared accents exactly | Café: new place near the station |
cafe: flat whites |
| One short word switched off substring search for every word | Haftpflichtversicherung zu teuer |
versicherung zu teuer |
| Some Latin letters didn't fold | Họp ở Hà Nội thứ hai |
ha noi |
| Accents in other scripts | Купить ёлку |
елку |
| Languages written without spaces | 今天下午去咖啡店见客户 |
咖啡 |
| Emoji | 🎂 Mum's cake order |
🎂 |
| Pasted characters | final figures (a ligature) |
final figures |
| Apostrophes | Mum's birthday dinner |
mums birthday dinner |
| Joined and split spellings | Cottage Wi-Fi password |
wifi password cottage |
| Word forms | Need new battery |
batteries smoke alarm |
| Phone numbers and codes with spaces | Plumber Dave 07700 900123 |
07700900123 |
| Numbers and units | Paid £1,240 |
1240 flue |
| Dates | Passport expires 14/03/2027 |
passport 14 march 2027 |
| Typos | the prescription from Boots |
perscription boots |
| Abbreviations | Bins go out Thurs night |
bins thursday recycling |
| Different words, same meaning | The couch delivery |
sofa delivery dunelm |
| Questions | Spare key is under the blue plant pot |
where is the spare key |
| Ranking | the newest dentist note, rescued twice | dentist (it ranked 137th) |
The first four were plain bugs. Any word ending in a colon became a strict tag, even one copied from the middle of a note, so password: bluebell42 looked for notes tagged password: and found none. A short tag like w: was a prefix query on w, which also matched every work: and wedding: note, and the scan stopped after 1,000 matches before it reached a real w: note. Tags compared exactly, though the website says accents don't matter. And a single search word under three letters, like zu, switched off the substring stage for every other word in the search.
The rest were limits of matching letters. Keyword search can't know that "Thurs" is Thursday or that a couch is a sofa. A question like where is the spare key required every word, including where, is and the. And BM25 alone decided the order, so for a common word like dentist, a long old note full of the word beat the one you wrote yesterday and rescued twice.
The bug the eval didn't catch
Speed was a separate benchmark, and it found the worst bug of the lot. The phone app uses op-sqlite, which bundles SQLite 3.51.3. On the phone's schema, that version's query planner started the search query from the index on deleted_at, then ran the full-text match once per note. In other words, every search on a phone read every note.
I compiled op-sqlite's own sqlite3.c with the app's iOS flags and ran the phone's query on my Mac against 40,000 notes. dentist took 817 ms. A one-letter search, t, took 288 seconds. The CLI and Mac app use newer or different SQLite builds that start from the full-text index, so they never showed it. Writing the condition as +n.deleted_at IS NULL stops SQLite using that index, and dentist dropped to 1.7 ms.
Keeping myself honest
Hill-climbing a test of 200 cases is easy to fake. You can add each failing word to a list until the score is 100% and the search is no better for anyone else. I set four guards before changing any code.
- A held-out set of 200 more cases in the same 20 classes, written by a separate agent that never saw the implementation. I reported it next to the main score and never tuned on it. The old search scored 16 of 200 there, 10 of them on the short-tag bug.
- The 200 controls had to stay at 200 of 200.
- Every word list (abbreviations, irregular plurals, number words) had to be a general list. None could be the eval's own words.
- Speed was measured against a frozen copy of the old search code, run back to back on the same machine.
There was one more problem to sort out. Some searches have more than one right answer. If the library holds three notes about topping up the boiler pressure, boiler pressure can fairly return any of them. So I had an independent model judge every failing case's top 10 without seeing the ranks, and it marked notes that answer the search as well as the target does. It found such notes for 12 cases. I report both numbers: the original target only, and counting judged answers. Counting judged answers moves the old search from 0 to 2 on the main set and from 16 to 17 on the held-out set.
What search v2 does
One index, built in app code
The new index lives next to your notes in the same SQLite file and replaces both old full-text tables.
notes_search(rowid, body, extra)holds a search document for each note.- An FTS5 table over
bodyandextrawith prefix indexes, and a trigram table overbodyfor matches inside words. - An
fts5vocabtable, so search can ask which words exist in your library and how many notes use each. - A
search_dirtyqueue. Triggers onnotesadd a note's row to the queue when it's created or its text changes.
SQL triggers can't build the search document, because op-sqlite on the phone has no custom SQL functions, and the folding rules are too involved for SQL anyway. So each app drains the queue in its own code: before each search, and in the background after writes. Draining a note computes its search document and its meaning vector and upserts them into the index.
Folding
body is the note folded into one canonical form. Text is normalised (NFKC), invisible formatting characters are removed, case is lowered and accent marks are stripped. Letters that don't decompose are spelled out (ß becomes ss, ø becomes o, ł becomes l, þ becomes th), Greek final sigma becomes σ, and katakana becomes hiragana. Words broken across lines with a hyphen are joined. Each emoji becomes a token like emo1f382, so 🎂 is a word you can search for.
The same fold runs on the search. That fixed the accent, script, pasted-character and emoji failures in one place.
Alternate forms
extra holds other ways of writing what's in the note:
- Joined forms of words with apostrophes, dots and hyphens:
mums,wifi,phd. - Numbers without thousands commas,
15kas15000,5 kmjoined as5kmand5kmsplit as5 km. - Codes. Runs of letter and digit groups like
07700 900123,SW1A 1AAor+44 7700 900123are stored joined, and a+44number also gets its0form. - Number words both ways (
sixand6), abbreviation expansions (thurstothursday,appttoappointment,xmastochristmas), and English stems using Snowball's Porter2, after mapping irregular forms likemicetomouse. - Dates and times as canonical tokens.
14/03/2027,14 March 2027andMarch 14th, 2027all producemd0314andy2027, and7.30pmproducest1930. Dates are read day first. - Overlapping two-character pieces for Chinese, Japanese, Korean and Thai, so
咖啡matches inside今天下午去咖啡店见客户.
Searches become concepts
The search is folded the same way and split into concepts. A word concept matches the word as a prefix, its stem, its number form and its expansion. Words of three letters or more also match as a substring. A typed date becomes the same tokens the note produced. Question words like where, is and the become concepts that are never required.
Some expansions use the library itself. A word of five letters or more that no word in your notes starts with gets up to three typo variants from the vocabulary table, by edit distance with the first letter fixed, so perscription finds prescription. A closed word like wifi can match a note that says wi fi, and two typed words whose join is a word in your notes, like pass word, become one.
A tag is still a tag when tagged notes exist. When none do, password: becomes the plain word password. Tags compare folded, so cafe: finds Café:, and a short tag like w: asks the index for notes whose first word is exactly w, so it doesn't scan through every work: note first.
Meaning from a 17 MB model
Some searches share no letters with their note: sofa and couch, lawyer and solicitor. For those, search v2 uses a static embedding model. A static model is a table with one vector per word piece. A text's vector is the sum of its pieces' rows, which takes microseconds with no neural network to run, so it works offline on a phone.
I tested the best current static models on the eval by meaning alone, with no keyword matching at all. On the main set, Sentence Transformers' multilingual static-similarity-mrl-multilingual-v1 found 146 of 200, static-retrieval-mrl-en-v1 found 138, and Minish Lab's potion-retrieval-32M found 137. The multilingual model looked best on paper and covers 50 languages in 27 MB. It lost on the searches keyword matching can't rescue: for lawyer, it ranked the solicitor note 1,155th, where potion-retrieval-32M ranked it 2nd. So Gravity ships potion-retrieval-32M (MIT licence), cut to its first 256 dimensions and stored as int8 with one scale per token. The file is 17.3 MB.
Every platform has to produce the same results, so the arithmetic is integers wherever it can be. Each note's vector is scaled so its largest component is ±127, rounded half away from zero and stored as 256 bytes. Similarity is an integer dot product over the product of integer norms. Rust, TypeScript and Swift get the same numbers bit for bit.
Scoring
Every candidate gets one score:
- 14 × coverage, the share of required concepts it matches
- 2 × the share matched as whole words
- 5 if it carries the searched tag
- 3 × its BM25 over the best BM25 in the results
- 3 × 0.5 ^ (age / 30 days), with age counted back from your newest note
- 1.5 × rescues, counting up to 3, over 3
- 6 × meaning similarity
- 2 × proximity, how close together the matched words sit
Notes that match every concept are kept, as are notes that match at least half of them and notes with a similarity of at least 0.40. Coverage dominates, so a note with all your words beats one with some of them. Among notes that match equally, recent and rescued notes rise, which fixed the dentist case. The recency half-life counts from your newest note so a library you haven't touched in a year doesn't rank as if everything had gone stale.
Climbing
I hill-climbed the reference implementation first, in Rust in gravity-core, running the eval after every change and checking the held-out set beside it. The held-out score tracked the main one the whole way, which told me the changes would hold for searches I hadn't written. With the model switched off, keyword matching alone now finds 192 of 200 on the main set and 190 held out. The model adds the rest.
Then I made it fast again, because the first working version wasn't:
- Finding the newest note's sort key scanned the table for 4 to 11 ms. An
INDEXED BYhint made it instant. - The word stage takes the best 250 notes by BM25 plus the newest 50. When more than 2,000 notes match, as with a single letter, it takes the newest 250 and skips BM25.
- The meaning stage runs for searches of two or more words, or when words found nothing. A single word that matched skips it.
- Vectors moved to their own table, so loading them reads 256 bytes a note. Cold loading dropped from 358 ms to 23 ms.
- Folding plain ASCII takes a fast path.
- A setup step that ran on every search, costing 6 ms, now runs once.
- Result excerpts are built in app code. FTS5's
snippet()was quadratic on long notes.
Before and after
All on the 40,400-note library, with the note in the top 10:
| Set | Before | After |
|---|---|---|
| Main, original target only | 0 | 190 |
| Main, counting judged answers | 2 | 198 |
| Held-out, original target only | 16 | 194 |
| Held-out, counting judged answers | 17 | 198 |
| Controls, both sets | 400 | 400 |
Speed is every keystroke of 400 typed searches, 2,329 searches in all, on an M3 Pro with a warm cache, as the apps run it:
| Notes | Median before | Median after | 99th percentile before | 99th percentile after | Slowest before | Slowest after |
|---|---|---|---|---|---|---|
| 1,000 | 0.33 ms | 0.29 ms | 1.80 ms | 2.06 ms | 9.6 ms | 3.1 ms |
| 10,000 | 0.73 ms | 0.78 ms | 5.70 ms | 3.27 ms | 16.3 ms | 7.2 ms |
| 40,000 | 1.58 ms | 1.88 ms | 15.3 ms | 8.33 ms | 48.6 ms | 29.5 ms |
| 100,000 | 2.88 ms | 2.40 ms | 35.4 ms | 9.58 ms | 149 ms | 14.1 ms |
Medians stay about the same, the slow tail is much shorter, and the short-tag bug went from 36 ms with no results to 6 ms with the right note. I set out to make no search slower, and that didn't fully hold. Fifteen of 196 fixed searches got slower by 0.5 to 3 ms, all of them searches by meaning. All 15 are searches where meaning now runs: rare words, words that match nothing, and searches of several words. The worst is bluebell at 40,000 notes, from 0.44 ms to 3.43 ms. I think 3 ms is a fair price for finding the note.
On the phone, the planner fix matters more than anything else in this post. With the new SQL on 40,400 notes, a one-word search takes 7 ms in Node and 32 ms with the JIT off, which is close to how Hermes runs on a phone. A 20-word paste takes 17 and 174 ms. Indexing a library that size the first time takes about 33 seconds with the JIT off, in the background, a chunk at a time. The Mac app indexes 2,000 notes in 387 ms and then searches in 1 to 4 ms.
The command line tool starts a new process for every search and loads the vectors each time, so it's the slowest path. At 40,000 notes a meaning search like where is the spare key takes 19 ms warm and about 150 ms cold. At 100,000 notes, cold meaning searches take 320 to 380 ms, over my 250 ms target. The apps keep vectors in memory, so they don't pay that.
One contract, three implementations
Gravity's apps don't share code. The CLI is Rust, the phone app is TypeScript in React Native, and the Mac app is Swift. They share a written contract instead: section 6 of the note contract specifies the index schema, the fold character by character, every alternate form, the model's file format and arithmetic, the query rules and the scoring formula. Beside it sit golden files, search documents, vectors and ranked results for fixed inputs, which every app must reproduce exactly.
I wrote the Rust reference first, then ported it to TypeScript and Swift. Both ports reproduce every golden file. The ports also found two bugs in the reference. The queue trigger used INSERT OR IGNORE, and an upsert on a note already in the queue failed, because an upsert's conflict clause overrides OR IGNORE. It's ON CONFLICT DO NOTHING now. And a wide character just before a word broken across lines crashed the indexer. Both fixes went into the contract, so all three apps got them.
Search in Claude and ChatGPT
Gravity has an MCP server that lets Claude, ChatGPT and other AI apps save and find notes in your synced library. Its search and list_notes tools had their own search, on the sync server, and it was the old design again. On the eval it scored 2 of 200 on the main set and 17 of 200 held out, the same as the old app search.
The sync server is a Cloudflare Worker with one SQLite-backed Durable Object per library, which I wrote about in how Gravity Sync keeps both versions. I moved the TypeScript port into a shared package that the phone app and the Worker both import. The Durable Object runs the same search v2 over its own copy of your notes.
The model ships with the Worker as a static asset behind the ASSETS binding, which the Worker reads and never serves over HTTP. Workers have tight CPU limits per request, so the Durable Object indexes in bounded chunks: up to 200 notes before a read, the rest on alarms alongside the nightly backup. If the model ever fails to load, search falls back to words only.
| MCP search | Main | Held-out | Per search |
|---|---|---|---|
| Before | 2/200 | 17/200 | ~1 ms |
| Words only | 192/200 | 190/200 | 2.4 ms |
| With the model | 198/200 | 198/200 | 6 ms |
Those times come from running the backend's own code on its schema in Node. In workerd, Cloudflare's runtime, 40,000 notes take about 8 seconds of CPU to index once, and a search takes 7.5 ms. When you ask Claude to find the note about the spare key, it now finds the same note your phone would.
What it still gets wrong
Four searches still fail, two in each set:
| Search | What it needed |
|---|---|
heating broken |
That a note saying "boiler keeps cutting out" is about broken heating |
baby supplies tesco |
That nappies and Calpol are baby supplies |
what time does the tip open |
That in Britain the tip is the recycling centre |
how do i top up the boiler pressure |
To rank this note above duplicate notes saying "top up if low" |
The first three need knowledge about the world that a static word model doesn't have. I tried larger static models, combining two models, and expanding searches with the model's nearest words. None fixed them without breaking something else. The fourth is a ranking call between near-duplicates, and the judge didn't count the others as equally good. A small language model could probably fix all four, but it would be hundreds of megabytes and far slower than 3 ms, and that isn't the trade I want for a notes app that opens ready to type.
The eval, the held-out set, the benchmark and the golden files all stay in the repository. A future change to search has to beat 198 without breaking the controls or the speed table, and the four misses are the cases to beat.
Search v2 is in the latest Gravity for iPhone, iPad, Android and Mac, and your library indexes itself in the background the first time you open it. If you search for something you know is there and Gravity doesn't find it, tell me what you typed at the contact page. Each miss becomes a new case in the eval.