2026-05-29 · fundamentals
What cosine similarity actually measures
Week 3 of the fundamentals series: embeddings and vector search. Cosine similarity is an angle between two points in a space built to capture relatedness, and relatedness is not assertion. A draggable demo of the geometry, the real pgvector setup behind my research pipeline, and the case where I chose no embeddings at all.
Week 3 of going deeper on the fundamentals behind my own systems: embeddings and vector search. I use them daily, my research pipeline runs on them, and until this week my working definition of cosine similarity was "how similar two texts are," which is the kind of definition that works right up until it costs you.
What an embedding is, mechanically
An embedding model maps a piece of text to a list of numbers, a point in a high-dimensional space. The one my pipeline uses, OpenAI's text-embedding-3-small, produces 1,536 of them. The training objective, going back to word2vec (Mikolov et al., 2013), is some variant of: texts that appear in similar contexts should land near each other. Sentence-BERT (Reimers & Gurevych, 2019) extended the trick from words to whole sentences, which is the version everyone's retrieval stack now depends on.
The famous word2vec result, king minus man plus woman lands near queen, tells you what the space encodes: relationships of usage. Words and sentences that show up in the same kinds of places. Hold onto that, because it's also the limitation.
Cosine is an angle, and only an angle
Cosine similarity between two vectors is the cosine of the angle between them. Nothing else. Two vectors pointing the same direction score 1.0 whether one is three times longer than the other. This is what pgvector's <=> cosine operator ranks by when my search functions run.
You can build the intuition faster by dragging it than by reading about it:
Cosine similarity
1.00
The angle between the vectors, nothing else. This is what pgvector's <=> cosine operator ranks by.
Dot product
7.32
Angle times both magnitudes. Grows when either vector gets longer.
Euclidean distance
3.12
Straight-line gap between the tips. On unit-normalized vectors (the dashed circle) it ranks the same as cosine.
Cosine says identical (1.00). Euclidean distance says far apart. Cosine can't see magnitude.
Two things in that demo earn their place in this essay. First, the "same direction, different length" preset: cosine 1.00 while the euclidean distance is large. Cosine deliberately throws magnitude away, which for text embeddings is mostly what you want, since vector length correlates with things like text length rather than meaning. Second, the dashed unit circle: normalize both vectors onto it and euclidean distance ranks identically to cosine (for unit vectors, squared distance is exactly 2 minus 2 times the cosine). So when you're choosing a distance metric for normalized embeddings, you're mostly choosing a notation.
The queries it quietly fails
Here's where the week got useful. The space is built to capture relatedness, and the failure cases are all sentences that are maximally related while asserting different things.
"Smoking causes cancer" and "cancer causes smoking." "The intervention produced excellent outcomes" and "the intervention produced terrible outcomes." Same vocabulary, same topic, same register, same kinds of contexts in training data. Everything the embedding objective rewards says: near each other. The meaning that separates them, direction and polarity, is exactly the part the geometry isn't organized around.
I didn't learn this from a paper. I learned it from paying adversaries to break my claim verifier, where those two attacks, the antonym swap and the argument-order swap, defeated two rounds of lexical grounding checks. The fix in that system took different machinery entirely: bigram coverage for near-verbatim cases, an entailment model for everything else, because entailment is trained on the assertion question and similarity isn't.
The practical rule I took away: vector search answers "what in my corpus is about this," and it's good at it. The moment your question is "what in my corpus supports this," similarity is the wrong instrument, and it fails in the worst direction, toward agreement, because a contradicting passage is usually about exactly the same thing.
One corpus, embedded two ways
The build for this week came straight from my own schema. NoeticMap embeds the same research corpus at two granularities: each paper gets an embedding of its title and abstract, and each extracted finding gets its own embedding of the specific claim. Both live in Postgres with pgvector, HNSW indexes, cosine ops, and near-identical search functions.
Run the same query against both tables and the retrievals disagree in an instructive way. Paper-level search returns papers whose overall topic matches, which is right for "what's been studied here." Finding-level search returns individual claims, which is right for "what specifically did studies conclude," and it surfaces papers whose abstracts would never have matched, because the relevant claim was one sentence in a paper mostly about something else. Neither is the better embedding. They're different retrieval units, and the choice of unit shaped the results more than any model parameter I could have tuned. (That thread continues next week, because the retrieval unit question is the chunking question.)
The other production detail worth naming: every search function takes a match_threshold on the cosine score, below which results don't return. That number is honest work. There's no universal "0.8 means similar." The distribution of scores depends on the embedding model and on your corpus, so the threshold has to be picked by looking at real queries against real data, and re-picked if the model changes.
The case where the right number of embeddings was zero
The counterweight, from the same monorepo. My fitness app needs exercise substitution: you can't do barbell squats today, what's the closest alternative with what's available? Textbook similarity problem, and I built it with no embeddings at all. The engine scores overlap on catalog fields that already exist on every exercise: primary muscles weighted heaviest, then whether the movement is compound or isolation, force direction, secondary muscles, with a hard floor requiring at least one shared primary muscle.
The reason is in the file header comment I wrote at the time: the swap reason has to be human-readable, because the chat UX explains it. "Same primary muscles, also a compound push" is a sentence a user can check. "Cosine similarity 0.87" isn't. Embeddings buy you recall over messy, unstructured input. When the input is already structured and the output has to be explainable, deterministic overlap wins, and it never surprises you at 2 a.m.
Cosine similarity tells you two texts live in the same neighborhood of a learned space. Most days that's the question I'm asking. The discipline is noticing the days it isn't.