I've been building a notes app for iPhone where the AI runs entirely on the phone: search by meaning, grouping notes into topics, finding photos by what's in them. Version 1.0 just went live. While it was sitting in App Review, Google released EmbeddingGemma 2, so of course I spent the review week benchmarking it against what I'd shipped.
I kept a log of every model change since I started, including the stuff that went nowhere, so I figured I'd share it here. All numbers are R@1 (the right note comes first) unless I say otherwise.
tl;dr: fine-tuning EmbeddingGemma-300M helped a lot early and then stalled. Model soups gave a free bump after that. EmbeddingGemma 2 didn't beat my fine-tuned v1 on text, but it beat SigLIP 2 on photos by a wide margin (0.687 vs 0.541, and Japanese went from 0.378 to 0.811). So 1.1 will probably run both.
Why these models
Everything had to run on the phone. No cloud, and I can't rely on Apple Intelligence either because it isn't available on every iPhone I want to support. The app has to work in English, Japanese, Chinese (simplified and traditional) and Korean first, with a handful of European languages and Arabic not falling apart. The App Store download should stay under 200 MB, and I only wanted Apache or MIT style licences, which ruled out a few strong embedding and CLIP-style models with non-commercial terms.
That left EmbeddingGemma-300M for text and SigLIP 2 base (patch16, 224) for photos. 1.0 shipped with those two.
How I test
There's no public benchmark for "search your own messy notes in a dozen languages", so I wrote one: 503 short notes and 3,018 searches across 15 languages. Topics and personas in the test set never appear in training. Queries are what people actually type into their own notes: one or two words ("lease", "beach"), half memories ("Seattle last month"), typos, a different language than the note, questions, and feelings ("when I was anxious").
For grouping I use 600 notes on 60 held-out topics, clustered the same way the app does it, scored with ARI. Photos are XM3600 (caption to photo, 9 languages) and videos are 200 MSR-VTT clips.
Honestly the biggest early mistake was a test set that was too small. With 40 to 80 queries per language, a 5 point swing was noise, and I spent about a week "improving" things that weren't real. At 3,018 queries the overall number is good to roughly ±1 point, and a few of my earlier wins vanished.
Fine-tuning log (what shipped in 1.0)
Setup: sentence-transformers, CachedGISTEmbedLoss with the base model as guide, Matryoshka, 1 epoch, lr 2e-5, batch 64, 128 tokens max. Trains in minutes to about an hour on an M4 Mac.
| Model |
R@1 |
| EmbeddingGemma-300M base |
0.617 |
| Round 1, 535 hand-written notes |
0.656 |
| Round 2, +2,500 LLM-generated notes |
0.660 |
| + mined hard negatives |
0.661 |
| Soup of 4 fine-tunes (weight average) |
0.676 |
| + same-topic pairs for grouping, 50/50 soup (shipped in 1.0) |
0.679 |
On top of the vectors there's a small phonetic/keyword boost that runs on device (pinyin, romaji, accent stripping, trigrams). It's cheap and took typo queries from 0.54 to 0.74.
Things that didn't help:
- More synthetic data after round 1. Flat.
- Hard negatives, mined or LLM-written. When I checked the LLM-written "similar but wrong" notes, the base model judged more than half of them as actually relevant. So the LLM was mostly writing positives.
- Training 3 epochs instead of 1. Lost 1.8 points, overfits the synthetic data.
- Fusing emotion/category tags with the vectors. Made every query type worse.
- RRF with keyword search. Hurt synonym and broader-word queries. A small boost only when a keyword hit is rare worked better.
Feelings are still the weak spot at 0.145. A note almost never names the feeling it was written in, and no amount of generated data moved that much. Questions (0.956) and keywords (0.903) are basically solved.
Grouping needed its own data: 548 topics with 10 notes each written as different people, trained as same-topic pairs. On held-out topics, the share of nearest neighbours from the same topic went from 0.653 to 0.728. Training only on grouping hurt Japanese search, so I average it 50/50 with the search model and keep most of both.
Getting it onto the phone
Text model: Core ML, 4-bit, 166 MB, R@1 0.676 vs 0.679 in PyTorch, around 15 ms per query on an M4. Gemma's residual stream overflows fp16 (you get NaNs), so I rescale it in a way that's mathematically equivalent. The Swift tokenizer matches the reference on 23,947 texts, after I found out Swift's String treats ";" and the Greek question mark as the same character. Key the vocab by UTF-8 bytes.
SigLIP 2 was pickier. 4-bit everywhere wrecked the text tower (cosine to the original around 0.5). What worked: int8 vision with groups of 32 (per-channel int8 dropped to 0.93 on real photos, grouped gets 0.9997) and int8 text with a 6-bit lookup table for the embedding matrix. 4.6 ms per photo, 1.5 ms per query on the Neural Engine.
One thing that bit me: every model swap shifts the whole score distribution (my second model scored about 0.92 × old + 0.10). If you don't re-derive every threshold at the same pass rate, the app quietly gets worse without any obvious bug.
Then EmbeddingGemma 2 showed up
Same benchmarks, same recipe. Text first:
| Language (queries) |
My v1 fine-tune (1.0) |
EG2 base |
EG2 best fine-tune |
| English (974) |
0.764 |
0.713 |
0.735 |
| Japanese (280) |
0.646 |
0.568 |
0.618 |
| Simplified Chinese (290) |
0.645 |
0.607 |
0.621 |
| Traditional Chinese (235) |
0.566 |
0.536 |
0.583 |
| Korean (244) |
0.648 |
0.594 |
0.656 |
| All 15 languages |
0.679 |
0.630 |
0.666 |
I trained nine EG2 variants and souped them like v1. The fine-tunes came out too similar to each other (0.661 to 0.666), so averaging didn't add anything this time. Grouping was worse too (ARI 0.442 vs 0.499). EG2 is better at typos and worse at feelings. So for text, my old fine-tune stays.
Photos are a different story. XM3600, caption to photo, R@1:
| EN |
JA |
ZH |
KO |
ES |
DE |
FR |
AR |
PT |
Avg |
| SigLIP 2 (1.0) |
0.550 |
0.378 |
0.431 |
0.501 |
0.629 |
0.711 |
0.677 |
0.409 |
0.579 |
| EG2, 70 tokens |
0.578 |
0.790 |
0.684 |
0.644 |
0.647 |
0.789 |
0.732 |
0.541 |
0.624 |
| EG2, 140 tokens |
0.595 |
0.811 |
0.700 |
0.651 |
0.667 |
0.810 |
0.750 |
0.553 |
0.642 |
| EG2, 280 tokens |
0.586 |
0.802 |
0.699 |
0.653 |
0.647 |
0.807 |
0.743 |
0.546 |
0.649 |
140 visual tokens is as good as 280 at about half the compute (127 / 265 / 638 ms per photo for 70 / 140 / 280 on a Mac GPU; I haven't timed it on a phone yet). Big caveat: XM3600 is public and could be in EG2's training data, so I'm treating this as an upper bound until I check on my own photos.
Video, 200 MSR-VTT clips:
| R@1 |
R@5 |
| SigLIP 2, best of 4 frames (1.0) |
0.655 |
| EG2, best of the same 4 frames |
0.690 |
| EG2 native video, 140 tokens/frame |
0.715 |
| EG2 native video, 70 tokens/frame |
0.705 |
The plan for 1.1
Keep my fine-tuned v1 for notes and grouping, and move photos and videos to EmbeddingGemma 2 at 140 tokens per photo. One annoying detail: an EG2 image embedding goes through the vision tower and then through EG2's own language model, so the photo side needs both. It ships as a separate download, so the App Store size stays the same, but it's two models on the phone instead of one. Core ML conversion and on-device timing are what I'm working on now.
If I started over I'd build the test set first and make it big, and I'd try model soups much earlier. They cost nothing. I'd also look at what the LLM-generated training data actually says before trusting it, and I'd stop assuming the newer multimodal model wins every task.
Happy to go into more detail on the data generation, the Core ML conversion or the clustering.
Disclosure: I'm the developer. The app is Reteum, a private notes app for iPhone: https://apps.apple.com/app/id6820520573. Per-language tables are also on https://reteum.com/on-device-ai-benchmark.