Recovered record ·· words.0007
Did You Mean?
This article is about the URI guesser on this site: what happens when you type a URL that doesn’t exist, and why the “AI” part of it turned out to be the least clever bit.
When I rebuilt this site, every single URL moved. The old posts lived at the root, projects moved from /projects to /work, games from /games to /play. The obvious ones got a plain redirect, and that’s that. But what about the ones nobody thought of? The typos? The half-remembered links? The person who swears there was an essay about Rooster Teeth on here somewhere?
Normally they get a 404. A dead end. That bothered me.
So I asked myself: what if the 404 page could guess?
How it works
The site is flat files served by nginx. No database, no backend. I wanted to keep it that way, so the guessing lives in a small sidecar service next to it, and nginx only asks it when it has already given up:
location / {
try_files $uri $uri/index.html $uri.html =404;
}
error_page 404 = @guess;
Real pages never touch the guesser. Only when nginx can’t find a file does it hand the path over, and the guesser answers one of two things: a 302 to the record it thinks you meant, or a 404, in which case you get the normal “no such record” page. If it isn’t confident enough to send you anywhere, that page asks it for a “did you mean” list instead.
The important bit: it is never load-bearing. If the guesser is down, slow or getting hammered by bots, nginx just serves the plain 404 page like nothing happened. And it can only ever answer with a URL from the site’s own list of records, so the worst case is a wrong guess, never a broken link.
Three ways to be wrong
Now, “guess what the user meant” sounds like one problem, but it’s really three:
- Typos.
/words/greif-by-proxy. A simple string comparison against every slug catches that one easily. - Meaning.
/rooster-teethshould find the podcast database, even though those words aren’t in its URL. This is where the AI comes in: a small embedding model (MiniLM) turns the path and every record into vectors, and compares them. - Names. Which is where it got interesting.
My test case was, fittingly, one of my own old posts. Back in 2015 I wrote about grieving Monty Oum, so I typed /monty-oum and waited for the magic.
Nothing. A “did you mean” at best, with a confidence of 0.37. The summary of that essay literally says “On mourning Monty Oum”, and the model still wasn’t sure. Turns out, small embedding models are bad with names. To them, “Oum” is just a couple of meaningless word fragments.
Bigger is better, right?
My first thought was: just use a bigger model. So I benchmarked one. Qwen3’s embedding model is more than 25 times the size of MiniLM, and should know a lot more about the world.
It was worse. Much worse.
Out of the box, it sent /monty-oum to the Work page. It didn’t know what /rwby was either. But the real killer was the gibberish. Even with proper instructions, /asdkjh scored 0.55 against a real page, which is higher than the correct answers for /monty-oum (0.46) or /buffy (0.45). When nonsense scores better than the right answer, there is no threshold in the world that’ll separate them. And it did all that 150 times slower, using eight times the memory.
Counting words
What actually fixed it was about as old-school as it gets: check which words from the URL appear in each record’s title and summary, and weigh each word by how rare it is. A word that only shows up in one record points straight at it. A word that shows up everywhere says nothing. It’s basically TF-IDF, the kind of thing search engines did long before anyone said “embedding” out loud.
/monty-oum -> /words/grief-by-proxy/ (1.00)
/the-monty-oum-essay -> /words/grief-by-proxy/ (0.67)
/buffy -> /words/my-love-for-storytelling/ (1.00)
/asdkjh -> nothing (0.00)
Gibberish matches no words, so it scores exactly zero. Every time. No tuning required.
The model still has a job, though. Word counting only works if you happen to use the same words I did. The model is there for when you don’t. So now the three work together: the string comparison catches typos, the word counting catches names, and the model catches meaning. Each one covers for the others’ blind spots.
Final thoughts
It’s tempting to throw a bigger model at a problem and call it a day. Heck, that’s what I did first! But the dumbest-sounding solution was the one that worked, it runs in under a millisecond, and it’ll never confidently send you somewhere you didn’t want to go.
Go ahead, try it. Type something wrong. I can wait.