Two constraints decided the design
A download budget. Visitors arrive on phones and slow connections, so I set a ceiling of about 30 MB. The smallest generative models that write usable answers run to hundreds of megabytes, and the useful ones further still. That ruled out a chatbot before any code was written.
A portfolio can't invent facts. A model that confidently states a wrong title or a made-up number about the person it represents is worse than no model at all. So the box never generates text. It finds the best answer among ones I wrote and approved, and when nothing fits well enough it says so and points to my email.
Together those make it an answer finder: a small model that understands what a question means, matched against a fixed bank of approved answers.
How it works
- A static embedding model, not a neural network at question time. The model is potion-base-4M, a model2vec model distilled from bge-base-en-v1.5. Every word piece maps to one 128-number vector, and a sentence is the normalized average of its pieces. There is no inference engine to download.
- Quantized to 3.9 MB. I converted the 15 MB float table to 8-bit integers with one scale per row. The whole box, page and model together, transfers about 4 MB.
- A hand-written tokenizer, checked id for id. The tokenizer is about 100 lines of plain JavaScript. A test runs it against the reference tokenizer on every string in the bank plus edge cases (accents, emoji, punctuation, very long words) and fails on any difference, because a silent mismatch would quietly corrupt every vector.
- Matching is a dot product. Each of the 62 answers has several sample phrasings, 494 in all. A question scores against every phrasing and takes the best. Below a threshold of 0.45, the box declines rather than guess. A full search takes under a millisecond.
- Answers are shaped like a conversation. A simple fact gets a sentence, a story gets a short paragraph or two, and a list gets a list only when the content really is one. Each leads with the direct answer, links into the site, and offers two follow-up questions I picked for it.
Choosing the model
I compared the static models against two small transformer models, MiniLM and bge-small, which run through a WebAssembly inference engine in the browser, scoring every model on the same held-out questions:
| Model | Download | Result |
|---|---|---|
| potion-base-4M | 3.9 MB | Shipped: the best accuracy for its size |
| potion-base-8M | 7.7 MB | No better than 4M once the answer bank improved, at twice the size |
| MiniLM-L6 | about 37 MB | Better at picking the first answer, and much better at declining off-topic questions |
| bge-small | about 48 MB | The most accurate, at twelve times the download |
The transformers were better, but not by enough to justify a download nine to twelve times larger on every phone that opens the page. Their main advantage was declining off-topic questions, and that matters less here than it would in a chatbot: a wrong match can only ever show an answer I approved, never an invented one.
Not grading my own homework
The easiest way to make an answer finder look good is to tune its phrasings until the test questions pass. Then the score measures nothing. Three rules kept it honest:
- Write the test before the run. Each held-out set was written before any model was scored on it: recruiter-style questions, fragments, typos, and off-topic questions the box should decline.
- Once you've looked, write another. After I used one set's misses to fix two phrasings, that set was no longer held out. I wrote a third set before scoring the final bank on it, and that third set is the only number I quote.
- Make copying impossible. A test fails if any phrasing in the bank copies, or nearly copies, a test question: the same words in any order, with at most one word different. It caught me twice: once when new phrasings came too close to test questions, and once when new test questions came too close to phrasings already in the bank.
On that third set the box puts the right answer first 81% of the time and in its top three 93% of the time, and it declines all ten off-topic questions. Each score is also a floor: a test fails if a future edit to the answers pushes any of them lower.
The thinking line is a wink; the trace is the work
Matching takes under a millisecond, which reads as nothing happening. So for about a second the box shows a thinking line the way Claude Code does, a blooming asterisk and a whimsical verb (Flibbertigibbeting, Sidewinding), and then the answer. The verb is the only playful thing in it. The line then becomes a receipt with the real compute time, and tapping it opens the trace: the steps the model actually performed, from the word pieces it read and the 128 numbers it turned them into, through the comparison against all 494 phrasings and the three closest answers with their scores, to the confidence check against the threshold. I first paced those steps over three seconds so a person could follow them; it was too long to wait through every time, so now they wait for whoever opens them. Visitors who ask their system for reduced motion get the answer instantly.
The orb in the bar is my Samantha UI shader orb, embedded in a mode the page can drive: it idles while you read, turns violet while it thinks, and glows coral while the answer appears. On a phone it's a drawn stand-in with the same three moods, since a live shader that small stuttered on phones and wasn't worth the battery.
Following a conversation
People rarely ask one tidy question. The box now reads each message in the light of the last answer, and it still never writes a word of its own: every reply is an answer I approved or a sentence copied from one, and every step shows in the trace.
- Follow-ups. After the copilot answer, "how long did that take?" gets the one sentence that fits: four weeks from first prototype to internal pilot. A follow-up that names a linked answer, like "does it use RAG?", goes to that answer instead.
- Two questions at once. "Can he code, and where is he based?" is split and answered in order, but only when each half confidently matches a different answer.
- Typos and names. "wher is he baesd" is repaired against the bank's own words, and "Sam" is read as "he". Both are kept only when they improve the match.
- "Did you mean" instead of a flat no, only when a question comes close to an answer and shares a telling word with it.
On a fresh set of test conversations, written blind from the answers' text and scored once, it answers 16 of 18 misspelled questions (was 8), both halves of 10 of 15 two-part messages (was none), and splits just one of eight questions that only look like two. Follow-ups are the honest weak spot: 6 of 15 about the answer just shown land (was 1), but now and then it reads a new question as a follow-up, and when nothing covers a follow-up it can offer the closest sentence where the old box declined. So the trace says "the line that fits best", never "the answer".
What I'd tell the next person
- Let the constraints pick the architecture. A download budget and a rule against invented facts pointed to retrieval over approved text before any model was chosen.
- A held-out set you've tuned against isn't held out. Track which sets you have looked at, and quote only the one you haven't.
- Show the real work. People trust a system more when they can watch what it did than when it performs effort it isn't making.
Links
- Source, answer bank and evaluation sets (desertcache/ask)
- The box on its own page
- potion-base-4M and model2vec by Minish Lab
- bge-base-en-v1.5 by BAAI, the source of the vocabulary