Skip to content
Back to Notesphamkhanhminhman.com / notes
Mathematics & ML• 13 Oct 2026• 3 min read

The Geometry Behind Vector Search

Notes from linear algebra (lesson 1) and the embeddings lesson of my Master's course. Sources: Deisenroth et al., Mathematics for Machine Learning §6.4.6; 3Blue1Brown, Essence of Linear Algebrach. 1 and 9; Ethayarajh (2019); Timkey & van Schijndel (2021). Numbers below were re-run with NumPy 2.0.2 and PostgreSQL 18.6 + pgvector 0.8.7.

1. The dot product is a shadow

a⋅b=∑iaibi=∥a∥ ∥b∥cos⁡θ\mathbf{a} \cdot \mathbf{b} = \sum_i a_i b_i = \|\mathbf{a}\|\,\|\mathbf{b}\|\cos\theta

Dividing by ∥b∥\|\mathbf{b}\| gives the signed length of the shadow of a\mathbf{a} on the line through b\mathbf{b}. Multiplying that length by the unit vector b/∥b∥\mathbf{b}/\|\mathbf{b}\| gives the shadow itself. The ∥b∥\|\mathbf{b}\| is divided out twice, which is why the denominator is b⋅b\mathbf{b}\cdot\mathbf{b}:

proj⁡b(a)=a⋅bb⋅b b\operatorname{proj}_{\mathbf{b}}(\mathbf{a}) = \frac{\mathbf{a}\cdot\mathbf{b}}{\mathbf{b}\cdot\mathbf{b}}\,\mathbf{b}
Geometric Visualizer•Dot Product as a Perpendicular Shadow
xy0-4-3-2-1123451234θ ≈ 16.3°proj_b(a) = (3.84, 2.88)shadow length = 4.80 (24 / 5)r = a - proj = (-0.84, 1.12)r ⟂ b (90° right angle, r · b = 0)b = (4, 3)||b|| = 5.0 (Base axis)a = (3, 4)||a|| = 5.0 (Input vector)
Target Axis: b=(4,3)\mathbf{b} = (4, 3)

Magnitude ∥b∥=5.0\|\mathbf{b}\| = 5.0. Acts as the line onto which the shadow is cast. The direction unit is b^=(0.8,0.6)\hat{\mathbf{b}} = (0.8, 0.6).

Projected Shadow: projb(a)\text{proj}_{\mathbf{b}}(\mathbf{a})

Shadow length is a⋅b∥b∥=245=4.80\frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{b}\|} = \frac{24}{5} = 4.80. Vector coordinate is (3.84,2.88)(3.84, 2.88).

Orthogonal: a⋅c=0\mathbf{a} \cdot \mathbf{c} = 0

Vector c=(−4,3)\mathbf{c} = (-4, 3) is at 90∘90^\circ to a\mathbf{a}. Light shining perpendicular produces exactly zero shadow (no component of a along c).

With a=(3,4)\mathbf{a}=(3,4) and b=(4,3)\mathbf{b}=(4,3): the dot product is 24, cos⁡θ=24/25=0.96\cos\theta = 24/25 = 0.96, the shadow has length 4.8 and equals (3.84,2.88)(3.84, 2.88). What is left, a−proj⁡b(a)=(−0.84,1.12)\mathbf{a}-\operatorname{proj}_{\mathbf{b}}(\mathbf{a}) = (-0.84, 1.12), is perpendicular to b\mathbf{b}. For c=(−4,3)\mathbf{c}=(-4,3) the dot product with a\mathbf{a} is 0, so the shadow is empty.

Orthogonal means no component along the other vector, which is weaker than independent: x=(−1,0,1)x=(-1,0,1) and y=x2=(1,0,1)y=x^2=(1,0,1) have dot product 0 and correlation 0, yet yy is determined by xx.

In dd dimensions the cosine of two independent random directions has a standard deviation of 1/d1/\sqrt{d}: 0.71 in 2D, 0.036 in 768D. So random vectors in high dimensions are nearly orthogonal.

2. Cosine and Euclidean agree on unit vectors

If ∥u∥=∥v∥=1\|\mathbf{u}\|=\|\mathbf{v}\|=1:

∥u−v∥2=∥u∥2+∥v∥2−2 u⋅v=2−2cos⁡(u,v)\|\mathbf{u}-\mathbf{v}\|^2 = \|\mathbf{u}\|^2 + \|\mathbf{v}\|^2 - 2\,\mathbf{u}\cdot\mathbf{v} = 2 - 2\cos(\mathbf{u},\mathbf{v})

Distance is a decreasing function of cosine, so the smallest distance and the largest cosine pick the same neighbours. On random data (10,000 documents, 50 queries, 768 dimensions) the top-10 lists were identical for 50 of 50 queries after normalizing, and for 0 of 50 before. The difference shows up with raw counts: for q=(1,0)q=(1,0) and a document d=(10,0)d=(10,0) the Euclidean distance is 9 while the cosine is 1. An approximate index such as HNSW returns an approximation of this ranking.

3. What pgvector computes

  • <=> cosine distance, 1−cos⁡(u,v)1-\cos(\mathbf{u},\mathbf{v})
  • <-> Euclidean (L2) distance
  • <#> negative inner product, − u⋅v-\,\mathbf{u}\cdot\mathbf{v}

<#> is negative because Postgres only supports ascending index scans on operators (pgvector README), so the largest inner product has to be the smallest value. On unit vectors −(inner product)-(\text{inner product}) is the cosine; over 2,000 random unit vectors it matched 1 - (embedding <=> q) to 6e-8.

CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  content TEXT NOT NULL,
  embedding vector(768) NOT NULL   -- L2-normalized at write time
);

CREATE INDEX ON documents USING hnsw (embedding vector_ip_ops);

SELECT id, content, (embedding <#> $1) * -1.0 AS cosine_similarity
FROM documents
ORDER BY embedding <#> $1 ASC
LIMIT 10;

The ASC query uses the HNSW index; with DESC the planner falls back to a sort over a sequential scan (a B-tree index can still scan backwards). On an exact scan of 20,000 rows, the three operators all took about 20 ms, so the simpler formula is not a speed argument.

4. A shared offset flattens cosine

For random variables with zero mean, covariance is an inner product: the norm is the standard deviation, cosine is the correlation, orthogonal means uncorrelated (MML §6.4.6). Cosine itself ignores the mean, so if all vectors share a large offset along one coordinate they all point the same way and every pair scores high. Ethayarajh and Timkey & van Schijndel describe this for contextual embeddings as anisotropy and rogue dimensions. Synthetic check, 2,000 independent vectors in 64 dimensions with one coordinate shifted and scaled:

Vectorsmean cosinestdpairs > 0.90
independent0.00000.1250%
one coordinate shifted0.95250.01099.996%
after subtracting the mean-0.00050.1530%

Subtracting the mean restores the spread. In practice: subtract the corpus mean from the documents and the query, then normalize again. I only checked this on synthetic data, not on a real embedding model.

Mathematics & MLBack to Notes