Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°05

Cosine Similarity

Easy AI EngineeringEmbeddingsMath

The measure behind every embedding search and RAG system: how aligned are two vectors, regardless of their length? It is the dot product divided by both magnitudes — 1 means identical direction, 0 orthogonal, -1 opposite.

The problem

Given two equal-length vectors a and b, return their cosine similarity: dot(a, b) / (‖a‖ · ‖b‖), where ‖v‖ is the Euclidean norm (square root of the sum of squares). The result lies in [-1, 1] and ignores vector length — only direction matters.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement cosine_similarity(a, b) = dot product of a and b, divided by the product of their Euclidean norms.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input a = [1, 2, 3], b = [1, 2, 3]
Output 1.0
same direction
EXAMPLE 2
Input a = [1, 0], b = [0, 1]
Output 0.0
orthogonal
EXAMPLE 3
Input a = [1, 0], b = [-1, 0]
Output -1.0
opposite
EXAMPLE 4
Input a = [1, 1], b = [2, 2]
Output 0.9999999999999998
edge case: length does not matter, so this is 1 up to floating-point rounding (sqrt(2) * sqrt(8) rounds to 4.000000000000001)
CONSTRAINTS
  • len(a) == len(b) ≥ 1, and neither vector is all zeros.
  • Result in [-1, 1], within floating-point tolerance.
  • Cosine ignores magnitude — scaling a vector does not change the answer.
Hints 4 ideas
HINTS — 4 IDEAS
  1. The dot product is the sum of element-wise products: Σ aᵢ·bᵢ.
  2. The Euclidean norm of v is sqrt(Σ vᵢ²).
  3. Divide the dot product by ‖a‖ · ‖b‖ to cancel out length and leave only direction.
  4. Identical vectors give 1; orthogonal give 0; opposite give -1.
Approach, complexity & discussion — open after you solve

Start from brute force

The tempting shortcut is to score similarity with the raw dot product, sum(x * y for x, y in zip(a, b)). It is one pass, O(d) time and O(1) space, and it does reward alignment, but it also grows with length: [2, 2]·[2, 2] = 8 outscores [1, 0]·[1, 0] = 1, although both pairs point in exactly the same direction. Euclidean distance fails the other way: [1, 1] and [2, 2] are about 1.41 apart even though their directions are identical. The question is about direction only, so the score has to divide out both lengths.

The approach

Cosine is the dot product of the two unit vectors, so divide out both lengths. The reference cosine_similarity computes three sums: dot = sum(x * y for x, y in zip(a, b)), then norm_a and norm_b as math.sqrt of each vector’s sum of squares, and returns dot / (norm_a * norm_b). A scale factor on either vector multiplies the numerator and the denominator alike, so it cancels, which is the length-independence the problem asks for. The Cauchy–Schwarz inequality, |a·b| ≤ ‖a‖‖b‖, is why the answer lands in [-1, 1].

The TypeScript cosineSimilarity fuses the three sums into one loop with running totals dot, na and nb, and takes both square roots only at the end. Either way the work is three multiply-adds per dimension. The constraints rule out an all-zero vector; without that promise you need an explicit guard, because the norm product is 0 and the division fails.

a = [3,1]b = [1,2]2b = [2,4]θ=45°xydot = 3·1 + 1·2 = 5norm_a = √10 ≈ 3.162norm_b = √5 ≈ 2.2365 / √50 = 0.7071a vs 2b: 10 / √200 = 0.7071
Cosine reads only the angle. Doubling b to 2b (dashed) doubles the dot product and one norm together, so the reference returns 0.7071067811865475 for both pairs, and acos of it is 45°. A raw dot product would score 2b twice as similar. Turning b toward a raises the score toward 1; turning it to [-1, 3], perpendicular to a, makes the dot product −3 + 3 = 0 and the score exactly 0, whatever the two lengths are.

Walk through an example

For the first example, a = b = [1, 2, 3], the running sums grow together and the division returns exactly 1.0.

stepworkdotsum of a²sum of b²result
1index 0: x = 1, y = 1111—
2index 1: x = 2, y = 2555—
3index 2: x = 3, y = 3141414—
4norm_a = norm_b = math.sqrt(14) = 3.7416573867739413141414—
5dot / (norm_a * norm_b), where the product rounds to exactly 14.01414141.0

Complexity

Time O(d) for d dimensions: three multiply-adds per index, whether done as three passes (Python) or one loop (TypeScript). Space O(1): three running sums. The raw dot product is also O(d) time and O(1) space, so normalizing costs only two extra sums and two square roots. Comparing one query against N stored vectors is O(N·d) either way.

Edge cases to test

  • a = [1, 1], b = [2, 2] → 0.9999999999999998, not exactly 1.0, because sqrt(2) * sqrt(8) rounds to 4.000000000000001. Compare with a tolerance, as the tests do, never with ==.
  • a = b = [1, 5] → 1.0000000000000002: rounding can also land just above 1, so clamp to [-1, 1] before passing a score to acos.
  • a = [0, 0], b = [1, 1]: the norm product is 0, so Python raises ZeroDivisionError and TypeScript returns NaN. The constraints exclude it; production code should return 0 or raise a clear error.
  • a = [1, 2, 3], b = [1, 2] → Python returns 0.5976…, because zip stops at the shorter list while norm_a still uses all of a; TypeScript returns NaN. Check the lengths first, or use zip(a, b, strict=True) in Python 3.10+.
  • a = b = [1e200, 1e200] → nan: every square overflows to infinity, and infinity over infinity is NaN. Dividing each vector by its largest absolute entry first gives the same cosine without overflow.
  • a = b = [1e-200, 1e-200] → ZeroDivisionError: the squares underflow to 0.0, so a nonzero vector looks like a zero vector. The same rescaling fixes it.

Common mistakes

  • Returning the raw dot product without dividing by the magnitudes — then length dominates and it is no longer cosine.
  • Dividing by zero on a zero vector — guard it (define the similarity as 0 or handle explicitly).
  • Assuming the inputs are the same length without checking.

Follow-ups an interviewer asks next

You compare one query against a million stored embeddings.
Normalize every stored vector to unit length once, at write time, and the query once per search. Cosine then reduces to a plain dot product, so no comparison pays for norms. A full scan is still O(N·d), so at scale an approximate nearest-neighbor index such as HNSW or IVF answers top-k queries without touching every vector, trading a little recall for speed.
Make it numerically robust.
Compare with a tolerance, and clamp the result to [-1, 1] before any acos, since rounding can land just outside. To avoid overflow and underflow, scale each vector by its largest absolute entry before squaring; cosine is unchanged by scaling. In Python, math.fsum sums many terms more accurately, and math.hypot(*v) computes a norm without intermediate overflow.
When should you use the dot product or Euclidean distance instead?
On unit-length vectors all three give the same ranking: the dot product equals cosine, and squared Euclidean distance equals 2 − 2·cos. They diverge when length carries meaning, for example in a model trained so that magnitude encodes something. Use the metric the embedding model was trained with; its documentation usually names it.
The vectors are sparse, like TF-IDF over a large vocabulary.
Store each one as a map from index to nonzero value, iterate over the smaller map, and look each key up in the other. The dot product then costs O(min(nnz(a), nnz(b))) instead of O(d), and each document’s norm can be computed once and stored. Most entries are zero, so skipping them is where nearly all of the saving comes from.
Score one query against a whole matrix at once.
Stack the stored vectors as the rows of a matrix and divide each row by its norm once. With NumPy, scores = M @ (q / np.linalg.norm(q)) then gives every cosine in one matrix-vector product, which runs in optimized native code instead of a Python loop. To pick the top k without sorting all N scores, np.argpartition(-scores, k)[:k] finds them in O(N) and only those k need sorting. A zero row still divides by zero, so drop or flag zero vectors when you normalize.

Where this shows up

Cosine similarity is the standard score for comparing embeddings — semantic search, RAG retrieval, clustering, deduplication, recommendations. On vectors that are already unit-normalized (many embedding models return these) it reduces to a plain dot product, which is exactly why vector databases can rank by dot product and get cosine ordering for free.

Before embeddings. The classic vector space model of information retrieval represented each document and query as a TF-IDF vector over the vocabulary and ranked documents by cosine. Dividing out length was the point there too: without it, a long document would beat a short, focused one simply by repeating more words. Item-based recommenders use the same score between columns of a user-item rating matrix, often in an “adjusted” form that first subtracts each user’s average rating, so that a generous rater and a harsh one who rank items the same way still look alike.

Scores are not probabilities. A cosine of 0.8 is not “80% similar”, and the same number means different things for different embedding models, because each model spreads its vectors over the sphere differently. A retrieval threshold, such as “only pass chunks above 0.75 to the model”, has to be chosen per model from labeled pairs of relevant and irrelevant results, and chosen again when the model changes.

Similarity versus distance. Libraries often report cosine distance, 1 − cos, so a smaller number is better; reading it as a similarity flips every ranking. It is also not a true metric, because it can violate the triangle inequality. When an algorithm needs a real metric, use the angle itself, acos(cos), or, on unit vectors, Euclidean distance, which equals sqrt(2 − 2·cos) and so ranks identically.

Check yourself

Predict cosine_similarity([1, 2, 3], [3, 2, 1]) as a fraction.

5/7 ≈ 0.7142857142857143. The dot product is 3 + 4 + 3 = 10, and both vectors have squared norm 14, so the denominator is exactly 14. Reversing a vector keeps its length but changes its direction.

Query q = [3, 1]; candidates x = [1, 0] and y = [2, 4]. Which ranks first by dot product, and which by cosine?

Dot product prefers y (10 against 3); cosine prefers x (0.949 against 0.707). y wins the dot product mainly by being long, about 4.47 against 1. On unit-normalized vectors the two rankings would agree.

Add 10 to every entry of [1, 0] and [0, 1]. Does the cosine change?

Yes, from 0.0 to about 0.9955 for [11, 10] and [10, 11]. Cosine ignores scaling but not shifting: a shared offset pulls both vectors toward the same direction. Subtracting each vector’s mean first is what makes it Pearson correlation.

Every entry of every vector is non-negative, as with word counts or TF-IDF. What range can the cosine take?

[0, 1]. Each product x * y is at least 0, so the dot product cannot be negative; it is 0 only when the two vectors have no nonzero index in common, such as documents that share no word. There, 0 means “nothing in common”, never “opposite”.

Normalize [3, 4] and [4, 3] to unit length. What are the unit vectors, and what is their dot product?

[0.6, 0.8] and [0.8, 0.6], since both vectors have length 5. Their dot product is 0.6 × 0.8 + 0.8 × 0.6 = 0.96, which is exactly cosine_similarity([3, 4], [4, 3]) = 24 / 25. Normalizing first turns cosine into a plain dot product.

Reference solution

Reference solution — Python
import math

def cosine_similarity(a, b):
    dot = sum(x * y for x, y in zip(a, b))      # alignment
    norm_a = math.sqrt(sum(x * x for x in a))   # length of a
    norm_b = math.sqrt(sum(y * y for y in b))   # length of b
    return dot / (norm_a * norm_b)


print(cosine_similarity([1, 2, 3], [1, 2, 3]))  # -> 1.0
print(cosine_similarity([1, 0], [0, 1]))        # -> 0.0
Reference solution — TypeScript
function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];   // alignment
    na += a[i] * a[i];    // |a|^2
    nb += b[i] * b[i];    // |b|^2
  }
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

console.log(cosineSimilarity([1, 2, 3], [1, 2, 3]));  // -> 1
console.log(cosineSimilarity([1, 0], [0, 1]));        // -> 0

Questions about Cosine Similarity

What does cosine similarity measure?

It measures the angle between two vectors and ignores their lengths. The value is the cosine of that angle: 1 when the vectors point the same way, 0 when they are perpendicular, and -1 when they point in opposite directions. It is computed as the dot product divided by the product of the two Euclidean norms. Because both lengths are divided out, doubling a vector changes nothing, which is why it suits embeddings, where the direction of a vector is what carries the meaning.

What is the difference between cosine similarity and the dot product?

The dot product multiplies matching entries and sums them, so it grows with the lengths of the vectors as well as with their alignment. Cosine similarity divides the dot product by both lengths, so only alignment remains. For [1, 0] and [1, 0] the dot product is 1, while for [2, 2] and [2, 2] it is 8, yet the cosine is 1 in both cases. When every vector is normalized to length 1 the two are identical, which is why many vector databases normalize once and then rank by dot product.

Can cosine similarity be negative?

Yes. It ranges from -1 to 1, and it is negative whenever the angle between the vectors is more than 90 degrees, which is exactly when the dot product is negative. [1, 0] against [-1, 0] scores -1. Whether you see negative values depends on the data: vectors with only non-negative entries, such as word counts or TF-IDF weights, can never score below 0, while learned embeddings can. A ranking or threshold that assumes a 0 to 1 range should be checked against the model you actually use.

Why does cosine similarity return 0.9999999999999998 instead of 1?

Because floating-point arithmetic rounds. For [1, 1] and [2, 2] the exact answer is 1, but the norms are the square roots of 2 and 8, neither of which is exactly representable, and their product rounds to 4.000000000000001, so 4 divided by it gives 0.9999999999999998. Rounding can also go the other way: [1, 5] against itself gives 1.0000000000000002. Compare scores with a tolerance, such as an absolute difference below 1e-9, and clamp to the range -1 to 1 before passing a score to acos.

▶  Watch it explained
Finished this one? 0 / 76 Challenges done
NextSemantic Chunker (Token Budget) Next on the “RAG & retrieval” ladder · rung 2 of 7

This idea in other formats

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.