AI EngineeringZero to ProductionHome·About·Contact
Retrieval (RAG) · Part 3.4 · Expert

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.

⏱️ ~100 min🧲 Retrieval🎯 Expert🐍 Runnable Python
🌱 What runs here, and what's a simulationThe labs run right here, offline, using operation counters instead of a clock — so the numbers are deterministic and reproducible, not timing-dependent. We simulate the IVF clustering idea in pure Python; we do not implement a production HNSW graph or a real vector database — those are far larger systems. Everything named here (HNSW, IVF, k-means, pgvector, FAISS) is a real, public method or tool. Any latency, cost, or QPS figures are illustrative and clearly labelled — measure your own. This chapter owns scale; chunking/hybrid live in 3.2, re-ranking in 3.3, and production systems + evals in 3.5, so we cross-reference rather than repeat them.

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:

python · count the comparisons (runnable — click ▶ Open in terminal)
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.

The wall is per-query, and it multipliesTwo things push you into it: corpus size (N) and query rate (QPS). A million vectors at a handful of QPS may be survivable; a million vectors at 500 QPS is not. Total work ≈ N·d·QPS. When any of those grows, brute-force is the first thing to break.

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:

FamilyCore ideaWhat 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.
Query from user Nearest centroids cheap: few centroids Probe those clusters only skip the rest Best in probed set approximate top-k

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:

python · simulate the IVF idea (runnable — click ▶ Open in terminal)
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.

Approximate is a setting, not a defectANN recall is a tunable knob, not an accident. You pick the operating point — high recall for a high-stakes search, lower recall (cheaper) where a rare miss is harmless — and you measure it against a brute-force ground truth on a sample. “Approximate” means “provably close, on a budget,” not “sloppy.” (Measuring recall properly is part of evaluation, covered in 3.5.)

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:

ConcernANN library (e.g. FAISS)Managed / server vector DB (e.g. pgvector, hosted services)
What it isA 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.
PersistenceYou 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 filteringMinimal; 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 & opsYou own sharding, replication, backups, and uptime.Sharding, replication, and (for managed) ops are provided or automated.
Best whenSmall/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.
Named factually, not rankedFAISS is a widely used open-source ANN library; pgvector adds vector columns and search to Postgres; several vendors offer managed vector databases. These are real, public options listed as examples of the two shapes — this course does not rank them, benchmark them, or claim any is “fastest.” Pick by your durability, filtering, scale, and operational constraints, and validate on your own data. Do not treat any illustrative number here as a benchmark.

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.

Query from user Scatter to all shards fan out Local top-k per shard search in parallel Gather & merge combine results Global top-k final answer
MechanismWhat it doesWhy it matters
ShardingPartition 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-gatherQuery 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.
ReplicationKeep copies of each shard on multiple nodes.Adds read throughput (more copies answer queries) and availability (a node can die).
Don't shard before you mustSharding adds real complexity: fan-out latency (you wait for the slowest shard), a coordinator to build and run, and rebalancing when shards grow uneven. If one well-provisioned node with an ANN index and replication handles your corpus and QPS, stay there. Reach for sharding when a single node can no longer hold the index in memory or keep up with traffic — not before. Illustrative rule of thumb: shard on size or QPS pressure, and measure the fan-out latency you're adding.

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:

ModeHow it worksTrade-off
BatchRe-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 / incrementalEmbed 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:

ChallengeWhat happensHow you handle it
Re-embedding on model changeSwap 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 & updatesA 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 lagThere'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.
Changing the embedder is a full migrationThis is the trap teams walk into: “let's upgrade to a better embedding model.” Because vectors from different models are not comparable, you must re-embed the whole corpus and rebuild the index — potentially millions of embedding calls. Budget for it, version your indexes, and keep the old one serving until the new one is verified. It is not a config flip.

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:

CacheWhat it storesPayoff
Embedding cacheText → 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 cacheQuery → 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:

python · cache embeddings + query results (runnable — click ▶ Open in terminal)
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.

The hard part of caching is invalidationAn embedding cache is safe to keep as long as the same model produced it — but the moment you re-embed with a new model (section 5), the whole embedding cache is stale and must be dropped. A query-result cache is only valid until the indexed documents change: expire entries on a TTL, or bust them when the relevant documents are updated. A stale cache serves confidently wrong answers, so key your caches by model version and index version, not just by text.

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:

StageSpends onLever to cut it
Embed the queryOne embedding-model call per (uncached) query.Cache embeddings for repeated queries (section 6).
Vector searchInspecting 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).
GenerateThe LLM call — usually the largest latency and cost item.Cache frequent answers; keep retrieved context tight so you send fewer tokens.
Generation usually dominates — measure before you optimiseIn many RAG systems the LLM generation call dwarfs retrieval in both latency and cost, so shaving milliseconds off search while ignoring a bloated prompt optimises the wrong thing. Profile a real query end to end, find the actual bottleneck, and spend your effort there. All figures in this chapter are illustrative — your budget comes from your measurements, and evaluating the whole system is 3.5's job.
✓ Knowledge check

Why does brute-force vector search stop working at millions of vectors, and what replaces it?

Show answer
Brute-force compares the query against every stored vector, so cost is O(N·d) per query and grows linearly with the corpus — at millions of vectors (times your QPS) that's far too much work. It's replaced by an approximate nearest neighbour (ANN) index — HNSW (a navigable graph) or IVF (cluster and probe only the near clusters) — which inspects a tiny fraction of the vectors and trades a little recall for a large speed-up.
✓ Knowledge check

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
Because vectors from different models live in different spaces and are not comparable — a query embedded with the new model can't be meaningfully scored against documents embedded with the old one. You must re-embed the entire corpus with the new model and rebuild the index (a full backfill), version it, and cut over atomically. It's a migration, not a config change — and it also invalidates your embedding cache.

🪜 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.

Exercise 1 · Grow the corpusBeginner

In scaling_wall.py, add 16000 and 32000 to the loop and confirm comparisons keep tracking N exactly.

Show solution The counter prints comparisons=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.
Exercise 2 · Probe more clustersIntermediate

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 At nprobe=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.
Exercise 3 · Measure recall as a numberIntermediate

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, compare ivf_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).
Exercise 4 · Version-key the cacheAdvanced

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).
Exercise 5 · Simulate scatter-gatherExpert

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 Partition vectors 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.
Exercise 6 · Design the scale layer for a real corpusIndustry scenario

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.