Cosine similarity measures the cosine of the angle between two non-zero vectors, producing a score from -1 to 1 that captures directional alignment independent of magnitude. For embeddings, this means two vectors pointing in the same semantic direction score near 1 regardless of their length, making it the default choice for comparing dense representations from models like BERT, OpenAI’s text-embedding-3-large, or Cohere’s embed-v3. The metric is computationally cheap, well-understood, and works directly on normalized vectors without additional preprocessing.
How cosine similarity works
Given vectors A and B in ℝⁿ, cosine similarity is defined as:
cos(θ) = (A · B) / (||A|| ||B||)
Where A · B is the dot product and ||A||, ||B|| are L2 norms. The numerator captures how much the vectors point together; the denominator normalizes by their lengths. When both vectors are unit length (L2-normalized), the formula collapses to a simple dot product:
cos(θ) = A · B (when ||A|| = ||B|| = 1)
This is why most embedding APIs return normalized vectors — you can skip the division entirely and compute similarity with a single matrix multiply. In NumPy:
import numpy as np
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Assumes a and b are already L2-normalized."""
return float(np.dot(a, b))
# Batch version for retrieval
def cosine_similarity_batch(query: np.ndarray, corpus: np.ndarray) -> np.ndarray:
"""query: (d,), corpus: (n, d) — both normalized."""
return corpus @ query # shape (n,)
If your vectors aren’t normalized, normalize first:
def normalize(v: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(v, axis=-1, keepdims=True)
return v / np.maximum(norm, 1e-12)
The computational cost is O(d) per pair for dot product plus O(d) for normalization — trivial compared to the embedding model’s forward pass. On GPU, a single matrix multiply handles thousands of comparisons in milliseconds.
Why magnitude invariance matters for embeddings
Embedding magnitude often correlates with confidence, frequency, or token count rather than semantic content. Consider these scenarios:
- A short sentence and a long paragraph about the same topic may have different norms because the model attends to more tokens.
- Some models (especially older ones like word2vec) produce vectors where frequent words sit closer to the origin.
- Fine-tuning or distillation can shift the overall scale without changing directional semantics.
Cosine similarity ignores all of this. Two vectors with identical direction but 10x magnitude difference score 1.0. Euclidean distance, by contrast, would penalize the magnitude difference heavily:
# Same direction, different magnitude
a = np.array([1.0, 2.0, 3.0])
b = np.array([10.0, 20.0, 30.0]) # 10x scale
cos_sim = cosine_similarity(normalize(a), normalize(b)) # 1.0
euclidean = np.linalg.norm(a - b) # ~31.6 — misleadingly large
This property makes cosine similarity robust for semantic search, clustering, and deduplication where you want “same meaning” regardless of verbosity or model-specific scaling quirks.
When cosine similarity is the wrong choice
Magnitude invariance is a feature — until it isn’t. Avoid cosine similarity when:
- Magnitude carries signal. In some contrastive learning setups (e.g., SimCLR, CLIP), the norm encodes confidence or “typicality.” Discarding it throws away information.
- You need a true metric. Cosine similarity is not a metric — it violates triangle inequality and
cos(a, a) = 1not 0. For metric-space indexing (VP-trees, cover trees), convert to angular distance:θ = arccos(cos_sim). - Vectors are sparse or high-dimensional with many zeros. In sparse TF-IDF or BM25 vectors, cosine similarity overweights rare terms. Dot product with IDF weighting often works better.
- You’re comparing probability distributions. Use KL divergence, JS divergence, or Hellinger distance instead.
Concrete example: semantic search over documentation
Suppose you’re building a RAG system over technical documentation. You embed chunks with text-embedding-3-large (3072 dimensions, normalized) and store them in a vector index. A user queries “how to authenticate with OAuth2”. Here’s the retrieval pipeline:
import numpy as np
from typing import List, Tuple
# Simulated normalized embeddings (in practice: from your vector DB)
chunk_embeddings: np.ndarray # shape (n_chunks, 3072), already L2-normalized
chunk_texts: List[str]
def retrieve(query_embedding: np.ndarray, top_k: int = 10) -> List[Tuple[float, str]]:
"""Return top-k chunks by cosine similarity."""
# query_embedding is already normalized by the embedding API
scores = chunk_embeddings @ query_embedding # (n_chunks,)
top_indices = np.argpartition(-scores, top_k)[:top_k]
top_indices = top_indices[np.argsort(-scores[top_indices])]
return [(float(scores[i]), chunk_texts[i]) for i in top_indices]
# Usage
query_vec = embed("how to authenticate with OAuth2") # normalized
results = retrieve(query_vec, top_k=5)
for score, text in results:
print(f"{score:.4f} {text[:120]}...")
Output might look like:
0.8723 OAuth2 authentication flow: redirect user to /authorize with client_id...
0.8411 Refreshing access tokens: POST /token with grant_type=refresh_token...
0.8194 Client credentials flow for service-to-service auth...
0.7982 PKCE extension for public clients prevents authorization code interception...
0.7651 Troubleshooting 401 errors: check token expiry, scope, and audience...
Scores cluster near 0.8–0.9 because all chunks share the “OAuth2 authentication” semantic direction. The ranking reflects nuanced topical alignment, not chunk length or token count.
Common misconceptions
“Cosine similarity is just normalized dot product”
True only for unit vectors. If you skip normalization, dot product conflates magnitude and direction. Always normalize first — or confirm your embedding provider does it. OpenAI, Cohere, and Voyage AI return normalized vectors. Sentence-Transformers’ encode(normalize_embeddings=True) does too. But raw BERT [CLS] outputs or word2vec vectors are not normalized.
“High cosine similarity means the texts are semantically equivalent”
Cosine similarity > 0.9 often indicates paraphrase or near-duplicate. But 0.7–0.8 can mean “related topic” not “same meaning.” Two documents about “Python async patterns” and “Go concurrency primitives” might score 0.75 — same domain, different languages. Always calibrate thresholds on your data:
def calibrate_threshold(embeddings: np.ndarray, pairs: List[Tuple[int, int, bool]]) -> float:
"""Find threshold maximizing F1 on labeled similar/dissimilar pairs."""
from sklearn.metrics import precision_recall_curve
scores = []
labels = []
for i, j, is_similar in pairs:
scores.append(float(embeddings[i] @ embeddings[j]))
labels.append(is_similar)
precision, recall, thresholds = precision_recall_curve(labels, scores)
f1 = 2 * precision * recall / (precision + recall + 1e-12)
return float(thresholds[np.argmax(f1)])
“Cosine similarity works for any vector type”
It assumes the vector space is isotropic — all directions equally meaningful. Anisotropic spaces (common in untrained or poorly trained embeddings) concentrate vectors in a narrow cone, inflating cosine similarities artificially. This is why whitening or centering (e.g., sklearn.decomposition.PCA with whiten=True) can improve downstream task performance:
from sklearn.decomposition import PCA
def whiten_embeddings(embeddings: np.ndarray, n_components: int = None) -> np.ndarray:
"""Center and whiten — makes cosine similarity more discriminative."""
pca = PCA(n_components=n_components, whiten=True, random_state=42)
transformed = pca.fit_transform(embeddings)
return normalize(transformed)
After whitening, cosine similarity better reflects true semantic distance. This is standard practice for static embeddings (word2vec, GloVe) and sometimes helps with transformer embeddings too.
“Negative cosine similarity means opposite meaning”
In high-dimensional spaces (> 768 dims), random vectors are nearly orthogonal (cosine ≈ 0). Negative cosine similarity is rare and usually indicates adversarial or explicitly contrastive pairs (e.g., “good” vs “bad” in a sentiment subspace). Don’t interpret -0.2 as “somewhat opposite” — it’s effectively noise. Most production systems clamp scores at 0 for retrieval:
scores = np.maximum(corpus @ query, 0.0)
Practical tips for production
Batch normalize once, not per query. If you control the index, store pre-normalized vectors. If you don’t (e.g., using a managed vector DB), verify the DB normalizes on write or query. Some databases (Pinecone, Weaviate) handle this automatically; others (pgvector, Redis) require you to normalize before INSERT.
Use float32, not float64. Embedding dimensions are typically 768–3072. Float32 dot products are bit-exact for normalized vectors up to ~2048 dimensions and 2–4x faster on GPU. Only use float64 if you’re accumulating many products (e.g., PCA whitening).
Pre-filter before vector search. Cosine similarity over 10M vectors is fast but not free. Apply metadata filters (tenant, date, language) first, then run vector search on the candidate set. Most vector databases support this natively.
Monitor score distributions. A healthy retrieval system shows a clear gap between relevant and irrelevant scores. If your top-10 scores are all 0.82–0.85, your embeddings may be anisotropic or your chunks too granular. Track max_score, mean_top10, and score_gap = top1 - top10 as observability metrics.
Summary
Cosine similarity embeddings remain the workhorse of semantic retrieval because they’re fast, magnitude-invariant, and well-calibrated for normalized dense vectors. The key engineering decisions are: ensure your vectors are actually normalized, recognize when magnitude carries signal you shouldn’t discard, and calibrate thresholds on real labeled pairs rather than trusting generic cutoffs. Whitening helps when the embedding space is anisotropic. And always pre-filter metadata before vector search — the fastest cosine similarity is the one you don’t compute.