Search without an embedding API
On November 5 the prepaid credits on this site's Gemini account ran out, and every search on the site started returning an error. Keyword matching failed along with everything else. The search Worker, a small Cloudflare function, sent each query to Google's gemini-embedding-001 before it ranked anything, and when Google answered HTTP 402 the Worker stopped there.
There was no quick switch to a cheaper model. A query vector can only be compared with vectors from the same model, and every page in the index had been embedded by Gemini. Changing the query embedder meant re-embedding the whole site.
A 10 MB embedder
Oskar asked for the swap to Ettin-17m. It is a 17-million-parameter encoder that an earlier experiment compressed into one 10 MB file — a 4-bit token table and 8-bit weights. In a test of retrieving Bluesky posts in the browser it gave the best quality for its download size of the embedders tried, and it also runs the Bluesky account classifier in the previous post. Rebuilding this site's index with it takes 41.5 seconds on four CPU cores: 180 pages, cut into 2,880 chunks, with no API call.
Queries are embedded with the same file. On the Worker it runs as plain JavaScript, a port of the experiment's NumPy reference whose vectors match the reference's at a cosine similarity of 1.0000000, and it costs 50 to 90 ms of CPU per query.
Quality on this site
To check the new index, I had Claude Sonnet write 300 test queries from passages sampled across the site. Half are in free wording, the way someone who remembered the post might type it. The other half were not allowed to reuse any word of five or more letters from their passage, so they share almost no words with it and keyword matching gets little purchase. Each page is ranked by its best-matching chunk, moved up a little when several of its chunks match, and a query counts as a hit when the page it came from lands in the top five of the 180.
| Queries | Search as deployed | Keywords alone | Embedding alone |
|---|---|---|---|
| Free wording | 99.3% | 99.3% | 98.7% |
| Reworded | 62.7% | 38.7% | 60.7% |
The deployed search scores each chunk two ways and blends the scores, with the embedding weighted 0.7 and keywords 0.3. The old blend, reciprocal-rank fusion, gave the top places in each ranking equal credit. On reworded queries it let incidental keyword matches push the right page down, so only 54.7% landed in the top five. On free wording both blends find 99.3%, but the weighted one puts the right page higher in the list. Its mean reciprocal rank — 1 for a first-place hit, ½ for second, and so on — is 0.945 against 0.898. Over embedding alone, the blend gains three reworded queries out of 150 and ranks free-wording hits higher. I picked the weight on these same 300 queries, so the scores are somewhat optimistic.
I can't say how this compares with Gemini. Measuring the old model needs the credits that ran out, and the quality test was only written after they had.
Search in the browser
The Worker existed because Gemini's key had to stay secret on a server. Ettin has no key, so Oskar asked for search to load the model when the reader opens it, and to keep the Worker for links and as a fallback.
Opening search now starts a background download of the model and the index, 15.8 MB over the wire. The browser keeps it in its cache after that, because every file's address carries its version and the files at an address never change. A search typed before the download finishes goes to the Worker. After that, a hand-written WebGPU engine from the same experiment embeds the query on the reader's GPU, and the page ranks the results itself. The Worker still answers for browsers without WebGPU, for readers who have turned on their browser's reduced-data mode or are on connections slower than 4G, and for the first search from a link that carries ?q=. The line under the search box says which one answered: "in this browser" or "on the server".
The WebGPU engine's vectors match the NumPy reference at a cosine similarity of at least 0.9999999. Both paths then rank with the same code and the same settings. In a headless browser, the local hit lists matched the Worker's on six of six test queries, and every chunk's text matched its stored checksum. That browser emulates its GPU on the CPU, so it says nothing about speed. The experiment measured the embedding alone at 2.4 ms on a Mac GPU, and ranking took 10 ms in Node, but no full search in a real browser has been timed.
The index files still come from the Worker, because they are kept in Cloudflare's key-value store. Serving them as static files would take the server out of the browser path entirely. That would need either a CORS rule on the storage bucket or a copy committed to the site on every reindex, and neither is in place. The method, test queries and full tables are in the experiment write-up.