# Embeddings & Vector Search, Explained

> What an embedding actually is (meaning turned into a list of numbers), how 'nearness' between two pieces of text is measured, and how vector databases search millions of them by meaning instead of keywords.


---

# Embeddings & Vector Search, Explained

You keep hearing that AI "understands meaning" - that it can find the right document even when you don't use the right keyword, that it powers search, recommendations, and the retrieval behind chatbots. Underneath almost all of that sits one quiet idea: an **embedding**, a way of turning a piece of text (or an image) into a list of numbers that captures what it *means*.

That sounds abstract until you see the trick: once meaning is a list of numbers, "find things that mean something similar" becomes "find numbers that are close together" - a math problem a computer can do in milliseconds across millions of items. This guide builds that mental model from the ground up, then shows how real systems search at scale and where they bite.

## How to read this

- **Want the one idea to take away?** Read [Phase 1: Meaning as Coordinates](01-meaning-as-coordinates.md). Everything else builds on it.
- **Want it to finally make sense end to end?** Read in order - each phase builds on the last. Three short phases, no math degree required.

## The phases

1. **[Meaning as Coordinates](01-meaning-as-coordinates.md)** - what an embedding *actually is*: a list of numbers that places meaning on a map, so similar meanings land near each other.
2. **[Measuring Similarity](02-measuring-similarity.md)** - how "near" gets computed, and why embedding your query and finding the nearest stored vectors gives you search by *meaning*, not keyword matching.
3. **[Vector Databases & the Gotchas](03-vector-databases-and-the-gotchas.md)** - how millions of vectors get stored and searched fast, the tools that do it, and the three traps that quietly ruin results.

**Related:** [What AI and ML Actually Are](/guides/what-ai-and-ml-are) for the bigger picture, and [RAG, Explained](/guides/rag-explained) - which is what you build *on top of* everything here.

> This guide deliberately stops at "search by meaning." How you feed those search results into a language model to answer questions - Retrieval-Augmented Generation - is its own guide: [RAG, Explained](/guides/rag-explained).


---

# Meaning as Coordinates - What an Embedding Actually Is

You've probably heard "an embedding is a vector representation of text." True, and it explains nothing - the kind of definition that makes people nod and stay quietly confused for a year. Here's the idea it's hiding: **if you can place every word, sentence, or document somewhere on a map, then "things that mean similar things" become "things that sit near each other."** An embedding is how you compute that placement. Once you see it, vector search stops being magic and becomes geometry.

## An embedding is a list of numbers that means something

**What it actually is.** An embedding is a list of numbers - a **vector** - that a model produces for a piece of input. Hand a model the word `cat` and it might hand back `[0.21, -0.88, 0.04, ...]`. Those numbers aren't random and they aren't a lookup ID. They're *coordinates*. Each one nudges the input toward or away from some aspect of meaning the model learned.

📝 **Terminology - vector.** In this guide, "vector" just means "an ordered list of numbers." `[0.21, -0.88, 0.04]` is a vector with three numbers in it.

**Why people get this wrong.** People assume an embedding is a *code* for the text - like an ID you could look up to get the word back. It isn't. You can't turn an embedding back into the original text. It's lossy on purpose: it throws away surface details (spelling, exact word choice) and keeps the *meaning*. Two sentences that mean the same thing produce two nearly-identical vectors, even sharing no words.

**What it does in real life.** Think of a map. Latitude and longitude place a city in space, and cities near each other on the map are near each other in reality. An embedding does the same for meaning - coordinates in a "meaning space," and the model is trained so that **similar meanings get similar coordinates**.

```text
   meaning space (simplified to 2 directions)

        kitten •
              • cat
   puppy •  • dog
   ───────────────────────────────────────
                              • car
                                • truck

   words about pets cluster together (top-left)
   words about vehicles cluster together (bottom-right)
   cat ↔ kitten: very close   |   cat ↔ car: far apart
```

*What just happened:* We placed five words on a flat map by their meaning. `cat` and `kitten` land almost on top of each other because they mean nearly the same thing. `dog` and `puppy` form their own little pet cluster nearby. `car` and `truck` sit far off in their own vehicle corner. Nobody told the map "pets go top-left" - the geometry *is* the meaning.

## The map is real, but it has way more than two directions

The 2D picture above is a teaching simplification, so let's correct it before it misleads you.

**What it actually is.** Real embeddings don't have two numbers - they have hundreds or thousands. OpenAI's `text-embedding-3-small` produces vectors with 1,536 numbers each (source: <https://platform.openai.com/docs/guides/embeddings>). Each number is one **dimension**: one independent direction in the meaning space.

📝 **Terminology - dimension.** A dimension is just one slot in the vector. A 2D map has 2 dimensions (left/right, up/down). An embedding with 1,536 numbers lives in a 1,536-dimensional space. You can't picture it, and you don't need to - the math works the same regardless of how many dimensions there are.

**Why people get this wrong.** "High-dimensional" sounds exotic, like intuition should break down. It doesn't: more dimensions just means more independent ways for two things to be similar or different. Two words can be close on the "animal vs object" direction but far apart on the "big vs small" direction. With 1,536 dimensions, the model has 1,536 subtle aspects of meaning to spread things along - exactly why it can tell apart meanings a flat 2D map would smush together.

⚠️ **Gotcha - you can't eyeball high-dimensional closeness.** `[0.21, -0.88, ...]` versus `[0.19, -0.85, ...]` - your eye gives up immediately. Closeness has to be *computed*, with a formula, which is exactly what [Phase 2](02-measuring-similarity.md) is about. Don't try to read meaning out of raw embedding numbers.

## Where the numbers come from

**What it actually is.** You don't write embeddings by hand. A trained model - an **embedding model** - produces them. You send it text, it returns the vector.

**What it does in real life.** In code it looks about as boring as calling any other API:

```console
$ curl https://api.openai.com/v1/embeddings \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "text-embedding-3-small", "input": "a small fluffy cat"}'
{
  "data": [
    { "embedding": [0.0123, -0.0456, 0.0789, ... ], "index": 0 }
  ],
  "model": "text-embedding-3-small",
  "usage": { "prompt_tokens": 5, "total_tokens": 5 }
}
```

*What just happened:* You sent one short string and got back one `embedding` - a long list of numbers (truncated here with `...`; the real response has 1,536 of them). That list is the coordinates of "a small fluffy cat" in the model's meaning space. Send a different model the same text and you'll get a *different* list - each model has its own private map. (Hold that thought; it becomes a real trap in [Phase 3](03-vector-databases-and-the-gotchas.md).)

**Why this saves you later.** Once you internalize "text in, coordinates out, and nearby coordinates mean similar things," every downstream idea clicks into place. Semantic search is "embed the query, find the nearest stored coordinates." Recommendations are "find items near this one." Clustering is "find groups of points that huddle together." They're all the same move on the same map.

## It's not only words, and not only text

**What it actually is.** The same trick works on whole sentences, paragraphs, and documents - and on images, audio, and more. An image embedding model places pictures on a map where a photo of a beach lands near other beach photos and far from spreadsheets.

**What it does in real life.** Some models are even trained so that *text and images share one map* - the text "a small fluffy cat" lands near actual photos of small fluffy cats. That's what powers "search your photo library by typing a description." The mechanism is identical: everything becomes coordinates; closeness means similarity. For the rest of this guide we'll stick to text, because the ideas transfer cleanly.

## Recap

1. An **embedding** is a list of numbers (a **vector**) that a model produces for a piece of input.
2. Those numbers are **coordinates on a map of meaning** - similar meanings land near each other, unrelated ones land far apart.
3. Real embeddings live in **hundreds or thousands of dimensions**, not two; the 2D scatter is just for intuition.
4. You **can't eyeball** closeness in high dimensions - it has to be computed (next phase).
5. The same idea covers sentences, documents, and even images; **closeness always means similarity**.

You now have the one idea the whole field rests on: meaning as coordinates. Next we make "near" precise - how a computer actually measures the distance between two of these vectors, and turns that into search.

---

Click a word to see its nearest neighbours. Similar meanings sit close together - that's the whole idea behind an embedding:

```playground-embed
```

Watch it animated: [embeddings](/explainers/Embeddings.dc.html)


---

# Measuring Similarity - From "Near" to Search by Meaning

In [Phase 1](01-meaning-as-coordinates.md) you learned that meaning becomes coordinates, and that *close* means *similar*. But we waved a hand at the most important word: "close." A computer can't squint at a map - it needs a number, a single value that says "these two vectors are 0.91 similar" or "these are basically unrelated."

This phase makes "close" precise, and then shows the payoff: once you can measure closeness, **search by meaning falls out almost for free.** You embed the question, you find the nearest stored vectors, you return what they point to. That's it. That's semantic search.

## The intuition: same direction means same meaning

**What it actually is.** The most common way to measure similarity between two embeddings is **cosine similarity**. Forget the dots on the map for a second and picture an *arrow* drawn from the origin to each point. Cosine similarity asks one question - **do these two arrows point the same way?**

```text
        ^                  arrow A: "a small fluffy cat"
        |   A   B          arrow B: "a tiny furry kitten"
        |  ╱  ╱            → point almost the SAME direction
        | ╱ ╱              → cosine similarity ≈ 1  (very similar)
        |╱╱
        +───────────────>
        |╲
        | ╲    C           arrow C: "quarterly tax filing"
        |  ╲               → points a totally different way
        v   C              → cosine similarity ≈ 0  (unrelated)
```

*What just happened:* Arrows A and B point in nearly the same direction, so their cosine similarity is near 1 - the system reads them as meaning almost the same thing. Arrow C heads off elsewhere, so its similarity to A is near 0. Cosine similarity ignores how *long* each arrow is and cares only about its *direction*, which is exactly what you want for comparing meaning.

📝 **Terminology - cosine similarity.** A single number, between -1 and 1, that measures how aligned two vectors are. **1** = pointing the same way (most similar). **0** = at right angles (unrelated). **-1** = pointing opposite ways. With text embeddings you'll mostly see values between 0 and 1, "higher = more similar."

**Why people get this wrong.** The frequent confusion is between *similarity* and *distance* - two sides of the same coin, easy to mix up. Higher cosine **similarity** means *more* alike (1 is best). Smaller **distance** means *more* alike (0 is best). Some tools report one, some the other; a few use plain straight-line (Euclidean) distance instead of cosine. The headache isn't the math - it's forgetting which direction means "better" in the tool in front of you.

⚠️ **Gotcha - know whether your tool returns "bigger is better" or "smaller is better."** Sort results the wrong way and you'll proudly return the *least* relevant matches. Before you trust any ranking, confirm: is this a similarity score (sort descending) or a distance (sort ascending)? Check the docs once; it saves a baffling afternoon.

## Semantic search: embed the query, find the nearest neighbors

This is the whole point. Everything so far was setup for this one move.

**What it actually is.** **Semantic search** means searching by meaning instead of by matching words. Three steps:

1. **Ahead of time:** embed every document you want to be searchable, and store the vectors.
2. **At search time:** embed the user's query with the *same model*.
3. Find the stored vectors **nearest** to the query vector, and return whatever they point to.

That third step - "find the nearest stored vectors to this one" - is called **nearest-neighbor search**.

```mermaid
flowchart LR
  T[query text] -->|same embedding model| V[query vector]
  V --> N[nearest-neighbor search]
  S[(stored vectors)] -.->|searched| N
  N --> R[top-k matches]
```

📝 **Terminology - nearest-neighbor search.** Given one query point, find the stored points closest to it. "Find the 5 nearest" is a *k-nearest-neighbors* search, often written `k=5`.

**What it does in real life.** Here's the move with a small library of stored documents:

```text
   STORED (embedded once, ahead of time)
   ┌────────────────────────────────────────────────┐
   │ doc1  "How to reset your password"              │
   │ doc2  "Recovering a lost account login"         │
   │ doc3  "Our refund and returns policy"           │
   │ doc4  "Office holiday hours for December"        │
   └────────────────────────────────────────────────┘

   QUERY:  "I can't get into my account"
              │
              ├─ embed with the SAME model → query vector
              │
              ▼
   compare query vector to every stored vector (cosine similarity):

      doc2  "Recovering a lost account login"     0.89   ◄── nearest
      doc1  "How to reset your password"          0.81
      doc3  "Our refund and returns policy"       0.12
      doc4  "Office holiday hours for December"    0.05
```

*What just happened:* The query "I can't get into my account" shares **no words** with doc2 ("Recovering a lost account login") - not one. A keyword search for "account" would miss the *intent* entirely. But in meaning space, "can't get into my account" and "recovering a lost account login" point almost the same direction, so doc2 scores highest. The system understood the *problem*, not the vocabulary.

💡 **Key point.** This is the superpower in one line: **semantic search matches meaning, so it handles synonyms and paraphrase automatically.** "Car" finds "automobile." "How do I cancel" finds "ending your subscription." "It's broken" finds "troubleshooting a malfunction." You didn't write any synonym lists - the embedding model already knows these things mean the same.

## Where it beats keyword search - and where it doesn't

Semantic search is not strictly better than keyword search; it's *different*, and the best systems often use both.

| Situation | Keyword search | Semantic search |
|---|---|---|
| Query uses different words than the doc ("can't log in" vs "account recovery") | Misses it | Finds it |
| Synonyms / paraphrase / typos in meaning | Misses unless you maintain synonym lists | Handles naturally |
| Exact match needed: an error code, a SKU, a function name like `parseDate` | Nails it | May drift to "similar-looking" but wrong results |
| Rare proper noun the model never learned well | Finds the literal string | Can fumble it |
| Explaining *why* a result matched | Easy - show the matched word | Hard - "the vectors were close" isn't satisfying |

*What just happened:* Each method wins where the other is weak. Keyword search is literal and precise; semantic search is flexible and meaning-aware. Production search frequently runs **both** and blends the rankings - a pattern called *hybrid search*. You don't choose a side; you choose per query, or run both and merge.

⚠️ **Gotcha - exact identifiers are semantic search's blind spot.** Search for the error code `ERR_2048` or part number `XJ-9920`, and embeddings may happily return things that *look or feel* similar instead of the exact match. Anything where the literal string is the point - codes, IDs, names - is where you want keyword matching in the mix.

**Why this saves you later.** Almost every "AI that finds the right thing" feature - support-article search, recommendation, deduplication, the retrieval step inside chatbots - is this exact pattern: embed, find nearest, return. When you build [RAG](/guides/rag-explained), the "retrieval" half *is* the semantic search you just learned.

## Recap

1. **Cosine similarity** measures whether two vectors point the same direction: **1 = most similar, 0 = unrelated.**
2. **Similarity** (bigger is better) and **distance** (smaller is better) are two ways to say the same thing - always know which your tool returns.
3. **Semantic search** = embed every document ahead of time, embed the query with the *same model*, return the **nearest neighbors**.
4. Because it matches *meaning*, it handles **synonyms and paraphrase for free** - its biggest advantage over keyword search.
5. It's **weak on exact identifiers**; serious systems often blend semantic and keyword search.

You can now reason about search by meaning end to end on a small library. The last question is the practical one: what happens when "compare the query to every stored vector" means comparing against *ten million* of them? That's the next phase - plus the three gotchas that quietly wreck real systems.


---

# Vector Databases & the Gotchas - Searching Millions, Without Getting Burned

[Phase 2](02-measuring-similarity.md) compared a query against four documents by hand. Real systems have a hundred thousand support articles, or ten million product descriptions, and a user expects results before they blink - comparing the query against every stored vector one by one gets slow exactly when you need it fast.

This phase covers what separates a toy from a real system: **searching millions of vectors quickly**, and the **three gotchas** that make a system technically work while quietly returning garbage. The gotchas matter more than the tooling - you can swap databases in an afternoon, but a chunking mistake will haunt you for months.

## The gotcha cheat-card

> **Search feeling off? Find your symptom, then read the section.**

| Symptom | Likely cause | The fix |
|---|---|---|
| Results are nonsense / everything looks equally (ir)relevant | Query and documents embedded with **different models** (§4) | Re-embed everything with one model; never mix (§4) |
| Right document exists but never surfaces | Document **chunked** too big/small or split mid-idea (§5) | Rethink chunk size and boundaries; re-embed (§5) |
| Top result is "similar" but factually wrong, and you trusted it | **Similarity ≠ correctness** (§6) | Treat results as candidates, not answers; verify (§6) |
| Search is slow at scale | Doing an **exact** scan over millions of vectors (§2) | Use an **ANN index** / a vector database (§1–2) |

---

## 1. A vector database is a search engine for nearest neighbors

**What it actually is.** A **vector database** is a store built for one job: keep a huge pile of embeddings and, given a query vector, return the nearest ones *fast*. It's the specialized muscle behind Phase 2's "find nearest neighbors" step, scaled to millions of vectors.

**What it does in real life.** You hand it vectors (usually with metadata attached - the original text, an ID, tags), and later a query vector, asking for the top `k` nearest:

```mermaid
flowchart LR
  D[document] -->|embed| DV[vector + text + metadata]
  DV --> DB[(vector database)]
  Q[query] -->|embed| QV[query vector]
  QV --> DB
  DB --> R[top-k documents]
```

*What just happened:* Same shape as Phase 2's semantic search - embed, find nearest, return. The database adds the speed to do "find nearest" across an enormous collection in milliseconds, plus the bookkeeping to hand back real text and metadata instead of a bare vector.

## 2. Why "fast" needs approximation: ANN indexes

**What it actually is.** The straightforward way to find the true nearest neighbors is to compare the query against *every* stored vector and keep the closest - an **exact** search. It's correct, but its cost grows with every vector you add; at ten million vectors, exact-on-every-query is too slow for an interactive app.

The escape is an **ANN index** - Approximate Nearest Neighbor. It organizes the vectors ahead of time (into graphs or clusters) so query time only checks a small, promising slice instead of everything.

📝 **Terminology - ANN.** A search that returns vectors that are *almost certainly* the nearest, by skipping most of the collection. You trade a tiny bit of accuracy for a large speed gain - it might occasionally miss the true #1 result and return #2 instead, almost always an acceptable trade.

**What it does in real life.** Most vector databases use ANN by default and let you tune how thoroughly it looks - more thorough means more accurate but slower.

⚠️ **Gotcha - "approximate" is in the name for a reason.** If a single miss is unacceptable (say, deduplication that must *never* let a duplicate slip through), either accept exact search's cost or tune the index toward higher accuracy. For ordinary search, the approximation is invisible to users.

## 3. The tools (lightly)

Roughly three flavors, and the right one depends on scale and what you already run:

- **A library you embed in your own code** - **FAISS** (from Meta). You call it directly and own the storage and serving around it. Great for batch jobs and full control.
- **An extension to a database you already have** - **pgvector**, adding vector columns and nearest-neighbor search to PostgreSQL. Keeps vectors next to your existing data: one system, one backup, normal SQL filters alongside vector search.
- **A managed vector database service** - **Pinecone** and similar hosted services. Send vectors over an API and they run the index, scaling, and ops for you.

A sane default: start with **pgvector** if you're already on Postgres, and move to a dedicated service only when scale or features demand it.

> This is a *light* tour on purpose. Picking and tuning a specific vector store is a deep topic of its own; the mental model here transfers to all of them.

## 4. Gotcha: vectors from different models can't be compared

This one produces the most baffling "why is my search returning total nonsense?" bug.

**What's actually happening.** Every embedding model has its **own private map** (hinted at in [Phase 1](01-meaning-as-coordinates.md)). Model A's coordinates for "cat" mean nothing on Model B's map - different spaces, often different lengths. Comparing a vector from one model against one from another is like comparing a latitude in degrees to a temperature in Celsius: the numbers compute, the result is meaningless.

⚠️ **Gotcha - embed your documents and your queries with the same model, always.** The classic disaster: documents embedded months ago with one model, incoming queries embedded with a newer one. Every query "works" (returns results, no errors) but the results are random, because the two live in incompatible spaces. If you **upgrade your embedding model**, you must **re-embed your entire collection** - old and new vectors don't mix. Write the model name down next to your stored vectors so future-you knows what they're in. This is the first thing to check when search suddenly returns garbage after a "harmless" dependency bump or model swap.

## 5. Gotcha: chunking decides what can ever be found

**What it actually is.** You usually can't embed a whole 40-page document as one vector - it would blur every topic into one muddy average, and most models have a length limit anyway. So you split long text into smaller pieces - **chunks** - and embed each separately. Search then finds the relevant *chunk*, not the whole document.

📝 **Terminology - chunking.** Splitting a long document into smaller passages before embedding, so each passage gets its own vector and can be matched on its own.

**Why people get this wrong.** Chunking feels like a boring preprocessing detail, so people slap an arbitrary "split every 500 characters" on it and move on. But chunking sets the ceiling on what your search can *ever* return. Split too **big** and one chunk covers five topics - its vector is a vague average matching everything weakly, nothing strongly. Split too **small** and you slice a single idea in half, so neither half carries the full meaning. Split mid-sentence and you can sever the exact passage that answered the question.

⚠️ **Gotcha - bad chunking is invisible until it isn't.** There are no errors. Search runs, returns results, looks fine in a demo. Then a user asks the one question whose answer got split across a chunk boundary, and the right passage never surfaces - because as a vector, it never existed as a coherent unit. When a document you *know* contains the answer refuses to show up, suspect the chunking before the model. Splitting on natural boundaries (paragraphs, sections) beats any fixed character count.

## 6. Gotcha: similarity is not correctness

**What's actually happening.** This is the deepest trap, because it's not a bug - the system is doing exactly what you asked. Nearest-neighbor search returns the vectors most *similar* to your query. Similar is not the same as **correct**, **true**, or **up to date**.

A document can be the closest match in meaning while being **wrong** (an outdated policy), **stale** (last year's prices), or **plausibly off-topic** (sounds relevant but answers a slightly different question). The search did its job perfectly - it found the nearest neighbor. Whether that neighbor is *right* is a question vectors can't answer.

⚠️ **Gotcha - "the top result" is a candidate, not a verdict.** Treat search results as *suggestions to be checked*, not answers. This matters enormously once you feed them to a language model: hand it a confidently-wrong "most similar" passage and it will often present that wrong information confidently. Being similar does not make an answer true - this is precisely the seam where [RAG, Explained](/guides/rag-explained) picks up, and "similarity ≠ correctness" is *the* reason RAG systems need careful retrieval, source citations, and verification.

## Recap

1. A **vector database** stores millions of embeddings and returns nearest neighbors fast - the muscle behind Phase 2's search.
2. Speed at scale comes from **ANN indexes**, trading a tiny bit of accuracy for a huge speed gain by skipping most of the collection.
3. Tools range from **libraries** (FAISS) to **database extensions** (pgvector) to **managed services** (Pinecone) - same core op, different amounts of ops work.
4. **Never mix models:** documents and queries must be embedded with the *same* model, and upgrading a model means re-embedding everything.
5. **Chunking sets the ceiling** on what can be found - split on natural boundaries, not arbitrary character counts.
6. **Similarity is not correctness:** the nearest match can still be wrong, stale, or off-topic - treat results as candidates to verify.

Meaning becomes coordinates, "near" becomes a number, and a vector database turns that into search across millions - along with the traps that decide whether it works in practice. Feeding these results to a language model to actually answer questions is exactly what [RAG, Explained](/guides/rag-explained) is about.
