Skip to content

Inner products, projections, cosine similarity, and top-k

ModuleM03.6 · build · Python · Pass 3 · 2 to 3 h
You buildpython/tinyllm/linalg/inner.py: normalize(x), cosine_sim(a, b) (each norm clamped on its own), project(x, v) (onto the line through vv), and topk_cosine(query, matrix, k) (exact top-kk rows by cosine, ties to the lowest index)
Contractcourse/contracts/py/tinyllm/linalg/inner.pyi
Testscourse/tests/M03.6/test_inner.py (what they check: section 4)
Needsnothing to call. Reading: M03.1 (the dot product is the inner loop of matmul), S-M03a
Used bylater L2.3 word analogies and nearest neighbours, L6.7 the zoo’s word-similarity task (each joins the registry with its batch); ag.07 re-implements it in Go for vector retrieval
MilestoneMS-P3 (the tokens-and-data gate)
Optional depthStrang, Introduction to Linear Algebra, sections 1.2 and 4.2; Mikolov, Yih, and Zweig, “Linguistic regularities in continuous space word representations” (2013)
  • The inner product a⋅b=∥a∥∥b∥cos⁡θa \cdot b = \lVert a \rVert \lVert b \rVert \cos\theta mixes length and angle; dividing out both lengths leaves the angle alone, which is what “similar meaning” should measure (test_hand_example_cosine_and_projection, test_cosine_properties).
  • Ranking by raw dot product lets long vectors win; ranking by cosine does not (test_hand_example_topk).
  • Clamp each norm separately with ε\varepsilon: a zero vector gives 0, not NaN, and two tiny parallel vectors still give 1 (test_zero_and_tiny_vectors).
  • The projection onto vv divides by v⋅vv \cdot v, not by ∥v∥\lVert v \rVert, and leaves a residual orthogonal to vv (test_projection_properties).
  • Exact top-kk breaks ties toward the lowest index, so every run and every language returns the same list (test_topk_ties_go_to_lowest_index).
Terminal window
ol start M03.6 # stubs python/tinyllm/linalg/inner.py into your repo
ol tests M03.6 # read the test catalog first: rung R0, you write no tests here
ol check M03.6 # exit code is the verdict
ol diff M03.6 # after passing: your code against the reference

L2.3 turns every word into a vector, and then every question about words becomes a question about angles: which words are closest to “king”, which word completes “man is to king as woman is to ?”, how well the model’s similarities agree with human ratings. All of these are cosine similarities and a sorted list of the best kk. A version that ranks by raw dot product quietly prefers frequent words (their vectors are longer); one that divides by a zero norm fills the table with NaN; one that breaks ties by whatever order the sort happens to leave makes two runs disagree. The same search later runs over document embeddings in the agent’s retrieval (ag.07, in Go). This module pins the definitions down once.

SymbolMeaningType / shape
a,b,x,va, b, x, vvectors of length ddfloat64[d]
a⋅b=∑iaibia \cdot b = \sum_i a_i b_iinner (dot) productscalar
∥a∥=a⋅a\lVert a \rVert = \sqrt{a \cdot a}Euclidean lengthscalar
θ\thetaangle between aa and bbradians
ε\varepsilonclamp for tiny norms: 10−810^{-8} in cosine_sim, 10−1210^{-12} in normalizescalar
x^\hat{x}the unit vector x/∥x∥x / \lVert x \rVertfloat64[d]
proj⁡vx\operatorname{proj}_v xorthogonal projection of xx onto the line through vvfloat64[d]
MMa matrix whose nn rows are the candidatesfloat64[n, d]
qqa query vector, or a batch of themfloat64[d] or [q, d]
kkhow many results to returnint

The inner product of two vectors is a⋅b=∑iaibia \cdot b = \sum_i a_i b_i, and a⋅a=∥a∥2a \cdot a = \lVert a \rVert^2. Expanding ∥a−b∥2=∥a∥2−2 a⋅b+∥b∥2\lVert a - b \rVert^2 = \lVert a \rVert^2 - 2\,a \cdot b + \lVert b \rVert^2 and comparing with the law of cosines for the triangle with sides aa, bb, a−ba - b gives

a⋅b=∥a∥ ∥b∥cos⁡θ.a \cdot b = \lVert a \rVert \, \lVert b \rVert \cos\theta .

So the inner product is positive when the vectors point the same way, zero when they are perpendicular (orthogonal), and negative when they point apart. The Cauchy-Schwarz inequality ∣a⋅b∣≤∥a∥∥b∥\lvert a \cdot b \rvert \le \lVert a \rVert \lVert b \rVert (S-M03b q3 asks you to prove it) is what makes cos⁡θ\cos\theta a number in [−1,1][-1, 1].

Cosine similarity divides out both lengths:

cos⁡(a,b)=a⋅b∥a∥ ∥b∥.\cos(a, b) = \frac{a \cdot b}{\lVert a \rVert \, \lVert b \rVert} .

It depends only on directions: scaling aa or bb by a positive number leaves it unchanged, a negative one flips its sign, and cos⁡(a,a)=1\cos(a, a) = 1. Normalizing a vector, x^=x/∥x∥\hat{x} = x / \lVert x \rVert, makes its length 1, and for unit vectors cosine similarity is just the dot product, cos⁡(a,b)=a^⋅b^\cos(a, b) = \hat{a} \cdot \hat{b}. That is how a vector index stores embeddings: normalize once, then every search is one matrix product.

In code, vectors lie along an axis (the last by default) and the other axes broadcast: cosine_sim(A, b) with AA of shape [7,5][7, 5] and bb of shape [5][5] returns 7 similarities. The reduced axis is dropped.

A zero vector has no direction, and 0/00 / 0 is NaN. The contract follows PyTorch’s cosine_similarity and clamps each norm separately:

cos⁡(a,b)=a⋅bmax⁡(∥a∥,ε) max⁡(∥b∥,ε),ε=10−8.\cos(a, b) = \frac{a \cdot b}{\max(\lVert a \rVert, \varepsilon)\,\max(\lVert b \rVert, \varepsilon)}, \qquad \varepsilon = 10^{-8} .

A zero vector then has similarity 0 with everything. Clamping the product instead, max⁡(∥a∥∥b∥,ε)\max(\lVert a \rVert \lVert b \rVert, \varepsilon), looks equivalent and is not: two parallel vectors of length 10−510^{-5} have a product of norms 10−10<ε10^{-10} < \varepsilon, so the product clamp returns 10−10/10−8=0.0110^{-10}/10^{-8} = 0.01 for what is plainly the same direction, while separate clamps leave both norms alone and return 1. normalize uses the same rule with ε=10−12\varepsilon = 10^{-12}, so it maps the zero vector to itself.

The point on the line through v≠0v \ne 0 closest to xx is

proj⁡vx=x⋅vv⋅v v.\operatorname{proj}_v x = \frac{x \cdot v}{v \cdot v}\, v .

To see it, write x=c v+rx = c\,v + r and ask that the residual rr be orthogonal to vv: 0=r⋅v=x⋅v−c (v⋅v)0 = r \cdot v = x \cdot v - c\,(v \cdot v), so c=(x⋅v)/(v⋅v)c = (x \cdot v)/(v \cdot v). By Pythagoras, any other point c′vc' v on the line is farther: ∥x−c′v∥2=∥r∥2+(c−c′)2∥v∥2\lVert x - c'v \rVert^2 = \lVert r \rVert^2 + (c - c')^2 \lVert v \rVert^2. Three consequences the tests check: projecting twice changes nothing; the residual x−proj⁡vxx - \operatorname{proj}_v x is orthogonal to vv; and the result does not depend on the length of vv, because vv appears once on top and twice below. That last point is where the common bug lives: dividing by ∥v∥\lVert v \rVert instead of v⋅vv \cdot v is correct only for unit vv. The zero vector spans no line, so projecting onto it raises. M03.5’s least squares is the same idea with a whole column space in place of a line.

Given a query qq and candidate rows M1,…,MnM_1, \dots, M_n, topk_cosine returns the indices of the kk rows with the largest cos⁡(q,Mi)\cos(q, M_i), in descending order of score, with the scores. Two rules make it reproducible:

  • Ties go to the lowest index (the same rule as greedy sampling, D11). Duplicate rows, and rows that are positive multiples of each other, score exactly the same. A stable sort on −score-\text{score} keeps tied rows in index order; sorting ascending and reversing puts the highest index first instead.
  • Identical rows get bitwise identical scores. The reference computes each query’s scores through cosine_sim itself, an elementwise product summed along each row; a BLAS matrix product can round equal rows differently in different blocks, which would break ties at random.

This is “exact” (brute force) search: it scores every row, O(nd)O(nd) per query, which is what L2.3 and L6.7 need for vocabularies of tens of thousands of words. Approximate indexes (IVF, HNSW) trade exactness for speed and arrive with ag.07.

a=(3,4)a = (3, 4) and b=(4,3)b = (4, 3).

QuantityValue
a⋅ba \cdot b12+12=2412 + 12 = 24
∥a∥\lVert a \rVert, ∥b∥\lVert b \rVert55, 55
cos⁡(a,b)\cos(a, b)24/25=0.9624/25 = 0.96
a^\hat{a}(0.6,0.8)(0.6, 0.8)
proj⁡ba=2425(4,3)\operatorname{proj}_b a = \frac{24}{25} (4, 3)(3.84,2.88)(3.84, 2.88)
residual a−proj⁡baa - \operatorname{proj}_b a(−0.84,1.12)(-0.84, 1.12); check: −0.84⋅4+1.12⋅3=−3.36+3.36=0-0.84 \cdot 4 + 1.12 \cdot 3 = -3.36 + 3.36 = 0

This is test_hand_example_cosine_and_projection.

Top 3 for the query q=(3,4)q = (3, 4) among the rows

RowVectorq⋅Miq \cdot M_i∥Mi∥\lVert M_i \rVertcosine
0(4,3)(4, 3)24524/25=0.9624/25 = 0.96
1(6,8)(6, 8)501050/50=150/50 = 1
2(−3,−4)(-3, -4)−25-255−1-1
3(10,0)(10, 0)301030/50=0.630/50 = 0.6
4(0,2)(0, 2)828/10=0.88/10 = 0.8

By cosine the top 3 are rows 1,0,41, 0, 4 with scores 1,0.96,0.81, 0.96, 0.8. By raw dot product they would be rows 1,3,01, 3, 0: the long vector (10,0)(10, 0), which points 53°53° away from qq, would beat (0,2)(0, 2), only 37°37° away. This is test_hand_example_topk.

def normalize(x: ArrayLike, axis: int = -1, eps: float = 1e-12) -> NDArray: ...
def cosine_sim(a: ArrayLike, b: ArrayLike, axis: int = -1, eps: float = 1e-8) -> NDArray: ...
def project(x: ArrayLike, v: ArrayLike, axis: int = -1) -> NDArray: ...
def topk_cosine(query: ArrayLike, matrix: ArrayLike, k: int) -> tuple[NDArray, NDArray]:
"""(indices int64, scores float64), [k] or [q, k], descending, ties to the lowest index."""
TestKINDChecksWhy it matters downstream
test_hand_example_cosine_and_projectionunit, smokesection 3’s cosine, projection, residual, and unit vectoryou and the tests agree on the definitions
test_hand_example_topkunit, smokerows 1,0,41, 0, 4 with scores 1,0.96,0.81, 0.96, 0.8cosine, not dot product, ranks the neighbours of L2.3
test_cosine_matches_formula_and_broadcastsdifferentiala [7,5][7, 5] batch against one vector, along the last axis and along axis 0the zoo scores whole vocabularies at once
test_cosine_propertiespropertysymmetric, scale invariant, sign flips, in [−1,1][-1, 1], cos⁡(a,a)=1\cos(a, a) = 1the angle and nothing else
test_zero_and_tiny_vectorsboundaryzero vector gives 0 (and normalize keeps it 0); parallel vectors of norm 10−510^{-5} give 1the per-norm clamp
test_normalize_gives_unit_vectorspropertyunit rows, rescaling recovers xx, axis respectedvector indexes store unit rows
test_projection_propertiespropertyP2=PP^2 = P, residual orthogonal to vv, independent of vv‘s lengthdivide by v⋅vv \cdot v
test_projection_rejects_zero_directionboundaryprojecting onto 0 and mismatched lengths raiseno NaN, no silent broadcast
test_topk_matches_brute_forcedifferential6 queries against 200 rows of mixed lengths: indices and scores equal a full sortexact top-kk
test_topk_ties_go_to_lowest_indexboundaryduplicate and rescaled rows come back in index orderreproducible across runs and languages
test_topk_rejects_bad_kboundaryk=0k = 0, k>nk > n, and a wrong query length raise; k=nk = n returns every rowcaller bugs fail early
PitfallSymptomCaught by
1. ranking by the raw dot productfrequent words (long vectors) are everyone’s nearest neighbourtest_hand_example_topk, test_topk_matches_brute_force (mutant s01)
2. ties broken toward the highest index (sort ascending, then reverse)two equal candidates come back in the other order; the Go port disagreestest_topk_ties_go_to_lowest_index (mutant s02)
3. clamping the product of the normstiny but parallel vectors score 0.01 instead of 1test_zero_and_tiny_vectors (mutant s03)
4. projecting with ∥v∥\lVert v \rVert in the denominatorcorrect only for unit vv; the residual is not orthogonaltest_hand_example_cosine_and_projection, test_projection_properties (mutant s04)
5. no clamp at allNaN for any zero vector, and NaN sorts unpredictablytest_zero_and_tiny_vectors (mutant s05)
projecting onto the zero vectorNaN instead of an errortest_projection_rejects_zero_direction (mutant s06)
returning the top-kk in ascending orderthe worst match firsttest_hand_example_topk (mutant s07)
summing over the last axis whatever axis sayswrong results for column-stored vectorstest_cosine_matches_formula_and_broadcasts (mutant s08)
DirectionModuleHow it uses this
BackM03.1the dot product is the inner loop of matmul; a row-major [n,d][n, d] matrix keeps each candidate contiguous (reading)
ForwardL2.3analogy ranks b−a+cb - a + c against the vocabulary with topk_cosine; nearest-neighbour checks of the embeddings
ForwardL6.7the zoo’s word-similarity task correlates cosine_sim with human ratings (Spearman)
Forwardag.07the Go retriever re-implements cosine and exact top-kk with the same tie rule (not a call site)

If you skip this module, L2.3 and L6.7 stop with BLOCKED ... needs M03.6 once they land: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
cosine_simtorch.nn.functional.cosine_similaritythe same per-norm clamp, batched on GPUtorch/nn/functional.py, aten/src/ATen/native/Distance.cpp
topk_cosine (exact)FAISS IndexFlatIP over normalized rowsSIMD and BLAS inner products, a heap per queryfaiss/IndexFlat.cpp
exact searchFAISS IVF and HNSW indexessublinear search with a recall knob (nprobe, efSearch)faiss/IndexIVF.cpp, faiss/IndexHNSW.cpp
normalize + dotpgvector’s <=> cosine distancethe same search inside Postgres, with IVFFlat and HNSW indexespgvector/src/vector.c