n4nAI

Vector distance metrics compared: cosine, dot product, L2

Compare cosine similarity, dot product, and L2 distance for vector search — when each metric works, fails, and how to choose for your retrieval system.

n4n Team6 min read1,366 words

Audio narration

Coming soon — every post will get a voice note here.

Vector distance metrics comparison comes down to one question: what does “similar” mean for your embeddings? Cosine similarity measures angle, dot product measures projection magnitude, and L2 distance measures absolute displacement. Each metric induces a different geometry on your vector space, and picking the wrong one silently degrades retrieval quality. This post breaks down the mathematical differences, practical implications, and decision criteria so you can choose confidently.

The three metrics defined

Given two vectors a and b in ℝⁿ:

Cosine similarity computes the cosine of the angle between them:

cos(θ) = (a · b) / (||a|| ||b||)

Range: [-1, 1]. Higher = more similar. Ignores magnitude entirely.

Dot product (inner product) computes the sum of element-wise products:

a · b = Σ aᵢ bᵢ

Range: (-∞, ∞). Higher = more similar. Sensitive to both angle and magnitude.

L2 distance (Euclidean distance) computes the straight-line distance:

||a - b||₂ = √(Σ (aᵢ - bᵢ)²)

Range: [0, ∞). Lower = more similar. Sensitive to both angle and magnitude.

Normalization changes everything

The relationship between these metrics hinges on whether your vectors are normalized to unit length.

If all vectors are L2-normalized (||v|| = 1), the three metrics become monotonically related:

  • cos(θ) = a · b (since ||a|| = ||b|| = 1)
  • ||a - b||² = 2 - 2(a · b) = 2(1 - cos(θ))

In this regime, ranking by any metric produces identical orderings. The choice reduces to implementation convenience and index compatibility.

If vectors are not normalized, the metrics diverge sharply. Dot product and L2 distance both conflate direction and magnitude. A long vector pointing in a moderately relevant direction can outrank a short vector pointing in the exactly correct direction. Cosine similarity ignores magnitude entirely, which can be desirable or disastrous depending on your embedding model.

import numpy as np

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

def dot_product(a, b):
    return np.dot(a, b)

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

# Unnormalized example
a = np.array([10.0, 0.0])      # long vector, direction X
b = np.array([0.0, 10.0])      # long vector, direction Y
c = np.array([0.1, 0.1])       # short vector, direction X+Y

print(f"cosine(a, c): {cosine_similarity(a, c):.3f}")  # 0.707
print(f"cosine(b, c): {cosine_similarity(b, c):.3f}")  # 0.707
print(f"dot(a, c):    {dot_product(a, c):.3f}")        # 1.0
print(f"dot(b, c):    {dot_product(b, c):.3f}")        # 1.0
print(f"l2(a, c):     {l2_distance(a, c):.3f}")        # 9.95
print(f"l2(b, c):     {l2_distance(b, c):.3f}")        # 9.95

# Normalized changes everything
a_n = a / np.linalg.norm(a)
b_n = b / np.linalg.norm(b)
c_n = c / np.linalg.norm(c)
print(f"\nAfter normalization:")
print(f"cosine(a_n, c_n): {cosine_similarity(a_n, c_n):.3f}")  # 0.707
print(f"dot(a_n, c_n):    {dot_product(a_n, c_n):.3f}")        # 0.707
print(f"l2(a_n, c_n):     {l2_distance(a_n, c_n):.3f}")        # 0.765

Embedding model behavior dictates the metric

Different embedding models produce vectors with different norm distributions. This is the single most important factor in metric selection.

Models that output normalized vectors (OpenAI text-embedding-3-*, Cohere embed-v3, Voyage AI): These models explicitly L2-normalize outputs. Cosine, dot product, and L2 all yield identical rankings. Use whichever your vector database optimizes for — typically dot product (via inner product index) or cosine (via normalized vectors + inner product).

Models with variable norms (older BERT-based models, some open-source models, custom fine-tunes): Vector magnitude often correlates with semantic “confidence” or information content. A longer vector may represent a more specific, detailed concept. In this case:

  • Cosine similarity discards that signal
  • Dot product amplifies it (magnitude × alignment)
  • L2 distance penalizes magnitude differences

Models where magnitude is noise (some contrastive learning outputs, poorly calibrated models): Magnitude varies randomly. Cosine similarity is more robust.

# Quick diagnostic: check norm distribution of your embeddings
import numpy as np
norms = np.linalg.norm(embeddings, axis=1)
print(f"Norm stats: mean={norms.mean():.3f}, std={norms.std():.3f}, "
      f"min={norms.min():.3f}, max={norms.max():.3f}")
print(f"Coefficient of variation: {norms.std() / norms.mean():.3f}")
# CV < 0.05 → effectively normalized, metric choice doesn't matter for ranking
# CV > 0.2  → magnitude carries signal or noise, choose deliberately

Index compatibility and performance

Vector databases implement approximate nearest neighbor (ANN) indexes optimized for specific distance functions. Your metric choice constrains index selection.

Metric Common index types Notes
Cosine HNSW (cosine), IVF (cosine), DiskANN (cosine) Requires normalized vectors for correctness
Dot product HNSW (ip), IVF (ip), ScaNN, DiskANN (ip) Works on raw vectors; equivalent to cosine if normalized
L2 HNSW (l2), IVF (l2), DiskANN (l2) Native support in most engines

HNSW (Hierarchical Navigable Small World) is the dominant index for high-recall, low-latency search. Most implementations (FAISS, hnswlib, Milvus, Weaviate, Qdrant, Pinecone) support all three distance functions. However, HNSW with L2 distance on unnormalized vectors can produce counterintuitive results because the index navigates by Euclidean proximity, not angular proximity.

ScaNN (Scalable Nearest Neighbors) from Google is specifically optimized for maximum inner product search (MIPS) — dot product. It uses anisotropic quantization to handle the magnitude component efficiently. If you need dot product on unnormalized vectors at scale, ScaNN is worth evaluating.

IVF (Inverted File) and PQ (Product Quantization) indexes in FAISS support all three metrics but require training. The training data distribution should match your query distribution for optimal quantization.

# FAISS index creation examples
import faiss

d = 1536  # dimension

# Cosine similarity: normalize + inner product index
index_cosine = faiss.IndexFlatIP(d)  # or IndexHNSWFlat(d, 32) + index.hnsw.efSearch = 64
# Must normalize vectors before add/search
faiss.normalize_L2(vectors)
index_cosine.add(vectors)

# Dot product: inner product index, no normalization required
index_dot = faiss.IndexFlatIP(d)
index_dot.add(vectors)  # raw vectors

# L2 distance: L2 index
index_l2 = faiss.IndexFlatL2(d)
index_l2.add(vectors)  # raw vectors

Quantization interactions

Product quantization (PQ) and scalar quantization (SQ) compress vectors for memory efficiency. The metric affects quantization error distribution.

Cosine/dot product on normalized vectors: Quantization error is bounded. Angular distortion from PQ is well-studied; 8-bit PQ typically preserves >95% recall@10 for 768-dim vectors.

Dot product on unnormalized vectors: Quantization must preserve magnitude information. Standard PQ allocates bits uniformly across dimensions, which can poorly represent magnitude if it concentrates in few dimensions. ScaNN’s anisotropic quantization addresses this.

L2 distance: Quantization error adds directly to distance. For unnormalized vectors, magnitude differences dominate, so quantization of high-magnitude dimensions matters more.

If you’re quantizing to 4-bit or 8-bit (common for cost reduction at scale), test recall at your target quantization level with your actual metric. Don’t assume cosine results transfer to dot product.

Numerical stability and implementation details

Cosine similarity requires division by norms. Zero vectors produce NaN. Guard against this:

def safe_cosine(a, b, eps=1e-8):
    norm_a = np.linalg.norm(a)
    norm_b = np.linalg.norm(b)
    if norm_a < eps or norm_b < eps:
        return 0.0
    return np.dot(a, b) / (norm_a * norm_b)

Dot product can overflow float32 for high-dimensional, high-magnitude vectors. Use float64 accumulation or normalize first.

L2 distance squared (||a-b||²) avoids the sqrt for ranking. Expand: ||a||² + ||b||² - 2(a·b). Precompute ||v||² for all vectors to accelerate:

# Precompute squared norms for fast L2 distance
norms_sq = np.sum(vectors ** 2, axis=1)  # shape (N,)
# Distance to query q: norms_sq + ||q||² - 2 * (vectors @ q)
distances = norms_sq + np.dot(q, q) - 2 * np.dot(vectors, q)

This is how FAISS’s IndexFlatL2 works internally — it’s a dot product index with precomputed norms.

Comparison table

Dimension Cosine similarity Dot product L2 distance
What it measures Angle only Projection magnitude Absolute displacement
Range [-1, 1] (-∞, ∞) [0, ∞)
Direction Higher = similar Higher = similar Lower = similar
Magnitude sensitivity None High High
Normalized vectors Identical ranking to dot/L2 Identical ranking to cosine/L2 Identical ranking to cosine/dot
Unnormalized vectors Ignores magnitude signal/noise Amplifies magnitude Penalizes magnitude difference
Best index support HNSW (cosine), IVF (cosine) HNSW (ip), ScaNN, IVF (ip) HNSW (l2), IVF (l2)
Quantization friendliness High (bounded error) Medium (needs anisotropic for unnormalized) Medium (error adds to distance)
Numerical stability Division by zero risk Overflow risk at high dim/magnitude Stable; use squared form for speed
Typical use case Semantic search, normalized embeddings Recommendation, MIPS, unnormalized embeddings Clustering, outlier detection, spatial data

Which to choose: verdict by use case

Use cosine similarity when:

  • Your embedding model outputs normalized vectors (OpenAI, Cohere, Voyage, most modern APIs)
  • You want semantic similarity independent of document length or embedding magnitude
  • You’re building a general-purpose semantic search or RAG system
  • You need maximum compatibility across vector databases and indexes

Default choice for 90% of LLM-era retrieval systems. Normalize once at ingest, use inner product index (HNSW-IP), done.

Use dot product when:

  • Your embeddings are unnormalized and magnitude carries signal (e.g., older BERT models, custom contrastive models where norm correlates with specificity)
  • You’re doing maximum inner product search (MIPS) for recommendation — user and item embeddings where magnitude represents preference strength
  • You’re using ScaNN or an index specifically optimized for MIPS
  • You need to preserve magnitude information for downstream scoring

Test cosine vs. dot product on a held-out evaluation set. If dot product improves nDCG or recall@k meaningfully, the magnitude signal is real. If not, normalize and use cosine.

Use L2 distance when:

  • You’re clustering embeddings (k-means, HDBSCAN) — L2 is the natural objective
  • You’re doing outlier/anomaly detection — absolute displacement matters
  • Your vectors represent spatial or geometric data (not semantic embeddings)
  • You’re implementing a custom index or algorithm that requires metric space properties (triangle inequality)

Avoid L2 for semantic search on unnormalized embeddings. It conflates “different direction” with “different magnitude” in ways that rarely match human similarity judgments.

Hybrid approach for uncertain cases:

If you don’t know whether magnitude matters, run this diagnostic:

def metric_agreement(embeddings, queries, k=10, sample=1000):
    """Measure ranking agreement between metrics on a sample."""
    from scipy.stats import spearmanr
    idx = np.random.choice(len(queries), min(sample, len(queries)), replace=False)
    agreements = []
    for q_idx in idx:
        q = queries[q_idx]
        cos_scores = cosine_similarity(embeddings, q)
        dot_scores = dot_product(embeddings, q)
        l2_scores = -l2_distance(embeddings, q)  # negate so higher=similar
        # Rank correlation
        rho_cos_dot, _ = spearmanr(cos_scores, dot_scores)
        rho_cos_l2, _ = spearmanr(cos_scores, l2_scores)
        agreements.append((rho_cos_dot, rho_cos_l2))
    return np.mean(agreements, axis=0)

# If both correlations > 0.95, metric choice doesn't matter for ranking
# If cos/dot correlation is low, magnitude matters → choose deliberately

One final consideration: reranking

If you’re using a two-stage retrieve-then-rerank pipeline (common in production RAG), the first-stage metric matters less. Retrieve 50-100 candidates with whatever metric your index supports efficiently, then rerank with a cross-encoder or LLM judge. The first stage only needs high recall; precision comes from reranking.

In this architecture, use the metric your vector database optimizes best. For most managed services (Pinecone, Weaviate, Qdrant, Milvus), that’s cosine via HNSW on normalized vectors or dot product via HNSW-IP. The marginal gain from a theoretically “better” first-stage metric rarely justifies the operational complexity of a custom index configuration.


Bottom line: Normalize your embeddings at ingest. Use cosine similarity (implemented as inner product on normalized vectors) with an HNSW index. This covers the vast majority of semantic search and RAG workloads. Only deviate when you have empirical evidence that magnitude carries signal your evaluation metrics reward.

Tagsvector-distancecosine-similaritydot-productcomparison

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All cosine similarity & vector distance metrics posts →