Skip to content
Software Atlas

Embeddings

How an embedding model turns text into a list of numbers so that similar meanings point in similar directions: vectors, dot products and cosine similarity, what embeddings capture and what they miss, and how to store and search them in production.

⏱ ~35 min read13 sections6 interactive widgetsGo · JavaScriptReal all-MiniLM-L6-v2 data
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceAn embedding model turns a piece of text into a fixed-length list of numbers. It is trained so that texts with similar meanings get lists that point in similar directions.

That list of numbers is called an Embedding. Once text is numbers, a computer can measure "how close in meaning are these two sentences?" with simple arithmetic. No exact words need to match.

Here is the idea with only two numbers per word. These numbers are made up by hand to show the shape of the idea; real models produce hundreds of numbers, and we'll use real ones from §05 on.

A toy embedding space (hand-made, 2 numbers per word)
catkittendograinstormpizza
Toy numbers written by hand, not from a model. cat = [0.9, 0.35] and kitten = [0.8, 0.5] point almost the same way; pizza = [0.35, −0.9] points at about a right angle to cat: unrelated. (Unrelated means a right angle, not opposite; fully opposite arrows are rare with real text, §03.) With only 2 numbers there's no room to put three unrelated topics at right angles to each other, so here rain and pizza end up nearly opposite. Real models use hundreds of numbers, which leaves every topic room for its own right angle; that's why real unrelated sentences score near 0 (§05).

The words about animals point one way, the weather words another, food a third. To ask "is kitten related to cat?", you compare the directions of their arrows. The standard score for that is Cosine similarity (§03).

What you can build with that

🔎

Search by meaning

“I can't log in” finds “Trouble signing in to my account” even though they share no important words.
🧹

Find duplicates

Two bug reports written differently but describing the same crash get almost the same vector.
🏷️

Group and classify

Put a new support ticket in the category of its closest labelled examples.

The catch, which this page shows with real data: embeddings capture the topic of a text much better than its details. "I love this phone" and "I don't love this phone" get very similar vectors (§07).

Foundations§02

Vectors: lists of numbers you can draw as arrows

A Vector is just an ordered list of numbers, like [3, 1]. In code it's an array: []float32{3, 1} in Go, [3, 1] in JavaScript. The number of entries is its Dimension: [3, 1] has 2 dimensions, and a real embedding from the model on this page has 384.

Drawing a vector

With two numbers you can draw a vector as an arrow from the origin (the point 0, 0): go 3 to the right and 1 up, and put the arrow tip there.

Two vectors as arrows
a = [3, 1]b = [2, 2]angle
a = [3, 1] and b = [2, 2]. Each arrow starts at the origin. The angle between them is the thing embeddings care about.

With 384 numbers you can't draw the arrow any more, but every formula below works the same way, just with a longer loop. That's the nice part: you can build intuition in 2-D and trust it in 384-D.

Length

The length of a vector is how long its arrow is. It's Pythagoras: square every number, add them up, take the square root.

  • |a| = √(3² + 1²) = √10 ≈ 3.16
  • |b| = √(2² + 2²) = √8 ≈ 2.83

The bars |a| are just notation for "the length of a".

The dot product

The Dot product of two vectors: multiply the matching numbers, then add the results.

a · b = 3×2 + 1×2 = 6 + 2 = 8

That one number mixes two things together: how much the arrows point the same way, and how long they are. Double the length of b to [4, 4] and the dot product doubles to 16, even though the direction hasn't changed. The next section separates the two.

Foundations§03

Cosine similarity: comparing directions

In one sentenceCosine similarity = dot product ÷ (length × length). Dividing by the lengths throws length away, so the score measures only the angle between the arrows.
cosine(a, b) = (a · b) / (|a| × |b|)

Worked example

Using the numbers from §02, a = [3, 1] and b = [2, 2]:

  1. Dot product: a · b = 3×2 + 1×2 = 8
  2. Lengths: |a| = √10 ≈ 3.162 and |b| = √8 ≈ 2.828
  3. Multiply the lengths: 3.162 × 2.828 ≈ 8.944
  4. Divide: 8 ÷ 8.944 ≈ 0.894

So the Cosine similarity is about 0.894. (It's called cosine because the result is exactly the cosine of the angle between the arrows, here about 27°. You don't need trigonometry to use it; the formula does the work.)

Now double b to [4, 4]. The dot product doubles to 16, |b| doubles to 5.657, and the cosine is 16 ÷ (3.162 × 5.657) ≈ 0.894 again. Same direction, same score.

What the numbers mean

CosineAngleMeaning for embeddings
10°Same direction: as similar as the model can express
around 0.7about 45°Clearly related
090°Unrelated
−1180°Opposite direction (rare with real text embeddings)

Treat the middle rows as rough. What counts as a "high" score depends on the model, and §07 shows real scores where the best match is only 0.49.

Drag the arrows and watch every step of the formula update:

Cosine dial
Toy 2-D vectors (hand-made). Drag the tip of a or b. Watch the angle, the dot product and the cosine.
ab
grid: one square = 1 unit · snaps to 0.5
a = [3, 1] b = [2, 2]
dot product a · b
3×2 + 1×2 = 8
lengths
|a| = √(3² + 1²) = 3.16
|b| = √(2² + 2²) = 2.83
cosine = a · b ÷ (|a| × |b|)
8 ÷ (3.16 × 2.83) = 0.894
angle ≈ 27° → similar direction
Try same direction, longer: the dot product grows to 6 but the cosine stays exactly 1. Cosine ignores length and only measures direction.

Why ignore length?

For text embeddings, the length of a vector often reflects things that have nothing to do with the topic, and many models (including the one on this page) throw it away by normalizing to length 1. You want "Is this paragraph about password resets?" to get the same answer for a short and a long paragraph. Comparing directions does that.

Practitioner§04

Normalize once, then it's just a dot product

Computing cosine means three running sums per comparison: the dot product and two lengths. When you compare one query with a million stored vectors, the stored vectors' lengths get recomputed a million times, even though they never change. There's a simple trick to skip it.

Normalizing: make every length 1

To normalize a vector, divide each number by the vector's length. The direction stays the same and the length becomes exactly 1.

a = [3, 1],  |a| = √10 ≈ 3.162
a ÷ |a| = [3/3.162, 1/3.162] ≈ [0.949, 0.316]
check: 0.949² + 0.316² ≈ 0.900 + 0.100 = 1.000 ✓

Do the same for b = [2, 2]: b ÷ 2.828 ≈ [0.707, 0.707].

Now the cosine formula has 1 × 1 underneath, so cosine = dot product:

[0.949, 0.316] · [0.707, 0.707] = 0.671 + 0.224 = 0.895

That's the 0.894 from §03 (the last digit differs only because we rounded to three decimals along the way). Normalize every vector once when you store it, normalize the query once, and from then on cosine is a single multiply-and-add loop. Many embedding models and APIs already return vectors of length 1; check the documentation or measure one (§05 does).

Cosine vs Euclidean distance

The other common way to compare vectors is the Euclidean distance: the straight-line distance between the two arrow tips. For unit vectors, the two are tied together by one equation:

distance² = 2 − 2 × cosine

Check it with our unit vectors: the difference is [0.949 − 0.707, 0.316 − 0.707] = [0.242, −0.391], so distance² = 0.0586 + 0.1529 ≈ 0.211. And 2 − 2 × 0.895 = 0.210. ✓

Why they agree for unit vectors
abca–b: small angle,short distancea–c: big angle,long distance
Every unit vector's tip lies on the circle of radius 1. The smaller the angle between two arrows, the closer their tips: a bigger cosine always means a shorter distance.

So for normalized vectors, sorting by "highest cosine" and sorting by "smallest Euclidean distance" give the same ranking. That's why a Vector database may offer cosine, dot product and Euclidean options: with unit vectors, any of them returns the same top results. They only disagree when the vectors have different lengths, and then the dot product starts rewarding long vectors instead of matching directions.

#1

Using raw dot product on vectors that aren't normalized

If a model returns vectors with varying lengths and you rank by plain dot product, longer vectors win more often regardless of meaning. Either normalize first, or use cosine. Measuring Math.hypot(...v) for a few vectors takes one line and tells you which case you're in.

Foundations§05

Real embeddings: 384 numbers per sentence

Time to leave the toy behind. Every number from here on comes from a real embedding model, all-MiniLM-L6-v2: a small, free, widely used model that turns English text into 384 numbers. We ran it offline while building this page and shipped the results with it (each widget has a "How this data was made" note). Nothing on this page calls a live model.

Three real embeddings (all-MiniLM-L6-v2, 384 numbers each)
“The cat is sleeping on the sofa.”
[ 0.135, −0.073, −0.028, 0.013, 0.021, 0.030, −0.066, 0.000, … 376 more] length = 1.000
“A kitten naps on the couch.”
[ 0.058, −0.013, −0.046, 0.074, 0.030, 0.032, −0.027, −0.032, … 376 more] length = 1.000
“It is raining heavily outside.”
[−0.007, −0.025, 0.093, 0.029, 0.067, −0.047, 0.071, −0.002, … 376 more] length = 1.000
−0.12 or less +0.12 or morepoint at (or drag along) a stripe to read its number
Each stripe is one of the 384 numbers. No single number means “cat” or “rain”; meaning is spread across all of them, which is why you compare whole vectors with cosine rather than reading positions. Real data: the same model and run as every widget on this page (see “How this data was made” under the map below).

Three things to notice:

  • Every sentence gets exactly 384 numbers, whether it has 3 words or 30. That fixed size is what makes comparison possible.
  • The length is 1.000. This model's vectors are normalized (§04), so cosine similarity is a plain dot product.
  • You can't read meaning from single positions. The numbers are small, positive and negative, and spread out. Meaning lives in the direction of the whole vector.

How does a sentence become one vector?

A model like this first produces one vector per token, and each token's vector depends on the words around it. To get one vector for the whole sentence, this model simply averages them, a step called Mean pooling, and then normalizes the result.

From a sentence to one vector (real numbers; first 3 of 384 shown)
1 · tokenize
[CLS]thecatissleepingonthesofa.[SEP]
2 · the model gives every token its own vector (it reads the whole sentence, so “cat” here depends on its neighbors)
[CLS][0.62, −0.10, −0.24, …]
the[0.40, −0.18, −0.10, …]
cat[1.09, −0.71, −0.19, …]
is[0.92, −0.51, −0.01, …]
sleeping[1.48, −0.76, −0.58, …]
on[0.74, −0.21, 0.04, …]
the[0.31, −0.17, −0.10, …]
sofa[1.29, −1.06, −0.18, …]
.[0.48, −0.24, −0.13, …]
[SEP][0.38, −0.19, −0.09, …]
3 · average them, number by number (mean pooling)
[0.77, −0.41, −0.16, …] length 5.69
4 · normalize to length 1 → the embedding
[0.14, −0.07, −0.03, …] length 1.00
Real data from the same run: these per-token vectors come straight out of all-MiniLM-L6-v2 for this sentence embedded on its own, and averaging plus normalizing them reproduces the model's sentence embedding exactly. [CLS] and [SEP] are marker tokens this model adds at the start and end.

(Other models use different final steps, but the output is always one fixed-length vector per input. How the per-token vectors are computed is covered in How LLMs Work.)

A map of 66 real sentences

Below are 66 short sentences across six themes, plus deliberate traps: pairs that differ only by a negation, a number, a name or word order. To draw 384-dimensional points on a flat screen we squash them to 2-D with PCA. The flat picture keeps only a small part of the information, so the widget always lists the true nearest neighbors, computed from all 384 numbers.

Embedding map
66 real sentences. Click a dot (or pick from the list) to see its true 5 nearest neighbors by cosine similarity.
2-D PCA of real 384-D all-MiniLM-L6-v2 embeddings. These two axes keep only about 16% of the spread between the theme sentences, so distances in 2-D are only approximate. The dashed lines are the true neighbors.
“How do I reset my password?”
group: account help
True top 5 (cosine, 384-D)
Of the 5 dots closest in the picture, 4 of 5 are true top-5 neighbors.
How this data was made
Real data. Each sentence was embedded with all-MiniLM-L6-v2 (384 dimensions; the 8-bit quantized ONNX build published by Xenova), run offline in Node.js with transformers.js, one sentence at a time, mean pooling, normalized to length 1. Cosine similarities were computed on the full 384-number vectors and rounded to 3 decimals. No API was called while you read this page; the numbers ship with it. The generator script is src/topics/embeddings/data-gen.mjs.txt. Other models give different numbers. The 2-D positions come from a PCA computed in the same script (power iteration, a standard way to compute PCA, on the centred 384-D vectors). The two axes were fitted on the 46 theme sentences, so the four near-identical phone sentences don't take over a whole axis, and then every sentence was projected onto them; they explain 15.9% of the variance of the theme sentences. The sentences were written for this page: six everyday themes plus “trap” pairs that differ only by a negation, a number, a name or word order.

Things to try:

  • It starts on How do I reset my password? All five true neighbors are account-help sentences, scoring 0.29 to 0.57.
  • Click The cat is sleeping on the sofa. The kitten (0.687) and the rescue cat (0.283) come first. After that the scores drop to 0.14 and below, and the "neighbors" are a password question and a restaurant. A top-5 list always has 5 entries, even when only 2 are relevant, so look at the scores, not just the ranks.
  • Turn on the traps filter and click I don't love this phone. Its neighbors are "I hate this phone" and "I love this phone", both scoring above 0.8.
  • Under the list, the widget counts how many of the 5 dots that look closest are real top-5 neighbors. Click around: for about two thirds of the sentences it's 2 or fewer.
Practitioner§06

Cosine similarity and search in Go and JavaScript

All of this is a few lines of code. The examples below run on real vectors: we saved three all-MiniLM-L6-v2 embeddings from §05 to a file called vectors.json, each as { "text": …, "vector": [384 numbers] }. (Go syntax is explained in the Go primer.)

Cosine similarity

This version doesn't assume the vectors are normalized, so it works for any model.

cosine.go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"math"
	"os"
)

type Item struct {
	Text   string    `json:"text"`
	Vector []float32 `json:"vector"` // 384 float32s = 1,536 bytes
}

// cosine returns dot(a, b) / (|a| * |b|).
func cosine(a, b []float32) float64 {
	var dot, na, nb float64
	for i := range a {
		x, y := float64(a[i]), float64(b[i])
		dot += x * y // ← multiply matching numbers, add them up
		na += x * x
		nb += y * y
	}
	return dot / (math.Sqrt(na) * math.Sqrt(nb))
}

func main() {
	raw, err := os.ReadFile("vectors.json")
	if err != nil {
		log.Fatal(err)
	}
	var items []Item
	if err := json.Unmarshal(raw, &items); err != nil {
		log.Fatal(err)
	}
	cat, kitten, rain := items[0], items[1], items[2]
	fmt.Println(len(cat.Vector), "numbers per vector")
	fmt.Printf("%.3f %s ↔ %s\n", cosine(cat.Vector, kitten.Vector), cat.Text, kitten.Text)
	fmt.Printf("%.3f %s ↔ %s\n", cosine(cat.Vector, rain.Vector), cat.Text, rain.Text)
}

Real output (Go 1.24 and Node 22 give the same):

384 numbers per vector
0.687 The cat is sleeping on the sofa. ↔ A kitten naps on the couch.
-0.166 The cat is sleeping on the sofa. ↔ It is raining heavily outside.

The cat and kitten sentences share almost no words and score 0.687. The rain sentence scores slightly below zero: unrelated.

Searching: compare the query with everything

Semantic search is "embed the query, score every stored vector, keep the best k". With normalized vectors, the score is just the dot product (§04). This time the JavaScript version also creates the embeddings, using the same model locally through the transformers.js library; the Go version reads the vectors that the JS script saved.

search.go
package main

import (
	"cmp"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"slices"
)

type Doc struct {
	Text   string    `json:"text"`
	Vector []float32 `json:"vector"` // already normalized to length 1
}

type Hit struct {
	Text  string
	Score float64
}

// dot of two unit-length vectors = their cosine similarity
func dot(a, b []float32) float64 {
	var s float64 // add up in float64, as in cosine.go
	for i := range a {
		s += float64(a[i]) * float64(b[i])
	}
	return s
}

// topK compares the query with every doc (brute force) and keeps the k best.
func topK(query []float32, docs []Doc, k int) []Hit {
	hits := make([]Hit, 0, len(docs))
	for _, d := range docs {
		hits = append(hits, Hit{d.Text, dot(query, d.Vector)})
	}
	slices.SortFunc(hits, func(a, b Hit) int {
		return cmp.Compare(b.Score, a.Score) // b before a → highest score first
	})
	return hits[:min(k, len(hits))]
}

func main() {
	raw, err := os.ReadFile("search.json") // vectors saved by the JS script, same model
	if err != nil {
		log.Fatal(err)
	}
	var in struct {
		Query []float32 `json:"query"`
		Docs  []Doc     `json:"docs"`
	}
	if err := json.Unmarshal(raw, &in); err != nil {
		log.Fatal(err)
	}
	for _, h := range topK(in.Query, in.Docs, 3) {
		fmt.Printf("%.3f %s\n", h.Score, h.Text)
	}
}

Real output (both). The widgets on this page show 0.535 for the first pair, not 0.530: they embedded every sentence on its own, while this script embeds the five documents as one batch. §10 explains why batching moves the third decimal.

0.530 How do I reset my password?
0.415 How do I delete my account?
0.356 My verification code never arrives.

Comparing the query with every stored vector is called brute-force or exact search. It's perfectly fine for thousands of vectors. At 100,000 vectors it's 100,000 × 384 ≈ 38 million multiply-adds per query. Simple loops like the ones above take roughly 50–150 ms for that on one CPU core (machine-dependent; on our test machines Go was at the low end and Node somewhat slower): fine for a batch job, slow for every keystroke of a search box. For millions of vectors you'll want an Approximate nearest neighbor (ANN) search index; that's the next topic, Vector Databases.

Where vectors come from in production

There are two options. Run a model yourself, as the JS example does (on a server, or even in the browser). Or call a hosted embeddings API, which is usually how Go services do it. Here is the shape of one such API, OpenAI's embeddings endpoint, as of October 2026:

embed_api.go
// Not run (needs an API key). OpenAI embeddings endpoint, as of October 2026.
func embedTexts(ctx context.Context, texts []string) ([][]float32, error) {
	body, err := json.Marshal(map[string]any{
		"model": "text-embedding-3-small",
		"input": texts, // several texts in one request
	})
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/embeddings", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY")) // ← key from the environment, never in code
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("embeddings API: %s", resp.Status)
	}
	var out struct {
		Data []struct {
			Index     int       `json:"index"`
			Embedding []float32 `json:"embedding"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	vecs := make([][]float32, len(texts))
	for _, d := range out.Data {
		vecs[d.Index] = d.Embedding // ← index says which input this vector belongs to
	}
	return vecs, nil
}

Other providers (and self-hosted model servers) have similar shapes: send a batch of texts, get back one vector per text. Whatever you use, keep the API key on the server, send texts in batches, and plan for rate limits and retries when you embed a large corpus.

Practitioner§07

What embeddings capture, and what they miss

In one sentenceAn embedding captures what a text is about far better than what exactly it says. Topic: excellent. Negation, numbers, IDs and word order barely move the vector; names are unpredictable. Either way, match those exactly.

That's not a bug in one model. An embedding squeezes a whole sentence into one fixed-size vector, and models are trained mostly to put texts on the same topic close together. Small words that flip the meaning ("not") and tokens that change one detail ("#48213" vs "#91577") are a small part of that average, so they move the vector only a little. Names are the odd one out: swapping one person's name for another can move the vector a lot (the explorer's last query) or hardly at all, and you can't tell in advance.

Explore it with real scores. Queries in italics show a failure or a trap.

Similarity explorer
Pick a query. Every stored sentence is ranked by its real cosine similarity to it.
A paraphrase that shares no words with “A kitten naps on the couch.” It still ranks it first. Notice the best score is only about 0.49: a good match does not need to be near 1.
query: “my pet is asleep”cosine
  1. 1A kitten naps on the couch. · pets0.493
  2. 2The cat is sleeping on the sofa. · pets0.382
  3. 3The vet said my dog needs more exercise. · pets0.361
  4. 4We adopted a rescue cat last week. · pets0.316
  5. 5My dog loves chasing tennis balls. · pets0.261
  6. 6The restaurant is closed on Sundays. · trap: negation0.223
  7. 7The puppy fetched the stick from the lake. · pets0.218
  8. 8Whisk the eggs with a pinch of salt. · cooking0.216
Classify by nearest examples: the top 3 vote pets (3/3).
How this data was made
Real data. Each sentence was embedded with all-MiniLM-L6-v2 (384 dimensions; the 8-bit quantized ONNX build published by Xenova), run offline in Node.js with transformers.js, one sentence at a time, mean pooling, normalized to length 1. Cosine similarities were computed on the full 384-number vectors and rounded to 3 decimals. No API was called while you read this page; the numbers ship with it. The generator script is src/topics/embeddings/data-gen.mjs.txt. Other models give different numbers. Queries that aren't in the list of 66 sentences were embedded the same way in the same script. Bars are drawn from 0 to 1; a negative score gets no bar.

What the real numbers show

Pair (real all-MiniLM-L6-v2 scores)CosineSame meaning?
I can't log in / I can't sign in to my account.0.841✅ yes
The cat is sleeping on the sofa. / A kitten naps on the couch.0.687✅ yes
The restaurant is open on Sundays. / … is closed on Sundays.0.906❌ opposite
I love this phone. / I don't love this phone.0.847❌ opposite
Take 1 tablet per day. / Take 10 tablets per day.0.903❌ dangerous
Your order #48213 has shipped. / Your order #91577 has shipped.0.836❌ different order
Alice sent money to Bob. / Bob sent money to Alice.0.975❌ reversed

Every wrong pair scores higher than cat ↔ kitten, and all but one also beat "can't log in ↔ can't sign in". No threshold can separate them: a cutoff at 0.8 would keep the opposites and throw away "cat ↔ kitten".

Pick a similarity cutoff
Treat pairs above the cutoff as “same meaning”. Try to keep every ✓ pair and drop every ✗ pair.
  1. ✗0.975Alice sent money to Bob. ↔ Bob sent money to Alice.kept (wrong)
  2. ✗0.906The restaurant is open on Sundays. ↔ The restaurant is closed on Sundays.kept (wrong)
  3. ✗0.903Take 1 tablet per day. ↔ Take 10 tablets per day.kept (wrong)
  4. ✗0.847I love this phone. ↔ I don't love this phone.kept (wrong)
  5. ✓0.841I can't log in ↔ I can't sign in to my account.kept ✓
  6. ✗0.836Your order #48213 has shipped. ↔ Your order #91577 has shipped.kept (wrong)
  7. ✗0.820The meeting starts at 9 am. ↔ The meeting starts at 3 pm.kept (wrong)
  8. ✗0.747The tests passed. ↔ The tests did not pass.dropped ✓
  9. ✓0.687The cat is sleeping on the sofa. ↔ A kitten naps on the couch.dropped (wrong)
  10. ✓0.530How do I reset my password? ↔ I forgot my login details.dropped (wrong)
  11. ✓0.493my pet is asleep ↔ A kitten naps on the couch.dropped (wrong)
Kept 1 of 4 same-meaning pairs and 6 of 7 different-meaning pairs. No cutoff gets both right: the scores of the two kinds overlap.
How this data was made
Real data. Each sentence was embedded with all-MiniLM-L6-v2 (384 dimensions; the 8-bit quantized ONNX build published by Xenova), run offline in Node.js with transformers.js, one sentence at a time, mean pooling, normalized to length 1. Cosine similarities were computed on the full 384-number vectors and rounded to 3 decimals. No API was called while you read this page; the numbers ship with it. The generator script is src/topics/embeddings/data-gen.mjs.txt. Other models give different numbers. ✓ and ✗ were assigned by hand: ✓ = the two texts mean the same thing, ✗ = they differ in a way that matters.
Pairwise similarity heatmap
12 real sentences, every pair compared. Brighter = more similar. Tap a cell.
123456789101112
1 cat sofa
2 kitten couch
3 raining
4 storm
5 love phone
6 don't love
7 hate phone
8 order 48213
9 order 91577
10 Alice→Bob
11 Bob→Alice
12 reset pw
“I love this phone.” vs “I don't love this phone.” → 0.8470 1
Look for the bright squares off the diagonal: love / don't love / hate form one block, the two orders another, Alice→Bob and Bob→Alice a third. The cat and kitten pair is bright for the right reason. Same scoring, very different meaning.
How this data was made
Real data. Each sentence was embedded with all-MiniLM-L6-v2 (384 dimensions; the 8-bit quantized ONNX build published by Xenova), run offline in Node.js with transformers.js, one sentence at a time, mean pooling, normalized to length 1. Cosine similarities were computed on the full 384-number vectors and rounded to 3 decimals. No API was called while you read this page; the numbers ship with it. The generator script is src/topics/embeddings/data-gen.mjs.txt. Other models give different numbers.

Four lessons

  1. Scores are relative, not absolute. In this set, the typical (median) score between two unrelated sentences is 0.07, and a correct paraphrase can score 0.49. Another model will have a completely different spread. Compare candidates with each other, and if you need a cutoff, pick it by testing on your own examples.
  2. Exact things need exact matching. For order numbers, error codes, product SKUs and people's names, extract them and filter with a normal database query, or use Keyword (full-text) search. Combining keyword and vector search (hybrid search) is standard in Retrieval-Augmented Generation.
  3. Similar is not the same as true or relevant. Retrieval hands you the closest text, even when it says the opposite of what you need. Whatever reads the results next (a person, or an LLM) has to check the details.
  4. Short queries are vague. "it's pouring" carries almost no context, so a soup recipe nearly ties with the rain. Longer, more specific queries give a clearer direction.
Practitioner§08

One model per index: compatibility, dimensions and choice

In one sentenceEach model invents its own coordinate system. Comparing a vector from model A with a vector from model B is like comparing a GPS position with a street address digit by digit: meaningless.

Never mix models

Number #17 in one model's vector has nothing to do with number #17 in another's, even if both happen to have 384 numbers. We only have one model here, so we made a stand-in "second model" by rotating every MiniLM vector with the same random rotation. That keeps every distance within the second model the same, exactly like a different model that learned the same geometry in its own coordinates. Real measurements:

ComparisonCosine
cat sentence vs kitten sentence, both from MiniLM0.687
cat sentence vs kitten sentence, both from the "rotated model"0.687
the same cat sentence, MiniLM vs "rotated model"0.016

A real second model is even less compatible, because it also has a different geometry. The rules that follow from this:

  • Embed queries with the same model (and the same version) as the documents.
  • Store the model name next to every vector, e.g. a model column. Then mixing is a query you can check for, not a silent bug.
  • Changing models means re-embedding everything (§10 covers the cost).

Dimensions: bigger isn't automatically better

Models produce anything from a few hundred to a few thousand numbers per text. More dimensions can hold finer distinctions, but every vector costs more to store and to compare: a 1,536-number vector is 4× the size of a 384-number one and takes about 4× as long to score. Whether the extra numbers help your data is something you measure, not assume.

Some models are trained so that the first part of the vector works on its own, and some APIs let you ask for fewer dimensions. As of October 2026, for example, OpenAI's embeddings API accepts a dimensions parameter for its text-embedding-3 models and returns the shortened vector still normalized to length 1. Only shorten vectors this way when the model's documentation says it was trained for it; cutting an ordinary model's vector in half just throws information away.

Choosing a model: the questions that matter

This page deliberately doesn't rank models; rankings change every few months. Ask these instead:

QuestionWhy it matters
Which languages and domains?A model trained mostly on English web text may do poorly on Vietnamese support tickets or legal contracts.
What's the maximum input length?Text past the limit is silently cut off (§10). all-MiniLM-L6-v2 is meant for sentences and short paragraphs.
Does it want instructions or prefixes?Some models expect text like a "query:" prefix on searches. Their model card says so; skipping it lowers quality.
Hosted API or self-hosted?APIs mean no servers to run but per-request cost, rate limits and sending your text to a third party. Self-hosting means the reverse.
Dimensions and speed?Storage and search cost grow with dimensions and with corpus size (§11).

Then test two or three candidates on your own data: take 50–100 real queries, write down which documents should come back, and measure how often each model puts them in the top 5. That small table beats any public leaderboard for your use case. (Building that kind of test set is covered in Evaluating LLM Apps.)

Practitioner§09

What you build with embeddings

Almost every use of embeddings is one idea: close vectors = related items. What changes is what you do with "close".

One operation, five products
embed → find nearest
→ return the top 5 to the user: semantic search
→ flag if the nearest scores very high: deduplication
→ group items that are near each other: clustering
→ copy the label of the nearest examples: classification
→ show items near what the user liked: recommendations
Every box uses the same step: embed, then find the nearest vectors. Only the last step differs.

Semantic search

Embed every document (or every Chunk of a long document) once, store the vectors, embed each query, return the nearest. §06 showed the code. Pair it with keyword search for exact terms (§07). Feeding the results to an LLM so it can answer from them is RAG.

Deduplication

To find near-duplicate bug reports, product listings or support tickets, look for pairs with a very high cosine. In this page's data, "Error E1027 in the payment service." and "Error E4410 in the payment service." score 0.908. Are those duplicates? Same service, different error codes: probably not. So treat a high score as "a human (or a stricter check) should look at this pair", not as an automatic merge. Exact deduplication (same bytes → same hash → same item) is a different, simpler job.

Clustering

Clustering groups items without labels: "what are customers writing to us about this week?". Algorithms such as k-means take the vectors and return groups: pick k centre points, put each vector with its nearest centre, move each centre to the middle of its group, repeat. The output is only groups of numbers; a person (or an LLM) still has to read a few items from each group and name it.

Classification by nearest example

Need to route tickets into categories but don't have thousands of labelled examples? Embed a handful of labelled examples per category. For a new ticket, find its 3 nearest examples and take the most common label. This is called k-nearest-neighbors (kNN) classification. The similarity explorer in §07 does exactly this under each result list. Real example: "the app keeps crashing" has the server-crash sentence as its nearest neighbor, but its top 3 are code, account, code. The vote still picks code & bugs, 2 to 1. Adding examples fixes most mistakes, with no training run.

Recommendations

Embed item descriptions (articles, products, songs' tags). For a user, average the vectors of things they liked and show the nearest items they haven't seen. Simple, and good for the "cold start" problem: a brand-new item can be recommended from its description before anyone has clicked it.

Staff-level§10

Chunk length, truncation and re-embedding

In one sentenceOne vector is one direction. Put one topic in, and it points clearly at that topic. Put twenty topics in, and it points at their average: nothing in particular.

Dilution: real measurements

Here one sentence answers the query "How do I reset my password?". We embed it alone, then glued together with more and more unrelated office announcements, and measure the cosine each time.

Chunk dilution
One sentence answers the query. Add unrelated sentences to the same chunk and watch the real score fall.
query: “How do I reset my password?”
filler sentences:
0.00.20.40.60.801248163264
x: filler sentences added · y: cosine to the query · solid = answer first, dashed = the other position
cosine(query, chunk)62 tokens
0.515
The same answer is still in the chunk, but it is now 1 topic out of 5, so it is only a small part of the average.
the chunk
To reset your password, click 'Forgot password' on the sign-in page and follow the link we email you. Our office is closed on public holidays. The cafeteria serves lunch from noon. Parking spaces are assigned by the facilities team. The quarterly report is due at the end of the month.
How this data was made
Real data. Each sentence was embedded with all-MiniLM-L6-v2 (384 dimensions; the 8-bit quantized ONNX build published by Xenova), run offline in Node.js with transformers.js, one sentence at a time, mean pooling, normalized to length 1. Cosine similarities were computed on the full 384-number vectors and rounded to 3 decimals. No API was called while you read this page; the numbers ship with it. The generator script is src/topics/embeddings/data-gen.mjs.txt. Other models give different numbers. The chunk is the answer sentence joined with N filler sentences (the 8 filler sentences repeat when N > 8), embedded as one text. Token counts are from the model's own tokenizer. transformers.js cut this model's input at 512 tokens; the sentence-transformers library cuts the same model at 256 word pieces, so in that library the cut would come sooner.

The answer never leaves the chunk, yet the score falls from 0.788 (alone) to about 0.37 with 8 filler sentences and 0.17 with 32. Meanwhile, a short chunk that merely mentions passwords could easily score higher and win. That's why long documents are split into chunks of a paragraph or a few hundred tokens, each with its own vector. How to split well (by headings, with overlap, keeping tables whole) is a big part of RAG.

Silent truncation

Every model has a maximum input length in tokens, and text past it is usually cut off without an error (Truncation). Switch the widget to answer last and pick 64: the chunk is 602 tokens, the tool we used stops reading at 512, and the answer at the end simply disappears. The score drops to −0.003, exactly what the filler alone scores.

The limit also depends on the software, not just the model. The same all-MiniLM-L6-v2 is cut at 512 tokens by transformers.js (which reads the tokenizer's settings) and at 256 word pieces (this model's tokens) by the sentence-transformers library, which follows the model card. Check the limit in the library you actually run, and count tokens before embedding long text.

The same text doesn't always give the same vector

Embedding is deterministic for the same model build, the same software and the same input, but small changes leak in. The 8-bit build we used rounds its internal numbers per batch: "The cat is sleeping on the sofa." embedded alone vs inside a batch of 66 sentences gave vectors with a cosine of 0.992, not 1. That's enough to move scores: cat ↔ kitten scores 0.687 when each sentence is embedded alone (the way all data on this page was made) but 0.714 when both are embedded inside that batch. Close results can swap places. Different hardware, library versions or numeric precision cause similar drift. Two practical consequences:

  • Don't use embeddings as cache keys or equality checks. "Same text?" is a string comparison or a hash, not a cosine of 1.0.
  • In tests, compare scores with a tolerance (as you would for any Floating-point number value, see Numbers in Computers), and expect the third decimal to move.

Changing models costs a full re-embed

Because vectors from different models are incompatible (§08), a new model means re-embedding every stored text. Do the arithmetic before you switch. With 10 million chunks, sending 100 texts per request and running 20 requests at a time, at a hypothetical 0.5 s per request: 10,000,000 ÷ 100 = 100,000 requests; ÷ 20 in parallel = 5,000 rounds; × 0.5 s ≈ 42 minutes, if you never hit a rate limit. Real runs take longer: rate limits, retries, and the time to rebuild the index.

The safe migration looks like a database migration:

  1. Add a new vector column (or a new index) for model B. Keep serving from model A.
  2. Backfill model-B vectors in a background job; new writes get both.
  3. Compare search quality on your test queries (§08).
  4. Switch queries to model B, keep A for quick rollback, then drop it.
Staff-level§11

Storing vectors: bytes, quantization and bias

How big is a vector?

Each number is a 32-bit Floating-point number value: 4 bytes. (Why floats are approximate, and why float32 is enough here, is explained in Numbers in Computers.)

384 numbers × 4 bytes        = 1,536 bytes per vector  (1.5 KB)
1,536 bytes × 1,000,000 docs = 1.536 GB of raw vectors

That's before the index, the text itself and any metadata. A 1,536-dimension model needs 6 KB per vector and about 6 GB per million. Vector indexes are fastest when they fit in RAM, so this number decides how big a machine you need.

#2

Storing vectors as JSON text

One of our 384-number vectors written as JSON with full precision is 8,044 characters, more than 5× the 1,536 bytes of raw float32, and it has to be parsed on every read. Use your database's vector type (for example pgvector's vector(384) column in PostgreSQL) or a binary format.

Quantization: fewer bits per number

Quantization stores each number with fewer bits. Two common levels, measured on this page's 66 sentences (all 2,145 pairs):

FormatBytes per 384-d vectorAvg. cosine errorMax errorTrue top-5 neighbors kept
float32 (baseline)1,53600100%
int8: scale each vector to −127…1273840.00040.00299.7%
1 bit: keep only the sign480.0460.23772.1%

int8 is nearly free for this data: a quarter of the space, and the neighbor lists barely change. Keeping only the sign of each number is 32× smaller but loses more than a quarter of the true top-5 neighbors here. So 1-bit vectors are used as a fast first pass (find 100 candidates quickly), followed by re-scoring those candidates with the full vectors. Your data will give different numbers; measure before you commit. (The model we used is itself 8-bit quantized internally, which is a different trick: it shrinks the model's weights, not the stored vectors.)

Bias is part of the geometry

Embedding models learn from huge amounts of human-written text, so they also learn its stereotypes. Published research on older word embeddings found, for example, occupation words sitting closer to one gender than the other, and similar associations for names linked to different ethnic groups (Bolukbasi et al., 2016; Caliskan et al., Science, 2017). Newer sentence models have been shown to carry such associations too, to varying degrees. In a product this shows up quietly: a résumé search that ranks some names lower, or a recommender that steers groups apart. Embeddings don't make decisions on their own, but anything you build on "nearest" inherits these patterns. If your system ranks or filters people, test it with matched examples that differ only in a name or a pronoun, compare the scores, and keep a human in the loop for decisions that matter.

Practitioner§12

Check yourself

Work out each answer before you click. Q1–Q2 are covered in §03, Q3 in §04, Q4 in §07, Q5 in §08, Q6 in §11.

QUESTION 1 / 6
Cosine of two toy vectors

a = [2, 0] and b = [0, 5]. What is their cosine similarity?

QUESTION 2 / 6
Same direction, different length

a = [1, 2] and b = [3, 6]. Which statement is true?

QUESTION 3 / 6
Why normalize?

Every stored vector and every query vector has length 1. Your code ranks results by plain dot product. Is that a bug?

QUESTION 4 / 6
The support bot answered the wrong order

Users ask “Where is order #48213?”. Your semantic search sometimes returns order #91577 with a high score. What's the best fix?

QUESTION 5 / 6
Changing the embedding model

You switch from model A to model B for new documents only. Old documents keep their model-A vectors. What happens to search?

QUESTION 6 / 6
Storage math

You store 2 million chunks with a 384-dimension model as float32. Roughly how much space do the raw vectors take (no index, no text)?

Answered 0 / 6
Foundations§13

Cheat sheet

🧭
Meaning as directionAn embedding model turns text into a fixed-length vector. Similar meaning → similar direction.
📐
Cosine = a·b ÷ (|a| |b|)From −1 to 1. Ignores length. Higher = more similar. Cosine distance = 1 − cosine.
1️⃣
Normalize onceUnit vectors: cosine = dot product, and Euclidean distance ranks the same way.
⚠️
Topic yes, details noNegation, numbers, IDs and word order barely move the vector; names are unpredictable. Use exact filters or keyword search for all of them.
📏
Scores are relative0.49 can be the best match. Pick thresholds per model, on your own data.
🚫
Never mix modelsSame model (and recipe) for queries and documents. Store the model name. New model = re-embed all.
✂️
Chunk itOne vector, one topic. Long text gets diluted, and text past the limit is silently cut.
💾
Size it384 × 4 bytes = 1.5 KB per vector, 1.5 GB per million. int8 = ¼ the size, little loss.
I want to…Do this
Compare two texts by meaningEmbed both with the same model; cosine similarity
Search a few thousand itemsBrute force: dot product with every normalized vector, sort, take top k
Search millionsAn ANN index in a vector database
Find an order, SKU, error code or nameExact database filter or keyword search, not embeddings alone
Spot near-duplicatesPairs with very high cosine → review, don't auto-merge
Classify with few labelsNearest labelled examples vote (kNN)
Switch embedding modelsNew column, backfill, compare on test queries, switch, then drop old
Save memoryint8 quantization; 1-bit only as a first pass with re-scoring
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in AI EngineeringVector Databases
Read →