← Writing

How vector search works: embeddings, similarity, and vector databases explained

Why keyword search fails, what an embedding really is, how ANN indexes like HNSW and IVF find neighbours fast, and when you actually need a vector database — with an interactive 3D embedding space.

  • rag
  • vector-search
  • embeddings
  • vector-databases

Every RAG system has the same hidden ceiling: the LLM can only answer with what retrieval hands it. If the right passage never comes back, no prompt engineering will save the answer. And in most RAG systems, the thing doing the retrieving is vector search.

This chapter builds the mental model from the ground up — no maths degree required. By the end you’ll know what an embedding is, how “close” is measured, how indexes find neighbours among millions of vectors in milliseconds, and when a vector database is worth it.

Why keyword search isn’t enough

Imagine a customer types this into your support bot:

how do I cancel my plan?

And the article that answers it is titled “Terminating your subscription.”

Keyword search compares words. These two share none — not “cancel”, not “plan”. The perfect answer scores zero and never shows up.

People describe the same thing in endless ways: “cancel”, “end”, “stop paying”, “terminate”, “get out of my contract”. Your documents use one of them. Vector search is how you bridge that gap.

Embeddings: GPS coordinates for meaning

An embedding model reads a piece of text and outputs a list of numbers — a vector. The model is trained so that texts with similar meaning get similar numbers.

Think of it like GPS. Every place on Earth has coordinates, and places that are near each other have similar coordinates. An embedding gives every sentence coordinates in a “map of meaning”, and sentences that mean similar things land near each other.

Figure
A toy two-dimensional map of meaningSentences are placed by two made-up axes: how much they are about money and how much they are about delivery. "Cancel my plan" and "terminate subscription" land almost on top of each other; "pasta recipe" is far from both.about money →about delivery ↑dashed circle = neighbours = same meaningcancel my planterminate subscriptionrefund my orderwhere's my parcel?pasta recipe
A toy map with two made-up axes. Real models don't have nameable axes like these — but the idea is the same: meaning becomes position.

Real embedding models don’t use two dimensions — they use hundreds or thousands (384, 768, and 1,536 are common). No single number means “about money”; meaning is spread across all of them. But the rule holds: close together = similar meaning.

Here’s what that looks like in code, using a small open-source model you can run on a laptop:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")   # 384 dimensions, free, runs locally

docs = ["Terminating your subscription", "Tracking where your order is"]
query = "how do I cancel my plan?"

doc_vecs = model.encode(docs, normalize_embeddings=True)
q_vec = model.encode(query, normalize_embeddings=True)

print(doc_vecs.shape)   # (2, 384) — each sentence is now 384 numbers
print(doc_vecs @ q_vec) # similarity of the query to each doc

With this model, “how do I cancel my plan?” scores 0.44 against “Terminating your subscription” — the highest of all 46 articles in the demo below, despite sharing zero words.

One hard rule: documents and queries must be embedded by the same model. Vectors from different models live on different maps. Switching models means re-embedding everything — treat it as a migration, not a config change.

Measuring “close”: cosine similarity

To find the nearest neighbours, we need a way to score how close two vectors are. The most common is cosine similarity: it measures the angle between two vectors and ignores their length.

Figure
Cosine similarity is the angle between two vectorsThree arrows start at the same origin. "Cancel my plan" and "terminate subscription" point in almost the same direction, a 10 degree angle, cosine 0.98. "Pasta recipe" points almost perpendicular, an 85 degree angle, cosine 0.09.cancel my planterminate subscriptionpasta recipesolid arc 10° → cos 0.98 (similar)dashed arc 85° → cos 0.09 (unrelated)
Same direction → similar meaning. Cosine is 1 when two vectors point the same way, near 0 when they're unrelated.
Metric What it measures When to use it
Cosine similarity The angle between vectors The default for text
Dot product Angle × length Identical to cosine when vectors are normalised — and cheaper to compute
Euclidean (L2) Straight-line distance Gives the same ranking as cosine on normalised vectors

In practice: normalise your vectors once (most libraries have a flag for it), then use whatever metric your embedding model’s documentation recommends. The ranking will be the same.

Play with it — drag the arrow tips:

Interactive · drag the arrow tips

ABunit circle
Cosine similarity
0.98

Angle
10°
Dot product
0.98
Distance (L2)
0.17
Length |A| · |B|
1.00 · 1.00
Adjust with sliders
Cosine only cares about direction: stretch B and the cosine doesn't move, but the dot product and distance do. That's why embeddings are usually normalised — then all three metrics rank results the same way.

See it for yourself

Below are 46 help-center articles from a fictional online store, embedded with the same model as above. Each point is one article; its shape shows the topic. The 384 dimensions are flattened to 3D so you can see them — drag to rotate, pick a question, and watch where it lands.

Interactive · drag to rotate · click a point

  • Billing
  • Shipping
  • Account
  • Returns
  • Products
  • Off-topic
  • Your query
1 · Ask something

Pick a question above. Its nearest neighbours light up in the plot and are ranked here.

46 help-center articles embedded with all-MiniLM-L6-v2 (384 dimensions), flattened to 3D with PCA for display. The 3D view keeps about 23% of the information, so on-screen distance is a shadow; the similarity scores are exact (384-d cosine).

Brute force: exact, simple, and fine more often than you think

The simplest way to find the nearest neighbours is to compare the query with every stored vector and keep the best:

import numpy as np

def search(query_vec: np.ndarray, doc_vecs: np.ndarray, k: int = 5):
    scores = doc_vecs @ query_vec            # one dot product per document
    top = np.argpartition(-scores, k)[:k]    # the k best, unordered
    return top[np.argsort(-scores[top])]     # ...sorted best first

That’s it — perfectly accurate, no index, no database. The catch is that the work grows with every document you add:

Documents Memory (1,536-dim float32) Multiply-adds per query
100,000 ~0.6 GB ~150 million
1,000,000 ~6 GB ~1.5 billion
10,000,000 ~61 GB ~15 billion

A modern machine chews through the first row in milliseconds. By the last row, every single query scans 61 GB of memory.

ANN indexes: trading a little accuracy for a lot of speed

Approximate nearest neighbour (ANN) indexes skip most of the vectors and return the probably best matches. A well-tuned index typically finds the large majority of the true top results, in a fraction of the time.

The way to measure “a large majority” is recall@k: of the true top 10, how many did the index return? You’ll meet recall again in Chapter 4 — it’s the number that keeps ANN honest.

There are two families of ANN index worth knowing.

IVF: only search the nearest sections

IVF (inverted file index) groups all vectors into clusters ahead of time. At query time it finds the few clusters whose centres are closest to the query and searches only those.

Figure
IVF index: search only the nearest clustersSix clusters of vectors. The query falls near the border between two clusters. With nprobe set to 2, only those two clusters are searched and the other four are skipped. The true nearest neighbour sits in the neighbouring cluster, so searching just one cluster would have missed it.QUERYnprobe = 2 → 2 of 6 clusters searched (white), 4 skipped✕ = cluster centre · the nearest match sits just across a border
IVF searches the nearest clusters only. The query here sits near a border — its best match is in the next cluster, which is why you search more than one (nprobe).

IVF is memory-friendly and fast to build, but its clusters are learned from your data — when the data drifts, they need retraining.

HNSW: motorways, then main roads, then streets

HNSW (hierarchical navigable small world) is the most common default today. It builds a graph where each vector links to its near neighbours, stacked in layers: the top layers have few vectors and long links; the bottom layer has every vector.

Figure
HNSW: a layered graph searched from the top downThree layers of the same graph. The top layer has two nodes with one long link, like a motorway. The middle layer has five nodes, like main roads. The bottom layer holds every vector, like streets. The search path starts at the top left, jumps across the motorway, drops down, takes one main-road hop, drops again, and takes one street hop to reach the nearest neighbour of the query.Layer 2 · motorwaysLayer 1 · main roadsLayer 0 · streets (every vector)STARTQUERYRed path: hop greedily, drop a layer, repeat — 5 moves, not 12 checks.
Search starts at the top, hops greedily toward the query, then drops down a layer and repeats. It touches a handful of points, not all of them.

HNSW gives excellent recall and speed, and you can add vectors at any time without retraining. The cost is memory — the graph lives alongside the vectors — and slower index builds. Its key dial is ef_search: explore more of the graph per query for higher recall, at the cost of latency.

See the trade-off for yourself

Here are brute force, IVF, and HNSW running for real on the same 600 points. Every distance calculation is counted, and recall is measured against the exact answer.

Interactive · click to move the query

Vectors checked

0 / 600

Recall@10

0 / 10

  • Query
  • Found (in true top 10)
  • Missed (true top 10, not found)
  • Checked
  • ✕ Cluster centre
600 points in 2D, so you can see them — real embeddings have hundreds of dimensions, but the trade-off is the same: check fewer vectors, risk missing some true neighbours. Both indexes here are real (small) implementations; the counts and recall are measured, not illustrated.

Quantization: compressing vectors

At large scale, the vectors themselves become the problem. Quantization compresses them — think JPEG for vectors:

  • Scalar (int8): store each number in 1 byte instead of 4. About 4× smaller with little quality loss.
  • Binary: 1 bit per dimension. About 32× smaller; search the compressed version first, then re-score the top candidates at full precision.
  • Product quantization (PQ): chop each vector into pieces and replace each piece with a short code. Very compact, more quality loss.

Quantization is what makes searching hundreds of millions of vectors affordable.

So what does a vector database add?

An ANN index like FAISS or hnswlib is a data structure. A vector database wraps that index with everything a production application needs:

You need… Because…
Metadata filtering “Similar articles — but only for this customer, in English, from this year.”
Inserts, updates, deletes Your documents change, and the index must stay correct as they do.
Hybrid search Exact terms like SKUs and error codes need keyword search alongside vectors (Chapter 7).
Persistence and replication It’s a database. It shouldn’t lose data when a machine dies.
Multi-tenancy and access control Customer A must never retrieve customer B’s documents.
Scale-out Eventually, the index outgrows one machine’s memory.

The landscape, by category

Category Examples Fits when
Index libraries FAISS, hnswlib Research, offline batch jobs, full control
Extensions to a database you already run pgvector (Postgres), Elasticsearch / OpenSearch, Redis You already operate it; you want vectors next to your relational data, in the same transactions
Dedicated vector databases Pinecone, Qdrant, Weaviate, Milvus Very large scale, heavy filtering, high query rates, or you want it fully managed
Embedded / local Chroma, LanceDB Prototypes, notebooks, local-first apps

Here’s how little it takes with pgvector, if you already run Postgres:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
  id         bigserial PRIMARY KEY,
  tenant_id  int  NOT NULL,
  content    text NOT NULL,
  embedding  vector(384)
);

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);

-- The 5 chunks closest in meaning to the query, for one tenant.
-- <=> is cosine distance (0 = identical), so smaller is better.
SELECT id, content
FROM chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 5;

Do you need one? Rules of thumb by use case

Use case Typical shape Starting point
Support bot over a help center Thousands of articles, few filters Brute force or pgvector — almost certainly enough
Internal docs for one company Tens of thousands of chunks, permission filters pgvector with an HNSW index and a team_id filter
Multi-tenant SaaS Millions of chunks, strict per-customer isolation A store with strong filtered search; test isolation explicitly
E-commerce catalogue Millions of items, heavy filters (price, size, stock), exact SKUs Hybrid search with filtering — dedicated store or a search engine like OpenSearch

These are starting points, not verdicts. Chapter 5 puts pgvector and dedicated databases side by side with numbers.

Where this fits in RAG

Vector search is the retrieve step. Everything before it — how you chunk and embed — decides what’s findable. Everything after it — reranking and generation — can only work with what it returns.

Indexing · offline

  1. Documents
  2. Chunksplit docs into passagesCh 3
  3. Embedpassages → vectorsCh 1Ch 2
  4. Storevector index + metadataCh 5

Answering · per question

  1. Question
  2. Embed queryquestion → vectorCh 1Ch 2
  3. Retrievenearest k passagesCh 6Ch 7Ch 11
  4. Rerankre-order the best fewCh 8
  5. GenerateLLM answers with context

Across every stage: evaluate (Ch 4) and operate — debugging, cost (Ch 9–10).

Key takeaways

  1. Retrieval quality is the ceiling on answer quality. Many “the LLM is wrong” bugs are retrieval bugs.
  2. An embedding is coordinates for meaning. Same model for documents and queries, always.
  3. Compare scores by rank, not threshold. Absolute similarity values vary by model.
  4. Brute force is fine below ~100K chunks. Reach for an ANN index when latency or memory says so.
  5. ANN is a dial, not a switch. nprobe and ef_search trade recall for speed — measure recall, don’t assume it.
  6. A vector database is an index plus database features. Filtering, updates, and isolation are what you’re really choosing between.

Next, in Chapter 2, we look at the embedding models themselves: what makes one better than another, open-source vs API models, and why the “sneakers” query went wrong.