Cosine similarity
A score from −1 to 1 measuring the angle between two vectors, ignoring their length: the standard way to compare embeddings.
Cosine similarity measures how closely two embeddings point in the same direction. A score near 1 means very similar meaning, near 0 means unrelated, and negative means pointing in opposite directions (rare for text embeddings).
It ignores how long the vectors are and only cares about direction, which suits comparing meaning.
Two arrows on a compass: what matters is whether they point the same way, not how long each arrow is.
Treating a high cosine score as proof the chunk answers the question. It measures topical closeness; an on-topic chunk can say the opposite.
cos(a, b) = (a · b) / (‖a‖ ‖b‖). It is the Dot product of the two vectors after scaling each to unit length. For unit vectors, ‖a − b‖² = 2 − 2 cos(a, b), so cosine, dot product and Euclidean distance all produce the same ranking. Without normalisation they disagree: dot product rewards long vectors, Euclidean distance punishes vectors that point the right way but sit at a different length, and only cosine ignores length entirely. That is why vector stores normalise once at write time and then use the cheaper inner product.
Absolute values are model-specific. Anisotropy means unrelated texts score ~0.1 on some models and 0.6–0.8 on others, so any threshold (for a Semantic cache, deduplication or abstention) must be calibrated per model on labelled pairs. Some models are trained for dot product on unnormalised vectors, where magnitude carries signal; follow the model card. Steck et al. (2024) also showed cosine on learned embeddings can be arbitrary when training regularisation leaves dimensions free to rescale. Cosine measures topical closeness, not truth or answer quality.
L2-normalise every vector at write time and at query time; index with inner product (pgvector vector_ip_ops, FAISS IndexFlatIP/HNSW-IP); follow the model card if it specifies dot product on raw vectors; calibrate thresholds per model on 500–1,000 labelled pairs.
Query q = (3, 4). Doc A = (6, 8) points the same way but is twice as long: cosine 1.00, dot 50, Euclidean 5.0. Doc B = (4, 3): cosine 0.96, dot 24, Euclidean 1.41. Doc C = (0.6, 0.8): cosine 1.00, dot 5, Euclidean 4.0. Doc D = (10, 0): cosine 0.60, dot 30, Euclidean 8.06. Cosine ranks A = C > B > D; dot product ranks A > D > B > C; Euclidean ranks B > C > A > D. Normalise all four to unit length and every metric agrees: A = C > B > D.
Commit: normalise at write, use inner product, and calibrate every threshold per model on labelled pairs. Show you know when the three metrics disagree (unnormalised vectors) and when they don't (unit vectors). Follow-up: 'set a dedup threshold for catalog listings': label 1,000 pairs, pick the cosine giving 99% precision, and re-derive it on any model change.
| Doc vector (q = 3,4) | Angle | Cosine | Dot | Euclidean | Rank cos / dot / L2 |
|---|---|---|---|---|---|
| A (6, 8) | 0° | 1.00 | 50 | 5.00 | 1= / 1 / 3 |
| B (4, 3) | 16.3° | 0.96 | 24 | 1.41 | 3 / 3 / 1 |
| C (0.6, 0.8) | 0° | 1.00 | 5 | 4.00 | 1= / 4 / 2 |
| D (10, 0) | 53.1° | 0.60 | 30 | 8.06 | 4 / 2 / 4 |
| Doc after L2-normalising | Cosine | Dot | Euclidean | Rank (all three) |
|---|---|---|---|---|
| A (0.6, 0.8) | 1.00 | 1.00 | 0.00 | 1= |
| B (0.8, 0.6) | 0.96 | 0.96 | 0.28 | 3 |
| C (0.6, 0.8) | 1.00 | 1.00 | 0.00 | 1= |
| D (1.0, 0.0) | 0.60 | 0.60 | 0.89 | 4 |
Numbers worth knowing
- Unit-vector identity
- ‖a − b‖² = 2 − 2cos(a, b)holds only when both vectors are L2-normalised
- Random-pair cosine
- ≈0 ± 1/√drandom directions in d dims; ±0.03 at d = 1,024
- Unrelated-text cosine
- ≈0.1 to 0.8model-dependent because of anisotropy; measure before thresholding
How to decide
| When | Do this |
|---|---|
| Model card says 'normalise' or doesn't say | Normalise and use inner product; cosine, dot and L2 then agree. |
| Model is trained for dot product on raw vectors | Don't normalise; magnitude carries signal for that model. |
| You need a yes/no threshold (cache hit, duplicate, abstain) | Calibrate on labelled pairs per model and per language; never reuse another model's number. |
Cost maths
Per comparison: d multiply-adds for a dot product, plus two norms and a division for raw cosine. Normalising once at write removes the norms: at 10M vectors × 1,024 dims, brute-force cosine without pre-normalisation does ~3× the arithmetic of inner product on normalised vectors.
Operating it
- Any threshold-based feature (cache, dedup, abstention) after an embedding-model change
- Mixed normalised and unnormalised vectors in one index
- Vector norm distribution at ingest: alert if any vector's norm deviates from 1 by more than 0.01 (normalised indexes)
- Precision of threshold decisions on a weekly 100-pair labelled sample: alert below 0.97
When it goes wrong
A catalog dedup job merged 12% of listings that were different products; merchants complained their items had vanished.
Merchant support tickets, then a 500-pair manual audit of merged pairs.
The job compared raw dot products on unnormalised vectors from a model that does not normalise its output; long, keyword-stuffed listings had large norms and scored high against everything.
L2-normalisation at write, threshold recalibrated on 1,000 labelled pairs (precision 0.99 at cosine 0.94), and merges above 0.90 but below 0.94 sent to human review.
Where it shows up
Sources
- Is Cosine-Similarity of Embeddings Really About Similarity? — Steck et al., 2024paper
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks — Reimers & Gurevych, 2019paper
Check yourself
Three questions. Every option is explained once you answer.