Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 74 of 899 min

Dot products and cosine similarity: one vector against a hundred thousand in a single line

An embedding is a row of floats

Ask an embedding model for a vector and you get back a list of numbers — 384 for a small open model, 1,536 or 3,072 for the hosted ones. What the numbers mean is the business of the machine learning course; here they are a (768,) float32 array, and a corpus of a hundred thousand documents is a (100000, 768) matrix. Everything below is arithmetic on that matrix.

python
q = np.asarray(embed("how do I reset my password"), dtype=np.float32)   # (768,)
M = np.load("embeddings.npy")                                            # (100000, 768)

np.save/np.load write the raw array with a small header — the fastest way to persist one, and much smaller than JSON.

Dot product

The dot product of two vectors multiplies them elementwise and sums:

python
a @ b                    # same as np.dot(a, b), same as (a * b).sum()

@ is the matrix-multiplication operator, and for a matrix against a vector it computes the dot product of every row with the vector at once:

python
M @ q                    # (100000, 768) @ (768,) → (100000,)

One line, one compiled loop, one hundred thousand dot products. Compare:

python
[np.dot(row, q) for row in M]     # ~400 ms
M @ q                             # ~3 ms

Same numbers, same arithmetic, different plumbing. The loop pays Python overhead per row; the matrix product hands the whole block to a compiled routine that reads memory in order and uses the CPU's vector units — the previous lessons in one measurement.

Scoring one query against every document02500500010000400000Documents in the indexMilliseconds per query—— Python loop, np.dot per row– – M_unit @ q, one lineBoth lines are straight: the arithmetic is rows times dimensions either way. What differs is theconstant. The loop pays Python's per-row overhead a hundred thousand times; the matrix product handsthe whole block to a compiled routine once. Brute force stays honest to a few hundred thousand rows,and an index earns its complexity after that.
Scoring one query against every document02500500010000400000Across: Documents in the indexUp: Milliseconds per query—— Python loop, np.dot per row– – M_unit @ q, one lineBoth lines are straight: the arithmetic is rowstimes dimensions either way. What differs is theconstant. The loop pays Python's per-row overhead ahundred thousand times; the matrix product hands thewhole block to a compiled routine once. Brute forcestays honest to a few hundred thousand rows, and anindex earns its complexity after that.

Cosine similarity

The dot product grows with the lengths of the vectors as well as their alignment. Cosine similarity removes the length:

python
def cosine(a, b):
    return (a @ b) / (np.linalg.norm(a) * np.linalg.norm(b))

It is the cosine of the angle between them: 1 for the same direction, 0 for perpendicular, −1 for opposite. Embedding models are used with cosine because a document's vector length carries little meaning while its direction carries the content.

Doing the division per query is wasteful. Normalise each row of M to unit length once, when you build it:

python
norms = np.linalg.norm(M, axis=1, keepdims=True)    # (100000, 1)
M_unit = M / norms                                  # every row now has length 1

Then every query is:

python
q_unit = q / np.linalg.norm(q)
sims = M_unit @ q_unit                              # (100000,) of cosines

A dot product of two unit vectors is their cosine. The keepdims=True is the broadcasting lesson paying off: (100000, 768) / (100000, 1) divides each row by its own norm.

Guard the zero vector: a document that embedded to all zeros has norm 0 and produces nan everywhere. norms[norms == 0] = 1 before dividing, or filter such rows out and log them.

Top k

python
k = 5
top = np.argpartition(sims, -k)[-k:]         # indices of the k largest, unordered
top = top[np.argsort(sims[top])[::-1]]       # sort just those k
for i in top:
    print(f"{sims[i]:.3f}  {docs[i][:60]}")

argpartition finds the k largest in linear time without sorting all hundred thousand; the second line orders only the five. For a hundred thousand rows argsort on everything is fine too — it is a few milliseconds — but the habit matters once the corpus is tens of millions.

Many queries at once

A batch of queries is a matrix too, and the matrix-matrix product gives every pairwise similarity:

python
Q_unit = Q / np.linalg.norm(Q, axis=1, keepdims=True)   # (50, 768)
S = Q_unit @ M_unit.T                                   # (50, 100000)

Row i of S is the similarity of query i to every document. The .T is a view, so nothing is copied. Fifty queries cost little more than one, because the compiled routine is doing the same memory pass with more arithmetic per element loaded.

Where brute force stops

The arithmetic is rows × dimensions multiply-adds per query: 100,000 × 768 ≈ 77 million, a few milliseconds on one core. At ten million rows it is 7.7 billion, around a second, and the matrix is 30 GB in float32 — more than most machines hold. That is the point where an approximate index (FAISS, HNSW via hnswlib, or a vector database) earns its complexity by trading a little recall for a thousand-fold speed-up. Below a million rows, M_unit @ q in NumPy is simpler, exact, and fast enough, and that covers nearly every project a person builds alone.

A cheap intermediate step: store M_unit as float16 to halve memory, and upcast the query result. The precision lesson said float16 keeps three figures; for ranking similarities that is usually enough, and it is a measurable decision rather than a guess.

Try this now

Make a random (100000, 768) float32 matrix with default_rng, normalise its rows, and time M_unit @ q against the Python loop. Then plant a known vector at row 4,321, query with a slightly noisy copy of it, and confirm that row comes out on top.

The one thing to keep

Cosine similarity is the dot product of two unit-length vectors, so normalise the matrix once and every query becomes one matrix-vector product; at a hundred thousand rows that is milliseconds, and the point at which it stops being milliseconds is the point at which an index becomes worth its complexity.

Before you move on

A developer has a `(100000, 768)` float32 matrix of embeddings and computes cosine similarity for each query with a Python loop over rows calling `np.dot` and two `np.linalg.norm` calls per row. It takes about 400 ms per query. What single change brings it to a few milliseconds?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly

Dot products and cosine similarity: one vector against a hundred thousand in a single line · Python, From Zero, For AI · Addaly