Skip to content
Sachini Dilrangi.
← The Log

Cosine Similarity, Explained With Your Hands

·5 min read·#embeddings #semantic-search #machine-learning
Glowing amber light rays fanning out from a single bright point of origin at different angles against a black background

Here's a reasonable-looking piece of code that measures "how similar are these two vectors":

import numpy as np

def similarity(a, b):
    return np.linalg.norm(a - b)  # smaller = more similar, right?

v1 = np.array([0.9, 0.9])
v2 = np.array([0.1, 0.1])
v3 = np.array([9.0, 9.0])

print(similarity(v1, v2))  # 1.13
print(similarity(v1, v3))  # 11.45

By this measure, v1 is roughly ten times "more similar" to v2 than it is to v3. But look at the actual numbers again. v1 is [0.9, 0.9]. v3 is [9.0, 9.0] — the exact same ratio between its two numbers, just scaled up by 10x. If these vectors represented, say, "how much this sentence is about weather" and "how much it's about sports," v1 and v3 would be describing the same balance of topics — one just more intensely than the other. v2, meanwhile, has a totally different balance baked in... except here it scores as the closer match, purely by coincidence of scale.

This is the exact trap that catches people the first time they try to compare embedding vectors using ordinary distance. The fix isn't a better distance formula — it's asking a different question entirely.

The real question

How do you compare two vectors by what direction they point, without magnitude (scale, length, intensity) getting in the way?

Stop thinking in distance. Start thinking in angle.

Picture both vectors as arrows, starting from the same origin point.

                    ↑ v3 (9.0, 9.0)
                   ↗
                  ↗
                 ↗  ← same direction as v1
                ↗
               • v1 (0.9, 0.9)
              ↗
             ↗
            ↗
           →————————————————→
                                  v2 sits off in a
                                  totally different
                                  direction (not shown
                                  to scale here)

v1 and v3 point along the exact same line from the origin — same direction, just different lengths. That's the relationship "how much distance apart are they" completely fails to capture, because distance only cares about where the tips of the arrows land, not which way they're pointing.

Cosine similarity asks a cleaner question: forget how long each arrow is — what's the angle between them?

  • Same direction (angle = 0°) → cosine similarity = 1
  • Perpendicular, unrelated directions (angle = 90°) → cosine similarity = 0
  • Opposite directions (angle = 180°) → cosine similarity = -1

Getting there with the dot product

The tool that measures angle is called the dot product — multiply matching positions together, then add up the results.

def dot_product(a, b):
    return sum(x * y for x, y in zip(a, b))

dot_product([0.9, 0.9], [9.0, 9.0])
# (0.9 * 9.0) + (0.9 * 9.0) = 8.1 + 8.1 = 16.2

On its own, the dot product still has the same magnitude problem as plain distance — a longer vector produces a bigger dot product even if the direction is identical. The fix is to divide it away:

import numpy as np

def cosine_similarity(a, b):
    dot = np.dot(a, b)
    magnitude_a = np.linalg.norm(a)
    magnitude_b = np.linalg.norm(b)
    return dot / (magnitude_a * magnitude_b)

v1 = np.array([0.9, 0.9])
v2 = np.array([0.1, 0.1])
v3 = np.array([9.0, 9.0])

print(cosine_similarity(v1, v2))  # 1.0
print(cosine_similarity(v1, v3))  # 1.0

Both come back as a perfect 1.0 — because v1, v2, and v3 all point in exactly the same direction. Dividing by both magnitudes cancels out the scale difference entirely, leaving pure direction. This is the calculation that fixes the misleading result from the very first code snippet.

The shortcut worth knowing

If every vector has already been scaled down to length exactly 1 — called normalizing — the magnitude part of the formula becomes 1 * 1 = 1, which means dividing by it does nothing. Cosine similarity then collapses into a plain dot product:

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

def cosine_similarity_normalized(a, b):
    return np.dot(normalize(a), normalize(b))

That's exactly why embedding models are usually set up to output vectors that are already normalized — it turns "compare these by meaning" into a single, cheap multiply-and-add, instead of redoing the normalization work on every single comparison.

Distance vs. angle, side by side

Euclidean distanceCosine similarity
MeasuresHow far apart the tips of the vectors landThe angle between the vectors
Sensitive to magnitudeYes — scaling a vector changes the resultNo — scaling a vector doesn't change the angle
Typical range0 to unbounded-1 to 1 (usually 0 to ~1 in practice)
Good fit forComparing raw physical quantities (coordinates, pixel values)Comparing meaning/direction (text embeddings, most similarity search)

The actual rule

When you want to know if two things point at the same idea, measure the angle between them, not the gap between their tips — scale should never be allowed to masquerade as similarity.

Why this matters beyond one formula

This isn't really a story about one specific equation — it's about noticing when a tool is answering a related but different question than the one you're actually asking. Euclidean distance genuinely does measure something real; it's just not "how similar in meaning are these," even though the two can look interchangeable in a quick code snippet. That gap between "technically works" and "measures the right thing" shows up constantly in engineering, well outside of vectors and embeddings — and the fix is almost always the same move made here: stop and ask exactly what property you actually care about, then check whether your formula, metric, or test is truly measuring that, or just something correlated with it.