Strange geometry in high dimensions
Near-perpendicular random directions, bunched-up distances, and the curse of dimensionality.
Create a free account Try part of it here
Why it matters
Embeddings have hundreds or thousands of numbers each. Your intuition for space comes from three dimensions, and in 1,536 it misleads you badly. Up there, two random directions are almost always nearly perpendicular, the distances between random points bunch together, and nearly all of a ball's volume sits just under its surface. These facts explain why cosine similarity works for embeddings, why nearest-neighbor search gets harder as data gets wider, and why reducing dimensions helps. This lesson lets you see each one by running small experiments.
What you'll be able to do
- Show by experiment that random directions in many dimensions are nearly perpendicular
- Explain why distances between random points bunch together as dimensions grow
- Say why embeddings are compared by cosine similarity rather than raw distance
- Describe the curse of dimensionality and what reducing dimensions buys you
What it covers
- Random directions are nearly perpendicular
- Most of the volume is near the surface
- Distances bunch together
- Measure the bunching
- Why embeddings use cosine similarity
- The curse of dimensionality
- How thin does the data get?
- High dimensions in AI
Show the answer
Close to 0: nearly perpendicular
In high dimensions, random directions are almost always nearly perpendicular, with cosines bunched tightly around 0. It's one of several facts about high dimensions that surprise everyone the first time.
Random directions are nearly perpendicular
The cosine similarity of two vectors, from the dot product lesson, is their dot product divided by both lengths. It's 1 for the same direction, 0 for perpendicular and −1 for opposite. Here's an experiment: draw random vectors and measure the cosine between pairs, first in 2 dimensions, then in 1,000.
import random random.seed(0) def cosine(a, b): dot = sum(x * y for x, y in zip(a, b)) return dot / (sum(x * x for x in a) ** 0.5 * sum(y * y for y in b) ** 0.5) for d in [2, 1000]: cos = [cosine([random.gauss(0, 1) for _ in range(d)], [random.gauss(0, 1) for _ in range(d)]) for _ in range(5)] print(d, [round(c, 2) for c in cos])
In 2 dimensions the cosines land anywhere from −1 to 1. In 1,000 they all sit within a few hundredths of 0. A rough rule: the typical cosine between random directions is about 1 / √d, so about 0.03 in 1,000 dimensions. (Drawing every coordinate from a normal distribution, with random.gauss, makes every direction equally likely, which is what "random direction" should mean.)
a = [1, 0, 0] b = [0, 1, 0] dot = sum(x * y for x, y in zip(a, b)) print(dot / ((sum(x * x for x in a) ** 0.5) * (sum(y * y for y in b) ** 0.5)))
Show the answer
0.0
The dot product is 1×0 + 0×1 + 0×0 = 0, so the cosine is 0: the vectors are perpendicular. Random high-dimensional vectors come close to this without being exactly on the axes.
Most of the volume is near the surface
Picture an orange: in 3 dimensions, the peel is a small part of the fruit. Now count how much of a ball's volume lies in its outer shell. A ball's volume grows like its radius to the power of the dimension, so the inner part, at 95% of the radius, holds 0.95ᵈ of the volume. In 3 dimensions that's 86%, so the outer 5% shell holds only 14%. In 100 dimensions, the inner part holds 0.95¹⁰⁰, under 1%, and the thin shell holds nearly everything.
The same holds for a cube: in many dimensions, almost all of it is in the corners and near the faces, and almost none is near the middle. It sounds like a curiosity, but it has a practical side: in high dimensions, random points almost all sit about the same distance from the center, and, as the next section shows, about the same distance from each other.
The share of a ball's volume in its outer shell: 1 − (1 − thickness)ᵈ. Push the dimensions up and watch the shell swallow the ball.
print(round(0.95 ** 3, 3))
Show the answer
0.857
Volume grows like the radius cubed in 3 dimensions, so the inner 95% of the radius holds 0.95³ ≈ 0.857 of the volume. The outer shell holds the other 14%.
Distances bunch together
Take a query point and a crowd of random points, and measure the distance to each. In 2 dimensions, some points are much closer than others. In 500 dimensions, the nearest and the farthest are almost the same distance away. A useful measure is the relative contrast: (farthest − nearest) ÷ nearest.
import random random.seed(1) def dist(a, b): return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5 for d in [2, 500]: query = [random.random() for _ in range(d)] ds = [dist(query, [random.random() for _ in range(d)]) for _ in range(100)] print(d, round((max(ds) - min(ds)) / min(ds), 2))
Real data is less extreme than these random points, because real features are correlated and the data lies close to a much smaller surface inside the space. That's exactly why embeddings and PCA work at all. Still, when everything is roughly equally far away, "the nearest neighbor" stops meaning much. This is a big part of the curse of dimensionality, and it's why search over raw high-dimensional data needs care.
Show the answer
It's barely closer than any other point
If the nearest point is only slightly closer than the farthest, being "nearest" says little about real similarity. That's why search over raw high-dimensional data needs better measures or fewer dimensions.
Measure the bunching
Write contrast(d): with random.seed(2), make one random query point and 200 random points in d dimensions (each coordinate random.random()), and return (farthest − nearest) ÷ nearest. Then set low_d = contrast(2) and high_d = contrast(300).
import random def dist(a, b): return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5 def contrast(d): random.seed(2) # a query point, 200 random points, and (farthest - nearest) / nearest return 0 low_d = contrast(2) high_d = contrast(300) print(round(low_d, 2), round(high_d, 2))
Show the answer
Somewhere in between the two
The contrast shrinks steadily as dimensions are added, so 30 dimensions lands between the two. Try contrast(30) to see it.
Why embeddings use cosine similarity
An embedding's direction carries its meaning; its length often reflects things like how long or common a text is. Comparing directions with cosine similarity ignores those lengths. And because random directions in high dimensions sit near cosine 0, any noticeably positive cosine stands out: in 1,536 dimensions, a cosine of 0.3 or 0.4 is far above what chance produces, even though it would look small on a 2-D diagram.
That's also why the absolute numbers differ between embedding models. Each model spreads its texts differently, so "similar" might mean 0.8 in one model and 0.4 in another. Judge cosines by comparing them with each other, or with a few known pairs, rather than against a fixed cutoff. Many vector databases also scale every embedding to length 1 before storing it; then the plain dot product equals the cosine, which is cheaper to compute.
Show the answer
It's notable: random directions there sit near 0
Random pairs in that many dimensions have cosines within a few hundredths of 0, so 0.42 is far above chance. Compare it with other pairs from the same model to judge how similar the tickets are.
The curse of dimensionality
Each new feature multiplies the amount of space data has to fill. Cut every feature into 10 bins: 1 feature makes 10 cells, 2 make 100, and 6 make a million. A million data points would put 100,000 in each cell of a 1-feature grid but about 1 in each cell of a 6-feature grid. In high dimensions, data is always sparse: most of the space is empty, and every point is far from every other.
That's the curse of dimensionality: methods that rely on nearby examples, like k-nearest neighbors, need far more data as features grow; models can find patterns in the emptiness that aren't real; and pictures are impossible. Dimensionality reduction, like PCA, helps by keeping the few directions where the data actually varies. Collecting more data helps less than you'd hope: each extra feature would need about ten times as much to keep the grid as full.
How thin does the data get?
You have a million data points, and you cut every feature into 10 bins. Set per_cell to a list with the average number of points per cell for 1, 2, 3 and 6 features (a million divided by 10 to the power of the number of features).
points = 1_000_000 features = [1, 2, 3, 6] per_cell = [] print(per_cell)
High dimensions in AI
Embedding models produce vectors with hundreds or thousands of numbers, and vector databases find neighbors among millions of them with approximate methods, because exact search through a sparse, high-dimensional space is slow. A surprising result, the Johnson-Lindenstrauss lemma, says you can project high-dimensional points onto far fewer random directions and still keep their distances nearly intact, which is why random projections and compressed embeddings work at all. And inside a transformer, attention scores are divided by the square root of the dimension, because dot products of high-dimensional vectors grow with the dimension and would otherwise be too large.
a = [2, 0] b = [5, 0] dot = sum(x * y for x, y in zip(a, b)) print(dot / ((sum(x * x for x in a) ** 0.5) * (sum(y * y for y in b) ** 0.5)))
Show the answer
1.0
Cosine similarity ignores length: 10 / (2 × 5) = 1.0, because the vectors point the same way. That's why it suits embeddings, whose directions carry the meaning.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 6-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Two random directions in 1,000 dimensions typically have a cosine of about what?