RAG at scale
The toy index from 3.1–3.3 scored every stored vector on every query — perfect for learning, fatal at millions of vectors. This expert chapter is about the jump from a laptop demo to a system that answers over huge, changing corpora: why brute-force search hits a wall, how approximate nearest neighbour indexes (HNSW, IVF) trade a sliver of recall for orders-of-magnitude speed, what a vector database actually buys you, and how you shard, ingest, keep data fresh, and cache so the whole thing stays fast and affordable. You will simulate the ANN and caching ideas in plain Python with operation counters — no clocks, no magic — and finish able to reason about a scale design.
Learning objectives
- Explain the scaling wall: why brute-force cosine is O(N·d) per query and dies at millions of vectors.
- Describe approximate nearest neighbour (ANN) search and the core recall-for-speed trade.
- Contrast HNSW (navigable graph) and IVF (cluster + probe) conceptually, and simulate the IVF idea.
- Say what a vector database adds over a raw library — persistence, filtering, sharding, replication.
- Reason about sharding and scatter-gather, and when splitting the index is worth it.
- Design ingestion at scale: batch vs streaming, re-embedding on model change, freshness, deletes.
- Use caching (embeddings + query results) and invalidation, and think in a cost/latency budget.
1 · The scaling wall
Every retriever so far scored the query against every stored vector and kept the best. That is brute-force (or “flat”) search, and it is exactly right at small scale: it is simple, it is exact (it truly finds the nearest neighbours), and for a few thousand chunks it is instant. The problem is the cost curve. Comparing the query against N vectors of dimension d costs O(N·d) per query: double the corpus, double the work, on every single query. Fine at thousands; a wall at millions.
Rather than time it (clocks are noisy and non-deterministic), we count operations. Each full vector comparison bumps a counter, so the growth is exact and reproducible. Watch comparisons track N one-for-one:
scaling_wall.py# Brute-force search cost, measured with an OPERATION COUNTER (not a clock).
# Every vector comparison bumps the counter, so cost is deterministic and reproducible.
def make_index(n, dims=8):
"""Deterministic fake vectors: value depends only on (row, col)."""
return [[((r * 7 + c * 13) % 17) / 17.0 for c in range(dims)] for r in range(n)]
comparisons = 0
def cosine(a, b):
global comparisons
comparisons += 1 # one full vector compared = one op
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
return dot / (na * nb + 1e-9)
def brute_force(query, index):
return max(range(len(index)), key=lambda i: cosine(query, index[i]))
query = [0.5] * 8
for n in (1000, 2000, 4000, 8000):
index = make_index(n)
comparisons = 0
brute_force(query, index)
print(f'N={n:>5} comparisons={comparisons:>5} (== N, grows linearly)')
N= 1000 comparisons= 1000 (== N, grows linearly)
N= 2000 comparisons= 2000 (== N, grows linearly)
N= 4000 comparisons= 4000 (== N, grows linearly)
N= 8000 comparisons= 8000 (== N, grows linearly)
The comparison count equals N every time — that is the O(N) part, and each comparison itself costs O(d). At 10k vectors nobody notices; at 10 million, a single query touches 10 million vectors, and if you serve hundreds of queries per second the machine melts. You cannot brute-force your way to scale — you need a data structure that finds near vectors without scanning them all.
2 · Approximate nearest neighbours (ANN)
The escape is to stop insisting on the exact nearest neighbours. Approximate nearest neighbour search accepts that it will occasionally miss a true top result, in exchange for touching a tiny fraction of the vectors. The knob is recall — the fraction of true neighbours the approximate search still finds — and in practice you can hold recall very high (say 0.95–0.99, illustrative) while inspecting orders of magnitude fewer vectors. That trade is the entire reason production RAG can exist at scale. Two families dominate, and it is worth holding both as mental pictures:
| Family | Core idea | What you tune |
|---|---|---|
| HNSW (graph) | Build a multi-layer navigable graph where each vector links to its near neighbours. A search enters at the top layer and “greedily” hops toward the query, dropping down layers to refine — like skimming a subway map before walking the last block. | How many neighbours each node keeps (M) and how hard you search (efSearch): higher = better recall, more work. |
| IVF (inverted file / clustering) | Cluster the vectors (e.g. with k-means) into buckets. At query time, find the nearest few cluster centroids and search only those buckets — ignore the rest of the corpus entirely. | How many clusters (nlist) and how many you probe per query (nprobe): more probes = higher recall, more vectors inspected. |
We will not build a real HNSW graph — that is a substantial system and the whole point of using a library is that someone already did it correctly. But the IVF idea is small enough to simulate, and it makes the recall trade concrete. Below we cluster 600 vectors into 4 buckets, then search only the nprobe nearest buckets, counting how many vectors we inspect versus brute force:
ivf_sim.py# IVF idea: cluster vectors, then search ONLY the nearest cluster(s).
# We count how many vectors each strategy inspects. Fully deterministic.
def make_vectors(n, dims=4):
# spread points across the space so all clusters get populated
return [[((r * 2 + c * 7) % 13) / 13.0 for c in range(dims)] for r in range(n)]
def dist2(a, b):
return sum((x - y) ** 2 for x, y in zip(a, b))
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
return dot / (na * nb + 1e-9)
# ---- build: assign each vector to its nearest of C fixed centroids (IVF 'inverted lists') ----
vectors = make_vectors(600, dims=4)
centroids = [[0.15, 0.25, 0.75, 0.35], [0.75, 0.65, 0.2, 0.25],
[0.25, 0.85, 0.35, 0.7], [0.85, 0.2, 0.55, 0.8]]
lists = {c: [] for c in range(len(centroids))}
for i, v in enumerate(vectors):
nearest_c = min(range(len(centroids)), key=lambda c: dist2(v, centroids[c]))
lists[nearest_c].append(i)
query = [0.2, 0.3, 0.7, 0.4]
# ---- brute force: inspect every vector, exact answer ----
bf_inspected = len(vectors)
bf_best = max(range(len(vectors)), key=lambda i: cosine(query, vectors[i]))
# ---- IVF: probe only the nprobe nearest clusters ----
def ivf_search(query, nprobe):
order = sorted(range(len(centroids)), key=lambda c: dist2(query, centroids[c]))
probed = order[:nprobe]
candidates = [i for c in probed for i in lists[c]]
inspected = len(candidates)
best = max(candidates, key=lambda i: cosine(query, vectors[i]))
return best, inspected
for c in range(len(centroids)):
print(f'cluster {c}: {len(lists[c])} vectors')
print(f'\nbrute force : inspected {bf_inspected}, best index {bf_best}')
for nprobe in (1, 2, 3):
best, inspected = ivf_search(query, nprobe)
hit = 'HIT ' if best == bf_best else 'MISS'
print(f'IVF nprobe={nprobe}: inspected {inspected:>3}, best index {best} ({hit} vs brute force)')
cluster 0: 184 vectors
cluster 1: 92 vectors
cluster 2: 232 vectors
cluster 3: 92 vectors
brute force : inspected 600, best index 12
IVF nprobe=1: inspected 184, best index 11 (MISS vs brute force)
IVF nprobe=2: inspected 416, best index 11 (MISS vs brute force)
IVF nprobe=3: inspected 508, best index 12 (HIT vs brute force)
This is the recall trade in one run. At nprobe=1 we inspected only 184 of 600 vectors — a big saving — but the true best (index 12) sat in a cluster we didn't probe, so we returned a near-but-wrong neighbour (index 11): a recall miss. Probing more clusters inspects more vectors and, by nprobe=3, recovers the exact answer. That is the entire ANN dial: more probing → higher recall → more work. A real IVF over millions of vectors would use many more clusters so even a small nprobe skips the vast majority of the corpus, and HNSW reaches similar recall by graph-walking instead of clustering.
3 · Vector databases — library vs managed
An ANN index is just the search structure. A real system needs much more around it, and that is what a vector database provides. You have two broad ways to get there, and the honest framing is a trade-off, not a winner — the right choice depends on your team, scale, and constraints:
| Concern | ANN library (e.g. FAISS) | Managed / server vector DB (e.g. pgvector, hosted services) |
|---|---|---|
| What it is | A code library you embed in your process to build and query an ANN index in memory. | A database (self-hosted like pgvector on Postgres, or a managed service) that stores and serves vectors. |
| Persistence | You handle it — save/load the index file yourself; it lives in your app's memory. | Built in — vectors are stored durably and survive restarts like any other data. |
| Metadata filtering | Minimal; you bolt on your own filtering around it (see hybrid/metadata in 3.2). | First-class — filter by fields alongside the vector search in one query. |
| Scaling & ops | You own sharding, replication, backups, and uptime. | Sharding, replication, and (for managed) ops are provided or automated. |
| Best when | Small/medium corpus, you want maximum control, or you're prototyping in one process. | Production traffic, durability and filtering matter, you don't want to run the plumbing yourself. |
Whichever you pick, the query interface is the one you already know from 3.1–3.3: embed the query, ask the index for the top-k, optionally with a metadata where filter and hybrid keyword fusion. The database changes where the vectors live and who operates it, not the retrieve → augment → generate shape.
4 · Sharding & distribution
A single node holds only so many vectors in memory and serves only so many QPS. When one machine isn't enough, you shard: split the index across several nodes so each holds a slice of the corpus. A query then fans out to all shards, each returns its local top-k, and a coordinator merges them — the classic scatter-gather pattern.
| Mechanism | What it does | Why it matters |
|---|---|---|
| Sharding | Partition vectors across nodes (by hash, by tenant, or by another key). | Each node searches a smaller set, so you scale corpus size and total throughput horizontally. |
| Scatter-gather | Query every shard, then merge their local top-k into one global top-k. | Correctness needs each shard to return enough candidates; the merge picks the true global best across them. |
| Replication | Keep copies of each shard on multiple nodes. | Adds read throughput (more copies answer queries) and availability (a node can die). |
5 · Ingestion at scale & freshness
The index isn't built once and frozen — documents are added, changed, and deleted continuously, and every change means embedding text and updating the index. How you feed that pipeline decides how fresh your answers are:
| Mode | How it works | Trade-off |
|---|---|---|
| Batch | Re-embed and rebuild (or bulk-upsert) on a schedule — nightly, hourly. | Simple and efficient per item, but answers lag reality by up to one batch interval — stale between runs. |
| Streaming / incremental | Embed and upsert each document as it arrives or changes. | Near-real-time freshness, but more moving parts and per-item overhead; needs idempotent upserts. |
Three ingestion realities bite specifically at scale:
| Challenge | What happens | How you handle it |
|---|---|---|
| Re-embedding on model change | Swap the embedding model and every stored vector is now in a different space — old and new vectors are no longer comparable. | Re-embed the entire corpus with the new model (a full, often expensive backfill), version the index, and cut over atomically. You cannot mix vectors from two models in one index. |
| Deletes & updates | A document is removed or edited, but its old vector still lurks in the index and can be retrieved — serving deleted or outdated content. | Treat an update as delete-then-insert; propagate deletes promptly; some indexes only tombstone and reclaim space on a later rebuild. |
| Freshness lag | There's a delay between a document changing and its new vector being searchable. | Pick batch vs streaming by how fresh answers must be; a fast-moving corpus (news, tickets) needs incremental, a stable one (policy docs) is fine on a batch. |
6 · Caching — embeddings & query results
Real traffic is skewed: a small set of popular queries and documents accounts for a large share of requests. Caching exploits that. Two layers pay off, and both cut the expensive embedding calls:
| Cache | What it stores | Payoff |
|---|---|---|
| Embedding cache | Text → its vector, so identical text is never embedded twice. | Skips the embedding-model call on repeats — the priciest step in ingestion and query. |
| Query-result cache | Query → its retrieved chunks (or the final answer). | A repeated query skips retrieval (and generation) entirely — the biggest latency and cost win. |
Here is both ideas in one runnable simulation. We count how many times the pretend-expensive embed() actually runs, and how many requests are served straight from the result cache:
caching.py# Caching: skip re-embedding text we've seen, and skip re-retrieving repeated queries.
# We count how many times the (expensive) embed function actually runs. Deterministic.
embed_calls = 0
_embed_cache = {}
def embed(text):
"""Pretend-expensive embedder. The cache makes repeats free."""
global embed_calls
if text in _embed_cache:
return _embed_cache[text] # cache HIT: no work
embed_calls += 1 # cache MISS: pay the cost once
vec = [(sum(ord(ch) for ch in text) + i) % 97 / 97.0 for i in range(4)]
_embed_cache[text] = vec
return vec
# A stream of queries where some repeat (real traffic is very skewed toward popular ones).
queries = ['reset password', 'refund policy', 'reset password',
'reset password', 'refund policy', 'shipping time']
query_cache = {}
query_cache_hits = 0
for q in queries:
if q in query_cache:
query_cache_hits += 1 # served straight from the result cache
continue
query_cache[q] = embed(q) # miss: embed + (would retrieve) once
print(f'requests : {len(queries)}')
print(f'distinct queries : {len(set(queries))}')
print(f'embed() calls made : {embed_calls} (== distinct queries, repeats were free)')
print(f'query-cache hits : {query_cache_hits}')
print(f'work avoided : {query_cache_hits}/{len(queries)} requests served from cache')
requests : 6
distinct queries : 3
embed() calls made : 3 (== distinct queries, repeats were free)
query-cache hits : 3
work avoided : 3/6 requests served from cache
Six requests, but only three distinct queries — so embed() ran three times and half the requests were served from the result cache with zero retrieval work. On skewed real traffic the hit rate is often far higher. The catch is invalidation: a cached result is only correct until the underlying documents change.
7 · Thinking in a cost & latency budget
At scale, every design choice is a spend. It helps to think of a per-query budget — a latency budget (how long a user waits) and a cost budget (money per query) — and to know which stage spends what. The numbers below are illustrative placeholders to show the shape of the reasoning, not measurements — your real figures depend on your models, data, and infrastructure, so measure them:
| Stage | Spends on | Lever to cut it |
|---|---|---|
| Embed the query | One embedding-model call per (uncached) query. | Cache embeddings for repeated queries (section 6). |
| Vector search | Inspecting vectors — brute-force is linear in N. | Use an ANN index and tune recall down to the lowest acceptable level (section 2). |
| Re-rank (if used) | A slower model pass over the top-N candidates. | Run it on few candidates, or only for high-stakes queries (this is 3.3's topic). |
| Generate | The LLM call — usually the largest latency and cost item. | Cache frequent answers; keep retrieved context tight so you send fewer tokens. |
Why does brute-force vector search stop working at millions of vectors, and what replaces it?
Show answer
You upgrade to a better embedding model. Why can't you just embed new documents with it and leave the old vectors alone?
Show answer
🪜 Practice — from a brute-force toy to a scale design beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
In scaling_wall.py, add 16000 and 32000 to the loop and confirm comparisons keep tracking N exactly.
Show solution
The counter printscomparisons=16000 and comparisons=32000 — one comparison per stored vector, every query. That's the O(N) growth in numbers: nothing about brute-force gets cheaper as the corpus grows, which is why it hits the wall.
In ivf_sim.py, extend the nprobe loop to include 4. What happens to inspected count and recall, and why is nprobe=4 no longer approximate here?
Show solution
Atnprobe=4 you probe all four clusters, so you inspect all 600 vectors and always match brute force — but you've paid the full brute-force cost. Probing every cluster is brute force; the whole point of IVF is to probe few clusters and accept the small recall hit that buys.
Adapt ivf_sim.py to run several queries and print the fraction where IVF's best matches brute force's best, for nprobe=1 vs 2.
Show solution
Loop over a handful of fixed query vectors, compareivf_search(q, nprobe)[0] to the brute-force best for each, and print hits/total. Recall rises with nprobe — you've just built the tiny evaluation harness that decides where to set the knob (evaluation proper is 3.5).
Extend caching.py so the embedding cache key includes a MODEL_VERSION string. Show that bumping the version forces every text to be re-embedded.
Show solution
Key the cache on(MODEL_VERSION, text) instead of text. Bump MODEL_VERSION from 'v1' to 'v2' and re-run: embed_calls jumps back to the distinct-query count because no old keys match — exactly the full re-embed a model upgrade forces (section 5).
Split the ivf_sim.py vectors across 3 lists (shards), have each return its local top-1 by cosine, then merge to a global top-1. Confirm it equals the single-index brute force best.
Show solution
Partitionvectors into three slices, compute each slice's argmax cosine to the query, then take the max of those three winners. Because top-1 is associative over a partition, the merged result equals the global brute-force best — that's why scatter-gather is correct, provided each shard returns enough candidates.
You have 20M chunks, ~300 QPS, a corpus that changes hourly, and a planned embedding-model upgrade next quarter. Specify the scale design: index type, sharding, ingestion mode, caching, and how you'll handle the model swap.
Show solution
ANN index (HNSW or IVF) tuned to a measured recall target — brute-force is impossible at 20M×300 QPS; shard across nodes with scatter-gather and replicate for read throughput/availability; streaming/incremental ingestion for hourly freshness with idempotent upserts and delete propagation; embedding + query-result caches keyed by model and index version. For the upgrade: build a new versioned index by re-embedding all 20M chunks offline, verify recall against the old one, cut over atomically, then drop the stale embedding cache. Evals for the cutover are 3.5's job.Context: Your prototype RAG works great on 5,000 chunks in memory, but you're about to point it at 8 million chunks across a corpus that updates all day, and a teammate asks what has to change.
Your task: Write a short scale design note (7–10 sentences) covering the jump from the in-memory prototype to a system that survives that corpus and traffic, in the order you'd tackle it.
Requirements:
- Name the scaling wall (brute-force is O(N·d) per query) and the fix: an ANN index (HNSW or IVF) at a measured recall target.
- Decide library vs managed/server vector DB and justify it by persistence, filtering, and ops — without ranking specific vendors.
- State whether you'd shard yet, and the scatter-gather + replication implications if you do.
- Pick an ingestion mode (batch vs streaming) for an all-day-updating corpus, and cover deletes and freshness.
- Add caching (embeddings + query results) and say how you'd key and invalidate it.
- Note what you defer to siblings: chunking/hybrid → 3.2, re-ranking → 3.3, evals/production hardening → 3.5.
💡 Hint: You don't need code — communicate the design and the order of leverage. Every lab in this chapter runs offline with operation counters; a real ANN library or vector DB swaps into the same retrieve → augment → generate shape you already know.
✓ Checkpoint — you can move on when you can…
- Brute-force search is O(N·d) per query and exact — great at thousands, a wall at millions (times QPS).
- ANN indexes trade a little recall for huge speed; the trade is a tunable, measurable knob.
- HNSW walks a navigable graph; IVF clusters and probes only the near clusters — more probing = more recall, more work.
- A vector database adds persistence, filtering, sharding, and replication over a raw ANN library; choose by constraints, don't rank vendors.
- Shard only when one node can't hold the index or keep up; queries fan out scatter-gather and merge, and replication adds throughput/availability.
- Plan ingestion (batch vs streaming), freshness, deletes, and the full re-embed a model change forces; cache embeddings + results and key them by model/index version.
- Think in a per-query cost/latency budget (illustrative until you measure) — generation often dominates, so profile before optimising.