I run a recommendation site. You type something you loved and why, and it finds films, shows, books and games that match. Under the hood it's the standard 2026 stack: an LLM reads the query, an embedding model turns it into a vector, Postgres with pgvector finds the nearest titles.
Last week I opened the page for Jaws. Under "More films like Jaws":
Sand Sharks. Toxic Shark. Sharktopus.
Nobody who loved Jaws wants Sharktopus. Well, almost nobody.
The embeddings weren't broken. They were doing exactly what embeddings do, which isn't quite what the word "semantic" promises. Here are five ways my search matched words instead of meaning, how each one showed up, and what fixed it.
1. The name is in the vector
Hollow Knight is a hard, lonely, beautiful game about exploring a ruined kingdom. Its nearest neighbours included Knights in the Nightmare and The Knight Witch. Kal Ho Naa Ho, a Bollywood weepie, sat next to Kuch Naa Kaho and Kaho Naa… Pyaar Hai.
None of those are alike. They share words in the title.
I did this to myself. Every item is embedded from a text that starts with its name:
// so that queries naming a work ("like Arrival") land near it
return `${item.title} (${item.year}). ${item.medium}. ${item.description}`
That line made sense at the time, and it does help a query that names a title. But a title is a few heavily weighted tokens at the very start of the text, and two works that share them get pulled together no matter what the rest says.
The fix I shipped (the root fix is re-embedding without titles, which means recomputing everything): when a search starts from a title, demote results whose only tie to it is a shared word.
const titleWords = (t: string) =>
t.toLowerCase().split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length >= 4 && !SMALL_WORDS.has(w))
.map((w) => w.replace(/s$/, '')) // "Knights" → "knight"
const echoes = (item) => titleWords(item.title).some((w) => anchorWords.has(w))
return [...results.filter((r) => !echoes(r)), ...results.filter(echoes)]
Demote, don't drop: sometimes a shared word is a real connection. The four-letter minimum is a deliberate trade-off. It catches "knight", but not "naa", because a rule loose enough to catch "naa" would also catch "man" and "war".
Check yours: take ten items and look at their nearest neighbours. If the neighbours share words in the name more often than they should, your names are in your vectors.
2. The nearest neighbour of anything is its sequel
"Movies like Jaws" returned Jaws 2, Jaws 3-D and Jaws: The Revenge in the top five. "Games like Hollow Knight" returned its own DLC.
Of course it did. A sequel shares the setting, the tone, the characters, half the description and the name. It's the most similar thing in the catalog, and the least useful recommendation: anyone asking for "movies like Jaws" already knows Jaws 2 exists.
The fix: a blunt franchise key (the first two meaningful words of the title, minus subtitle and trailing number), so that a search starting from a title leaves out the title's own franchise and keeps one result per franchise:
franchiseKey('Jaws: The Revenge') // "jaws"
franchiseKey('The Witcher 3: Wild Hunt - Hearts of Stone') // "witcher"
franchiseKey('Jaws 3-D') // "jaws 3" ← my first version. The "-D" broke the number rule.
That last line is real. The test I added after finding it is two lines long. Write the test.
3. The LLM rewrote the user's intent away
This one took me the longest to see, because the results looked fine.
Search "movies like Interstellar" and you get 2001, Ad Astra, Gravity, Arrival. Good.
Search "movies like Interstellar: the ache of leaving children behind" and you get… 2001, Ad Astra, Gravity, Arrival. The same list, in nearly the same order. The part the user actually cared about changed nothing.
My pipeline has an LLM step that rewrites the query into a richer description before embedding it. Here's what it produced:
"Vast, awe-filled space drama with a melancholy ache about leaving children behind; sweeping, emotionally weighted, a father's sacrifice… set against cosmic scale, somber and contemplative with a big orchestral score."
The user wrote eight words of why. The rewrite buried them in forty words describing Interstellar. Then my code blended the query vector 50/50 with Interstellar's own vector on top. The why was drowned twice.
My first instinct was the wrong one: turn down the blend. At 25% and at 15%, the results barely moved. I was tuning a knob that wasn't the cause.
My second instinct was also wrong: fix the prompt ("when a title is named, describe only the why"). The Interstellar results improved, and my eval score fell from 37% to 34%. A prompt change shifts how the model reads every query, including the 28 that never named a title.
The fix that worked was in code, not the prompt. When a query names a title and says why, embed the why in the user's own words and let the title's vector supply the rest:
const why = whyBeyondTitles(query, references.map((r) => r.title))
// "Movies like Interstellar: the ache…" → "the ache of leaving children behind"
const base = why ? await embedQuery(why) : await embedQuery(understoodText)
queryEmbedding = blend(base, titleVector, 0.5)
Now the same search leads with Leave the World Behind, Children of Men and E.T. And every eval case scored exactly what it scored before, because a query that doesn't name a title never reaches this code path.
The lesson: an LLM rewrite step is a lossy step. Print what it produces for your hardest queries, not your typical ones.
4. The average said fine. The cases said otherwise.
My database was too small to keep the vectors in memory, so I tested shrinking them. OpenAI's text-embedding-3-small is trained so you can keep only the first N dimensions, and 768 instead of 1,536 halves the size. Half precision (2 bytes a number instead of 4) halves it too.
My eval (28 real-style queries, recall@12) said:
| Variant | Mean recall@12 |
|---|---|
| Full vectors | 38% |
| 768 dimensions, half precision | 35% |
Three points. Probably noise, right?
Then I read the individual cases. The post-apocalyptic story game search lost both Last of Us games. The political fantasy search went from 75% to 25%, losing House of the Dragon and The Witcher. Those aren't reshuffles near the cutoff. Those are the answers.
Half precision at full length (halfvec(1536) in pgvector), by contrast, changed zero of 28 cases.
The lesson: report "up / down / flat" per case next to the mean, and read the downs. My eval script now prints this on every run:
vs last run on the same 28 cases: 38% → 35% · 4 up, 5 down, 19 flat
A mean can hide one disaster behind three small wins. A list of the five worse cases can't.
5. "Nearest" isn't "good"
I wanted a row under each search: "the same feeling, in other media". Searched for a TV show? Here's the book and the game that give you the same thing. Nobody else does this, and it's a great demo.
Searched "shows like Breaking Bad: a decent man slowly turning bad". Nearest film: Breakdown. Nearest game: Downfall.
A shared syllable and a vibe of things going badly. Across media it gets worse, because books, films and games are described in different vocabularies, so the nearest item from another medium is often the one that shares a word with the query rather than a meaning.
I tried a similarity floor: only show an item if it's at least X% as close as the main results. At 88%, the junk got through. At 90%, the junk still got through, and good picks started dropping out. Similarity scores across domains just aren't calibrated against each other.
The fix: retrieval proposes, an LLM disposes. The three nearest items from each medium go to a small model with one job, and it's explicitly allowed to say no:
Keep only candidates that genuinely give the user the same thing they
asked for... Sharing a word, a setting or a genre is not enough. Be
strict: keeping none is fine, and better than keeping a stretch.
Breaking Bad now gets The Iceman ("a seemingly ordinary family man living a double life") and Darkly Dreaming Dexter ("a respectable professional hiding a dark nature"). A courtroom drama gets Phoenix Wright: Ace Attorney. A heist film gets Six of Crows and Hitman 3. It costs about $0.0002 a search, and when nothing fits, the row just doesn't render.
The line that matters is "keeping none is fine". Without it, the model fills every slot, because you asked for slots.
The pattern
All five failures look the same from the outside: results that are close and wrong. And all five came from the same assumption, that vector similarity measures the thing you care about. It measures overlap in the text you embedded. Sometimes that's meaning. Often it's names, sequels, the LLM's paraphrase, or vocabulary.
What actually caught these:
- Looking at neighbours by hand, for the items you know best. Every one of these I found by looking at a page, not at a metric.
- A small eval with per-case diffs, run on every change. Mine is 30 queries. It doesn't need to be big. It needs to be read.
- Printing the intermediate text. The rewrite step was the bug, and it was invisible until I printed it.
- Letting a model say "none". Retrieval is good at candidates and bad at refusing.
And keep your sense of humour: it really did recommend Sharktopus.
The site is Because I liked: describe what you loved and why, and get films, shows, books and games matched on the why. Try a title with a reason, "movies like Jaws: the dread of what's under the water", and tell me what it gets wrong. That's how I find these.
Top comments (0)