Similarity metrics: cosine, dot product and Euclidean distance
Tuesday, 9:05: the new duplicate-review detector hid 47% of last week's reviews. It thinks "the room was clean" and "the room was not clean" are the same review, thanks to a 0.8 threshold copied from a blog post.
There are three common ways to say how close two vectors are. They can disagree, and knowing when saves you from slow, wrong searches.
- Problem
Search, deduplication and RAG all reduce to one question: how similar are these two vectors, and is that similar enough?
- Why the simple system fails
Teams pick a metric at random, mix normalized and unnormalized vectors, query with an operator the index wasn't built for, and copy a '0.8 means relevant' threshold from a blog post written for a different model.
- Engineering concept
Cosine measures direction, dot product measures direction times length, Euclidean measures straight-line distance. On unit-length vectors all three rank identically. Thresholds are model-specific and must be calibrated on labelled data.
- Technology
L2 normalization at write and query time, pgvector operators (<=>, <#>, <->) with matching index operator classes, score histograms on labelled pairs, rerankers for relevance decisions.
- Trade-offs
Dot product is cheapest but only safe on normalized vectors or models trained for it; cosine is the safe default; Euclidean matters for some clustering and non-text embeddings. Fixed thresholds are simple but brittle.
The problem
2 min- Omar · On-call SREIt should catch copy-paste review farms: about 3% of new reviews. Last week it hid 47%.
- Maya · Product managerIt hid "The room was not clean" as a duplicate of "The room was clean".
- You · Engineer at StayHubThe rule hides a review if cosine ≥ 0.8 with an earlier one. I found 0.8 in a blog post.
- Lina · Senior engineer, your mentorGuess first: what's wrong with it?
The big idea
4 minA similarity score isn't a probability: a threshold belongs to one model and one kind of data, so you measure it on labelled pairs, never copy it.
Cosine was never the problem. With our model, unrelated reviews of the same hotel score around 0.71, so the blog's 0.8 sits inside the "unrelated" range. Embeddings capture what a text is about far more than a single "not".
How it works
5 minAn embedding is a list of numbers (a vector). To ask "how alike are two texts?" you compare their vectors. There are three common ways, and two rules that keep them honest.
- 1Cosine
compares direction only: 1 means same direction, 0 unrelated, −1 opposite. The safe default for text.
- 2Dot product
The is direction × both lengths. On raw vectors, long vectors score higher whatever they mean.
- 3Euclidean distance
is the straight-line gap between two points. Smaller means more similar.
- 4Normalize
Normalization scales every vector to length 1 (). Then all three metrics give the same ranking. Do it when you store and when you query.
- 5Calibrate the threshold
A is the score above which you act. Score labelled pairs from your own data and pick the number that meets your goal: high precision for automatic actions, high recall for suggestions.
- 6Match operator and index
In pgvector each metric has its own operator:
<=>cosine,<->Euclidean,<#>inner product. An index built withvector_cosine_opsis only used by<=>. Any other operator scans every row.
Watch a decision being made
4 minLina fixes the duplicate detector's threshold
The detector hides 47% of new reviews with a copied 0.8 threshold. Make it hide only true copy-paste duplicates, with a safe path for borderline cases.
- 1Label real pairs
"Before any number, data." The moderation team labels 800 pairs of reviews from the same hotel, duplicate or not, including tricky ones like clean / not clean.
Why: A threshold is a property of our model on our data. Only labelled pairs from that data can set it.
- 2Read the two distributions
Not duplicates: 0.62–0.80 (median 0.71), with a tail of negation pairs up to 0.92. True duplicates: 0.93–0.99.
Why: Where the two groups overlap tells you which mistakes any threshold will make.
- 3Pick the number for the goal
"Hiding a genuine complaint is the costly error." So aim for 98% precision: auto-hide at 0.94. Scores 0.90–0.94 go to a moderator.
Why: Auto-actions need high precision; a borderline band gets human judgement.
- 4Version it with the model
Store
threshold = 0.94next to the model id, and recalibrate on every model change.textembedding_model: v3 duplicate_threshold: 0.94 review_band: 0.90-0.94Why: A new model moves the whole score range; an untied threshold breaks silently.
In real life
3 minYouTube Content ID: act automatically
- Matches uploads against rights holders' files and applies their policy (block, track or monetise)
- Volume far too large for humans to check each match
- Mistakes are handled afterwards through disputes
Fits huge volume where wrong matches can be appealed.
Google Photos: ask when unsure
- Groups photos of the same face, and sometimes asks "same or different person?"
- Borderline cases go to the owner
- Slower, but avoids confident mistakes
Fits when a wrong merge is costly and a person is available.
What goes wrong
3 minEach model spreads scores differently; 0.8 can mean "identical" or "same hotel".
Calibrate on a few hundred labelled pairs and store the threshold with the model id.
Same results, full scan, latency explodes.
Match operator and index class, and check vector queries with EXPLAIN.
Embeddings capture topic more than negation: clean / not clean scores 0.91.
Use similarity for "same topic"; add a reranker or classifier when meaning details matter.
Trade-offs
3 min| Choice | Fits when | You pay |
|---|---|---|
| Cosine | Text embeddings, unsure about normalization | Slightly more work per comparison |
| Dot product on unit vectors | You normalize everywhere, or the model was trained for it | Wrong rankings if one vector isn't normalized |
| Calibrated threshold + human band | A wrong automatic action is costly | Labelled data, recalibration, a queue to staff |
- Maya · Product managerReview farms are real. Auto-hide at 0.90 and catch more fakes; moderators can restore mistakes.
- Omar · On-call SRE0.90 hides complaints like "not clean". Auto-hide at 0.94, send 0.90–0.94 to moderators.
- Maya · Product managerThen more fakes stay visible until someone looks.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- One metric for the whole system (cosine for reviews), in one shared constant, so index, queries and thresholds can't drift apart.
- Every threshold comes from labelled pairs for the current model (800 pairs → 0.94 for 98% precision), stored with the model id.
- What a match does: auto-hide, or go to a moderator first.
Our duplicate-review detector hid 47% of new reviews; we expected about 3%. Its 0.80 cosine threshold came from a blog about another model. Using the 800 labelled pairs in `data/duplicate-pairs.csv`, find the lowest threshold with 98% precision and show the score ranges for unrelated pairs and true duplicates. Store it in config with the model id. Don't touch the HNSW index or the `<=>` operator. Until I approve, send matches to moderation instead of hiding them.
- The calibration report shows both score ranges, the chosen threshold (about 0.94), its precision and the share flagged (about 3.4%).
EXPLAINon the search query shows the HNSW index, not a sequential scan.- "The room was clean" vs "The room was not clean" is not hidden.
- A new threshold picked by feel ("0.90 looks better") with no labelled pairs or precision number.
- The ORDER BY operator changed "for speed" without checking the query plan.
- A cosine score described as a probability ("0.8 = 80% likely").
Check yourself
2 minNext episode: the concierge's knowledge index grows to 60 million vectors that must be filtered per hotel and fresh within minutes. Karim asks whether Postgres can still carry it.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
Three metrics, three rankings. Query q = (3, 4), length 5. Stored: A = (6, 8) (same direction, length 10), B = (4, 3) (slightly off, length 5), C = (0.6, 0.8) (same direction, length 1).
- A: dot 50, cosine 1.00, distance 5.00
- B: dot 24, cosine 0.96, distance 1.41
- C: dot 5, cosine 1.00, distance 4.00
Dot product ranks A > B > C (C is last only because it's short). Cosine: A = C > B. Euclidean: B > C > A (A is last only because it's far). Normalize everything to length 1 and all three agree: A = C > B, since on unit vectors dot = cosine and Euclidean² = 2 − 2 × cosine.
You fix the duplicate detector
The detector hides 47% of new reviews with a copied 0.8 threshold. Make it hide only true copy-paste duplicates, with a clear path for borderline cases. Lina asks one question per step.
- 1Normalize on both sides
Lina: "Are our vectors unit length?" You check: the model returns unit vectors, but the old import didn't re-normalize after averaging. Normalize at write time and at query time.
tsconst normalize = (a: number[]) => { const n = Math.sqrt(a.reduce((s, x) => s + x * x, 0)); return n === 0 ? a : a.map((x) => x / n); };Why: On unit vectors the three metrics agree, so a later operator choice can't change the ranking.
- 2Label real pairs
The moderation team labels 800 pairs of reviews from the same hotel: duplicate or not, including tricky ones like "clean" / "not clean".
Why: A threshold is a property of our model on our data. Only labelled pairs from that data can set it.
- 3Look at the two distributions
Score all 800. Not duplicates: 0.62–0.80 (median 0.71), with a tail of negation pairs up to 0.92. True duplicates: 0.93–0.99.
Why: Seeing where the two groups overlap tells you which errors any threshold will make.
- 4Pick the threshold for the goal
Hiding a genuine review is the costly error, so aim for 98% precision: threshold 0.94 for auto-hide. Scores 0.90–0.94 go to a moderator.
pythonfor t in np.arange(0.85, 0.99, 0.01): flagged = scores >= t precision = labels[flagged].mean() print(round(t, 2), round(precision, 3), flagged.mean())Why: Different goals need different thresholds: auto-hiding wants high precision; "suggest similar reviews" would want high recall.
- 5Version it with the model
Store
threshold = 0.94next to the model id. Re-run the calibration on every model change. Result: 3.4% of new reviews hidden, and no genuine complaint in a week's sample of 200.Why: Swapping the model moves the whole score range; a threshold that isn't tied to its model breaks silently.
| Metric | Measures | Use when | Watch out |
|---|---|---|---|
| Cosine similarity | Angle only | Default for text embeddings | pgvector's <=> is a distance (1 − cosine); sort the right way |
| Dot product | Angle × both lengths | Unit vectors (fastest, equals cosine), or models trained for it | On raw vectors, long vectors win regardless of meaning |
| Euclidean (L2) | Straight-line distance | Clustering, models trained with L2 | Matches cosine's ranking only after normalization |
Interactive simulation
Visual diagram
Where the metric choice lives in a semantic search pipeline, and the three places it must agree: the model, the stored vectors and the index.
Analogy
- Three hikers leaving a hotelAna walks 2 km north-eastSimilarity metricsA short vector
- Three hikers leaving a hotelBen walks 10 km north-eastSimilarity metricsA long vector, same direction
- Three hikers leaving a hotelComparing compass bearings: Ana = BenSimilarity metricsCosine similarity
- Three hikers leaving a hotelAna is far from Ben on the groundSimilarity metricsEuclidean distance
- Three hikers leaving a hotelBen "agrees" most because he went farSimilarity metricsDot product favours long vectors
- Three hikers leaving a hotelEveryone walks exactly 1 kmSimilarity metricsNormalised vectors: all three agree
Where the analogy breaks: hikers move in two dimensions. Embeddings have hundreds, where nearly everything feels far apart, so scores bunch together in a narrow band.
Check your understanding
Practice ladder
Three metrics by hand
Estimation Warm-upQuery q = (1, 0). Stored vectors:
- P = (2, 0)
- Q = (0.8, 0.6)
- R = (0.5, 0)
Compute the dot product, cosine similarity and Euclidean distance between q and each one, and give the ranking (best first) for each metric. Then say what changes if P and R are normalized first.
Match the operator to the index
Code PracticeThe team wants to use the dot product for speed (vectors are normalized). Complete the migration and the query so the new index is actually used, and the score shown to the UI is a similarity where higher is better.
-- 1. Build an index for inner product
CREATE INDEX reviews_emb_ip ON reviews USING hnsw (embedding ___);
-- 2. Query: top 10, with a similarity score (higher = better)
SELECT id, ___ AS score
FROM reviews
WHERE hotel_id = $2
ORDER BY ___
LIMIT 10;Calibrate the concierge's "no answer" threshold
Estimation PracticeThe concierge searches hotel policies and should say "I'm not sure, let me check" when nothing relevant is found. On 400 labelled question–passage pairs, the best passage's cosine score was:
| score band | relevant | not relevant |
|---|---|---|
| ≥ 0.85 | 150 | 5 |
| 0.80–0.85 | 60 | 25 |
| 0.75–0.80 | 20 | 70 |
| < 0.75 | 5 | 65 |
Pick a threshold for "answer vs say I'm not sure", explain the trade-off, and estimate precision and recall at your choice.
A semantic cache threshold
Decision StretchThe concierge's semantic cache (generic FAQs per hotel) returns a stored answer when a new question's cosine with a cached question is above a threshold. On 600 labelled pairs ("same question?"):
- At 0.92: 99% precision, 40% of repeat questions served from cache.
- At 0.88: 95% precision, 65% served.
- "Is breakfast included?" vs "Is breakfast included for kids?" scores 0.90.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Explain to a teammate why 'we use dot product because it's fastest, and 0.8 means similar' could be wrong, and what you'd do instead.