Cosine similarity measures the cosine of the angle between two non-zero vectors in an inner product space. It ranges from -1 (opposite directions) to 1 (identical directions), with 0 indicating orthogonality. Unlike Euclidean distance, it ignores magnitude and focuses purely on orientation, making it the default choice for comparing text embeddings.
How cosine similarity works
The formula is straightforward: the dot product of two vectors divided by the product of their magnitudes.
import numpy as np
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
Geometrically, this computes cos(θ) where θ is the angle between vectors. When vectors point in the same direction, θ = 0 and cos(0) = 1. When they’re orthogonal, θ = 90° and cos(90°) = 0. Opposite directions give -1.
The key insight: cosine similarity is scale-invariant. Multiply a vector by any positive scalar and the similarity stays the same. This matters because embedding models produce vectors where magnitude often carries little semantic signal — it’s the direction that encodes meaning.
Why it matters for embeddings
Embedding models (BERT, OpenAI’s text-embedding-3-large, Cohere, etc.) map text to high-dimensional vectors. The training objectives — typically contrastive learning or next-token prediction — optimize for angular separation between semantically distinct inputs. Magnitude varies with token count, vocabulary frequency, and model quirks, but direction clusters by meaning.
Consider two sentences:
- “The cat sat on the mat”
- “A kitten rested on the rug”
Their embeddings will point roughly the same direction despite different lengths and word choices. Euclidean distance would penalize the length difference; cosine similarity ignores it.
This property makes cosine similarity the standard for:
- Semantic search: ranking documents by query relevance
- Clustering: grouping similar documents without magnitude bias
- Deduplication: detecting near-duplicate content
- Recommendation: finding items with similar embedding profiles
A concrete example
Let’s walk through a minimal example with 3-dimensional vectors for clarity. In practice you’ll work with 768, 1536, or 3072 dimensions, but the math is identical.
import numpy as np
# Three sentences, artificially mapped to 3D for visualization
sentences = {
"king": np.array([0.8, 0.5, 0.1]),
"queen": np.array([0.75, 0.55, 0.05]),
"apple": np.array([0.1, 0.2, 0.9]),
}
def cos_sim(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
for label_a, vec_a in sentences.items():
for label_b, vec_b in sentences.items():
if label_a < label_b: # avoid duplicate pairs
sim = cos_sim(vec_a, vec_b)
print(f"{label_a:5} vs {label_b:5}: {sim:.4f}")
Output:
king vs queen: 0.9942
king vs apple: 0.2673
queen vs apple: 0.2841
King and queen are nearly identical in direction (0.99). Both are far from apple (~0.27). The magnitude of each vector doesn’t matter — only the angle.
Now compare with Euclidean distance on the same vectors:
def euclidean(a, b):
return np.linalg.norm(a - b)
for label_a, vec_a in sentences.items():
for label_b, vec_b in sentences.items():
if label_a < label_b:
dist = euclidean(vec_a, vec_b)
print(f"{label_a:5} vs {label_b:5}: {dist:.4f}")
Output:
king vs queen: 0.0860
king vs apple: 1.0440
queen vs apple: 1.0062
The ranking is preserved here, but Euclidean distance conflates magnitude and direction. If we scaled “king” by 10x (same direction, larger magnitude), cosine similarity would be unchanged while Euclidean distance would explode.
king_scaled = sentences["king"] * 10
print(f"cosine(king*10, queen): {cos_sim(king_scaled, sentences['queen']):.4f}")
print(f"euclidean(king*10, queen): {euclidean(king_scaled, sentences['queen']):.4f}")
cosine(king*10, queen): 0.9942
euclidean(king*10, queen): 7.7860
This scale invariance is why cosine similarity dominates embedding workflows.
Efficient computation at scale
In production you’re comparing a query vector against millions of document vectors. Naive pairwise computation is too slow. Two standard approaches:
1. Normalize once, use dot product
If you L2-normalize all vectors to unit length, cosine similarity reduces to a simple dot product:
# Preprocessing: normalize all document vectors once
doc_vectors = np.random.randn(1_000_000, 1536).astype(np.float32)
doc_vectors = doc_vectors / np.linalg.norm(doc_vectors, axis=1, keepdims=True)
# Query time: single matrix-vector multiply
query = np.random.randn(1536).astype(np.float32)
query = query / np.linalg.norm(query)
similarities = doc_vectors @ query # shape: (1_000_000,)
top_k_indices = np.argpartition(similarities, -10)[-10:]
This is exactly what FAISS, Annoy, and HNSWlib do internally for cosine similarity indexes — they normalize vectors on insert and use inner product search.
2. Approximate nearest neighbors (ANN)
For millions of vectors, exact search is still expensive. ANN indexes trade a tiny bit of recall for orders-of-magnitude speedup:
import faiss
# Build HNSW index for cosine similarity (inner product on normalized vectors)
dim = 1536
index = faiss.IndexHNSWFlat(dim, 32) # 32 connections per node
index.hnsw.efConstruction = 200
index.hnsw.efSearch = 128
# FAISS expects normalized vectors for cosine via inner product
index.add(doc_vectors) # already normalized
# Search
distances, indices = index.search(query.reshape(1, -1), k=10)
# distances are inner products = cosine similarities
HNSW is the workhorse here. IVF-PQ works too but requires training. For cosine similarity specifically, always normalize first and use IndexFlatIP or IndexHNSWFlat with inner product metric.
Common misconceptions
“Cosine similarity is a metric”
It’s not. A metric must satisfy four properties: non-negativity, identity of indiscernibles, symmetry, and triangle inequality. Cosine similarity fails the first two:
- It can be negative (range is [-1, 1])
- Two different vectors can have similarity 1 (any positive scalar multiples)
Cosine distance (1 - cosine_similarity) is a proper metric on the unit sphere, but even then it only satisfies triangle inequality for vectors on the unit hypersphere. Don’t feed raw cosine similarity into algorithms that require a metric space.
“Higher dimensions make cosine similarity less meaningful”
This confuses cosine similarity with the curse of dimensionality affecting distance concentration. In high dimensions, random vectors become nearly orthogonal (cosine ~ 0). But embeddings aren’t random — they’re trained to cluster semantically similar items. The signal-to-noise ratio depends on training quality, not dimensionality per se. 1536-dim OpenAI embeddings work fine; 768-dim BERT embeddings work fine. What matters is whether the embedding space actually separates your concepts.
“Cosine similarity of 0.8 means 80% similar”
No. Cosine similarity is not a percentage. It’s the cosine of an angle. 0.8 corresponds to ~36.9°. The relationship to human similarity judgments is empirical, not linear. Calibrate thresholds on your data:
# Find a threshold that works for your use case
thresholds = np.arange(0.5, 0.95, 0.05)
for t in thresholds:
# evaluate precision/recall at this threshold on labeled pairs
pass
“Negative cosine similarity means opposite meaning”
In embedding spaces, negative similarity is rare and usually indicates an artifact — out-of-distribution inputs, adversarial examples, or vectors from different embedding models. Semantically opposite concepts (hot/cold, buy/sell) typically have low positive similarity (0.1–0.3), not negative. Don’t build logic assuming negative = antonym.
“You can average cosine similarities”
Averaging cosine similarities across pairs is mathematically meaningless. The average of cosines is not the cosine of the average angle. If you need an aggregate similarity (e.g., document-to-cluster), average the vectors first, then compute cosine similarity:
# Wrong: average of pairwise similarities
pairwise_sims = [cos_sim(query, doc) for doc in cluster_docs]
avg_sim = np.mean(pairwise_sims) # meaningless
# Right: similarity to centroid
centroid = np.mean(cluster_docs, axis=0)
centroid = centroid / np.linalg.norm(centroid)
correct_sim = cos_sim(query, centroid)
When not to use cosine similarity
- Magnitude carries signal: If your vectors encode confidence, frequency, or intensity in their norm, cosine similarity throws that away. Use Euclidean distance or a learned metric.
- Sparse binary vectors: For bag-of-words or one-hot encodings, Jaccard index or Hamming distance often works better.
- Strict metric requirements: If you need triangle inequality for theoretical guarantees (e.g., certain clustering algorithms), use cosine distance on normalized vectors or Euclidean distance.
- Cross-model comparison: Cosine similarity between vectors from different embedding models is meaningless. The spaces aren’t aligned. Use a cross-encoder or translate via a shared space.
Practical checklist
When implementing cosine similarity in a pipeline:
- Normalize embeddings on write — do it once at ingestion, not at query time
- Use float32 — float64 wastes memory and bandwidth; float16 loses precision for similarity near 1.0
- Batch queries — matrix multiplication (BLAS) is 10-100x faster than looping
- Monitor distribution — log similarity histograms; a shift toward 1.0 often indicates data leakage or model drift
- Version your embeddings — cosine similarity is only comparable within the same model version
# Production-ready pattern
class EmbeddingIndex:
def __init__(self, dim: int):
self.dim = dim
self.index = faiss.IndexHNSWFlat(dim, 32)
self.index.hnsw.efConstruction = 200
self.index.hnsw.efSearch = 128
self.ids = []
def add(self, vectors: np.ndarray, ids: list[str]):
# Ensure float32, normalized
vectors = vectors.astype(np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / np.maximum(norms, 1e-10)
self.index.add(vectors)
self.ids.extend(ids)
def search(self, query: np.ndarray, k: int = 10) -> list[tuple[str, float]]:
query = query.astype(np.float32)
query = query / np.maximum(np.linalg.norm(query), 1e-10)
scores, idxs = self.index.search(query.reshape(1, -1), k)
return [(self.ids[i], float(s)) for i, s in zip(idxs[0], scores[0]) if i != -1]
Summary
Cosine similarity measures angular alignment between vectors, ignoring magnitude. That’s exactly what you want for trained embeddings where direction encodes semantics. Normalize once, use dot product or ANN indexes for speed, and calibrate thresholds on your data — not on intuition about what “0.8” means. The math is simple; the engineering is in the pipeline around it.