Skip to content
beginner Phase 7 · Embeddings and Vector Search

Similarity Metrics for Vectors

Compare cosine similarity, dot product, and Euclidean distance and when each applies.

45m
0 problems
Topic Progress 0%

What 'Close' Has to Mean

Comparing two vectors is only meaningful once you decide what close means, and the three metrics in common use encode three different answers.

Cosine similarity measures the angle between the vectors and ignores their length:

cosine = a @ b / (np.linalg.norm(a) * np.linalg.norm(b))

Dot product measures the angle scaled by both lengths — it is what you compute when you skip the division:

dot = a @ b

Euclidean distance measures the straight-line gap between the endpoints:

euclid = np.linalg.norm(a - b)
Metric Reads Sensitive to magnitude Range
cosine angle between the vectors no -1 to 1
dot product angle times both lengths yes, quadratically unbounded
Euclidean straight-line distance yes, linearly 0 upward

The choice is a statement about which information you keep. Cosine throws magnitude away; dot product keeps it; Euclidean keeps it and penalizes it. The next chapter shows the three disagreeing on one pair of vectors.

One Pair, Opposite Rankings

Two candidate documents and one query vector. Which document is closer depends entirely on what "closer" means:

import numpy as np

query = np.array([1.0, 0.0])    # unit vector on the x axis
doc_a = np.array([3.0, 0.4])    # long, almost the same direction
doc_b = np.array([0.6, 0.6])    # short, clearly off-axis

def cosine(a, b):
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))

def dot(a, b):
    return a @ b

def euclid(a, b):
    return np.linalg.norm(a - b)

for name, v in [('doc_a', doc_a), ('doc_b', doc_b)]:
    print(name, f'cos={cosine(query, v):.3f}',
          f'dot={dot(query, v):.3f}', f'l2={euclid(query, v):.3f}')

Output:

doc_a cos=0.991 dot=3.000 l2=2.040
doc_b cos=0.707 dot=0.600 l2=0.721

Cosine prefers doc_a: it points almost the same way as the query. Euclidean prefers doc_b: it sits physically nearer. Same two vectors, opposite rankings — because doc_a is three units long, and Euclidean distance charges for length while cosine refuses to see it.

The hand check for the first row: the numerator is 1*3 + 0*0.4 = 3.0, the denominator is 1 * 3.027 (the norm of doc_a), and the quotient is 0.991.

Scale every document by 10 and cosine rankings are untouched, dot product rankings slide toward the longest vectors, and Euclidean distances inflate for them. Vectors from a real embedding model come out similar in length but not identical, so a metric picked by habit is a metric picked blind.

Normalize Once, Then Any Metric Agrees

Scale each vector to unit length — Euclidean norm of 1 — and the denominator in cosine vanishes: the dot product is the cosine from then on. Indexes that support only inner product can therefore serve cosine-style queries without recomputing anything at query time (ANN Index Types).

def unit(v):
    return v / np.linalg.norm(v)

q, a, b = unit(query), unit(doc_a), unit(doc_b)
print(f'{q @ a:.6f} vs {cosine(query, doc_a):.6f}')
print(f'{q @ b:.6f} vs {cosine(query, doc_b):.6f}')

Output:

0.991228 vs 0.991228
0.707107 vs 0.707107

Three consequences fall out of those two lines:

  • Normalize at write time. The division moves to ingestion, paid once per document, instead of on every probe.
  • Scaling input before normalization is a no-op for cosine. Unit-normalizing doc_a * 10 gives the same direction as unit-normalizing doc_a, so cosine cannot distinguish a popular document from an obscure one if length was carrying that signal.
  • Dot product without normalization keeps the signal. If magnitude encodes something real — term weight, popularity, recency folded into the vector — inner product over raw vectors preserves it and cosine destroys it. The metric is a statement about which information you are willing to discard.

Dimension changes the intuition behind the numbers. In 1,536 dimensions, unrelated text does not land near cosine 0: anisotropy in trained models pushes the whole distribution up, so random document pairs commonly score 0.1 to 0.35, and near-duplicates sit around 0.9 plus. The 2-D picture of angles is a mnemonic, not a calibration — the score band belongs to the model that produced it, which is what the next chapter has to handle.

A Threshold Belongs to Your Dataset

cosine = 0.92 reads like a probability and is not one. It reports a small angle in one model's space. It says nothing about the chance a document is relevant, and it is not comparable to a 0.92 from another model or from the same collection before a rebuild.

Cutoff What the feature does
too low every query returns matches; the LLM assembles answers from noise
too high the right document never enters top-k; the feature answers 'not found' on questions it can answer
copied from another project both failures at once, discovered after launch

Failure modes worth naming before a cutoff ships:

  • Reading 0.9 as '90% likely related'. Similarity is geometry; relevance is a judgment about text.
  • Reusing a value across models. A retrieval-tuned model clusters unrelated text higher than a generic one, so 0.85 means different things in each space.
  • Tuning on the queries that built the index. Those queries already share vocabulary with the corpus, and the cutoff inherits that optimism.
  • Freezing the value forever. New chunking, a new model version, a corpus that grew — all three move the score distribution while the number stays put.
  • Charting scores across rebuilds. Two index versions, two scales, one trend line that measures nothing.

The defensible version: derive the cutoff from labeled pairs every time the model, chunking, or corpus changes, and keep the derivation — queries, labels, precision and recall at each candidate value — beside the number. That machinery is Evaluating Embedding Quality, and it is also how you compare two candidate models before moving a single vector. Meanwhile the corpus itself has to get embedded somewhere, which is Batching Embedding Requests.

Quiz

1. With query = [1, 0], doc_a = [3, 0.4], doc_b = [0.6, 0.6], which pair of rankings is correct?

Question 1 options

2. Why does the dot product rank this pair differently from cosine?

Question 2 options

3. Every stored vector is scaled to unit length before indexing. What is now true?

Question 3 options

4. A feature gates on cosine > 0.85. You switch embedding models, scores now cluster around 0.4, and every request misses. What is the diagnosis?

Question 4 options

Flashcards

Question

What does cosine similarity measure, and what does it ignore?

Answer

The angle between two vectors, on a scale of -1 to 1. Vector length is divided out entirely, so magnitude carries no weight in the score.

Question

When is dot product preferable to cosine similarity?

Answer

When magnitude encodes signal — popularity, term weight, recency folded into the vector. Dot product keeps that signal; cosine normalizes it away.

Question

What happens to cosine and dot product once all vectors are unit length?

Answer

They coincide: the cosine denominator becomes 1, so the dot product equals cosine and both rank documents identically.

Question

Why is a similarity score not a probability?

Answer

It reports geometry — angle or distance — in one model's space. It carries no statement about relevance, and it is not comparable across models or across index rebuilds.

Question

How should a similarity cutoff be chosen?

Answer

From labeled query-document pairs: compute precision and recall at candidate values, pick the operating point, and redo it whenever the model, chunking, or corpus changes.

Revision Notes

Key Takeaways

  • 1. Cosine reads angle, dot product reads angle times length, Euclidean reads distance — they can rank the same pair in opposite orders
  • 2. Unit normalization collapses cosine into dot product, which is why inner-product indexes serve cosine queries
  • 3. Choosing a metric is choosing which information to discard: magnitude is signal in some features and noise in others
  • 4. Cutoffs are calibrated values tied to one model and one dataset, never universal constants

Interview Tips

  • • Derive cosine from scratch on a 2-D pair — numerator, norms, quotient — then state its range and its blindness to magnitude
  • • Given 'our scores dropped after a model swap', answer with distribution shift, not model degradation: the geometry changed, so the cutoff must be re-derived
  • • Explain why normalizing vectors makes cosine and dot product identical, and name the index types that depend on this
  • • Say what you would lose by normalizing: any signal carried in vector length, such as popularity or term frequency

Cheat Sheet

Similarity Metrics — Cheat Sheet

Formulas (numpy)

cosine = a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
dot    = a @ b
euclid = np.linalg.norm(a - b)
unit   = a / np.linalg.norm(a)      # then: cosine == dot

Which metric

Situation Metric
text similarity, lengths roughly equal cosine
magnitude carries signal (popularity, weights) raw dot product
spatial clustering, distance thresholds Euclidean
index supports inner product only normalize at write time, query with dot

Rules

  • Normalize once, at ingestion — never normalize one side of a comparison only.
  • In 1,536 dimensions unrelated pairs score roughly 0.1–0.35, not 0; near-duplicates sit above 0.9.
  • Scores are model-specific geometry, not probabilities; retune cutoffs per model and per corpus.
  • Ranking metrics are safe to compare across rebuilds only if the model string is unchanged.

Failure modes: cosine on one side only · cutoff copied across models · tuning on index-building queries · mixing raw BM25 and cosine scores without normalizing (see Hybrid Search Basics).

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading