AI Harness Engineering

Embedding Ops

Drift, migrations, and version compatibility. An embedding is not a value you can compare to another embedding — it is a coordinate in a space that one particular model invented, and swapping the model swaps the space underneath four million vectors you already stored. This lesson is the arithmetic, the training trick, and the runbook for surviving that.

Prerequisites: a text embedder turns a passage into a list of numbers + cosine similarity ranks the neighbours. Everything else is built here.
10
Chapters
6
Simulations
0
Assumed Knowledge

Chapter 0: The Silent Outage

It is Tuesday morning. You own search for a documentation product: 4.2 million text chunks, each about 380 tokens, every one of them embedded into a 768-dimensional vector and loaded into an approximate-nearest-neighbour index. Users type a question, you embed the question, you fetch the 8 closest chunks, you hand them to a language model, it writes an answer. It works. Recall on your golden query set is 0.86.

Your embedding vendor ships v2. The release notes say it beats v1 by four points on public retrieval benchmarks. Same output width — 768. Same API shape. The change is one line in one file:

python# the entire deploy
- MODEL = "text-embedding-v1"
+ MODEL = "text-embedding-v2"

You ship it at 09:40. Nothing pages. There is no exception, no 500, no dimension-mismatch error, no latency spike, no memory alarm. The index accepts the query vector, the distance function returns numbers between −1 and 1, the top-8 comes back in 14 milliseconds exactly like yesterday. Every dashboard is green.

And every answer is subtly wrong. At 11:20 a support engineer notices that a question about billing returned three chunks about SAML. By 14:00 someone re-runs the golden set. Recall@8 has gone from 0.86 to 0.11.

The shape of this failure. Nothing was violated that a type system, a schema, a linter, or a smoke test can see. Both sides are float32 arrays of length 768. What broke is a contract nobody wrote down: the gallery vectors and the query vector must have been produced by the same encoder. That is a geometric contract, and no language you use has a way to express it. So it lives in your head until it does not.

Why 0.11 is scarier than 0.00

Pause on that number, because it is the most informative thing in the incident. If the two models had produced truly unrelated coordinates, recall would be at the chance level. Chance here is brutal: picking 8 chunks at random out of 4.2 million and hoping the right one is among them gives you

8 ÷ 4,200,000 = 1.9 × 10−6

which rounds to zero on any dashboard you own. So 0.11 is not random. It is roughly fifty-eight thousand times better than random. Something real survived the swap.

What survived is that both models were trained on overlapping web text with similar objectives, so they end up agreeing on a few coarse directions — roughly, “is this document about code or about prose,” “is this English or German,” “is this long-form or a list.” Enough shared structure to put topically-adjacent chunks in the vicinity. Not nearly enough to rank them.

And that is precisely why this outage is dangerous. A system at 0.00 looks broken; someone rolls it back before lunch. A system at 0.11 looks like a quality regression. It generates plausible-sounding answers built on the wrong sources. It gets attributed to “the model is worse than we hoped,” or to a chunking change from last week, or to that one noisy customer. Teams have run for weeks in this state.

Key insight. Partial compatibility is worse than zero compatibility, because zero compatibility is self-announcing. Design your version checks to fail loudly (Chapter 4) precisely because the natural failure of this system is to fail quietly.

The smallest possible demonstration

Two dimensions is enough to see the whole thing. Forget 768 coordinates; put three documents on a circle, and describe each one by its angle. A unit vector at angle θ is just [cosθ, sinθ], and the cosine similarity between two unit vectors at angles α and β is cos(α − β) — the cosine of the angle between them. That single fact carries this chapter.

Here is the gallery, as encoder v1 laid it out:

d1 “cat” at 0°     d2 “kitten” at 15°     d3 “truck” at 90°

Sensible: cat and kitten are 15° apart, truck is off in another direction. Now the user asks about “feline”, and v1 puts that query at 10°. Score it against each document:

cos(10° − 0°) = cos 10° = 0.985    cos(10° − 15°) = cos 5° = 0.996    cos(10° − 90°) = cos 80° = 0.174

Ranking: kitten (0.996), cat (0.985), truck (0.174). Correct, and the margin between the right answers and the wrong one is enormous. This is a healthy retrieval system.

Now swap the encoder

Encoder v2 is a genuinely better model. It also has its own idea of which direction is “zero.” Suppose — and Chapter 1 will show this is not a suspicious assumption but the expected outcome — that v2’s layout is v1’s layout turned by 70°. Under v2:

cat → 70°    kitten → 85°    truck → 160°    “feline” → 80°

Inside v2’s own world nothing has changed. The query at 80° is 5° from kitten, 10° from cat, 80° from truck — the identical scores, the identical ranking. v2 is not confused. v2 is fine.

But your index still holds the v1 gallery. So the comparison that actually happens on Tuesday morning is the v2 query against v1 documents:

cos(80° − 0°)  = cos 80° = 0.174   (cat)
cos(80° − 15°) = cos 65° = 0.423   (kitten)
cos(80° − 90°) = cos 10° = 0.985   (truck)
Read the winner. “truck” wins, with a score of 0.985 — the highest similarity in the whole system, higher than any score the healthy system produced except the correct one. The retriever is not hedging. It is not returning a weak match with low confidence you could threshold away. It is maximally confident and completely wrong, and the number it reports is indistinguishable from the number a perfect match reports.

This is why a similarity threshold does not save you. Thresholds filter out low scores. Cross-space retrieval does not produce low scores; it produces high scores attached to the wrong rows.

Two encoders, one index — watch the winner change

The three warm arrows are the gallery vectors, stored by v1 and never touched. The purple arrow is the query. Turn the “new encoder rotation” dial — that is v2 expressing the same meaning in its own coordinates. The bars are the cosine scores that your index actually computes. Find the rotation where “truck” takes first place, and notice how high its score is when it does.

new encoder rotation70°

What the runtime saw, line by line

Concept to realization. Here is the actual data flow, with shapes, on the morning of the incident. Nothing in it is invalid.

python# written months ago, by v1, into the index
gallery = index.vectors()          # float32[4_200_000, 768]   space: v1

# computed this morning, by v2, at query time
q = embed("how do I change my billing plan?")   # float32[768]   space: v2

# the comparison. every shape agrees. every value is finite.
scores = gallery @ q                # float32[4_200_000]  range [-1, 1]
top8   = np.argsort(-scores)[:8]     # int64[8]
# -> eight confident, well-scored, semantically unrelated chunks

Every assertion you could reasonably write passes. q.shape == (768,): true. np.isfinite(scores).all(): true. scores.max() > 0.8: true, in fact suspiciously true. len(top8) == 8: true. The one assertion that would have caught it does not exist yet:

pythonassert q_meta["embedder_fingerprint"] == index_meta["embedder_fingerprint"], \
    f"query built by {q_meta['embedder_fingerprint']} cannot be compared " \
    f"against an index built by {index_meta['embedder_fingerprint']}"

Chapter 4 is entirely about that one line and the metadata discipline it requires. But we cannot write it honestly until we understand why two competent encoders disagree about coordinates in the first place, which is Chapter 1.

An aside that will matter in Chapter 3: the four points never arrive

Set the outage aside for a moment and ask a quieter question. The release notes promised four points of improvement. Where do public benchmark numbers actually come from?

Every retrieval leaderboard number is measured with both sides embedded by the model under test. The queries are v2 vectors and the passages are v2 vectors. There is no cell on any public leaderboard for “v2 queries against a v1 gallery,” because outside of production nobody has a v1 gallery lying around. So the four points describe a world that only exists after your backfill finishes.

And even then they are an average over tasks. A benchmark suite averages dozens of datasets across domains you do not serve. If your corpus resembles three of those datasets and v2 gains half a point on those three, your realized gain is half a point, not four. The leaderboard is a screening tool for building a shortlist; it is not a prediction about your corpus. The only number that predicts your corpus is your own golden set, which is why Chapter 6 spends so long on sizing it properly.

Two lessons in one aside. First: measure the upgrade on your data before you plan a migration for it. Second, and more subtly: the metric that actually governs whether you can ship today — new queries against the old gallery — is a number nobody publishes, so you will have to measure it yourself. It has a name and a training technique attached, and both are Chapter 3.

What is actually at stake

Before we go there, notice how many systems in a typical retrieval stack quietly assume a shared space. Each one is a separate copy of the same outage:

SurfaceThe hidden assumptionWhat a version mismatch looks like
ANN indexquery and gallery share a spaceconfident, unrelated results (this chapter)
Semantic cachethe calibrated threshold means what it meanta cached answer to a different question (Chapter 7)
Deduplicationcosine > 0.97 implies “same document”distinct documents merged, or duplicates kept
Clustering / topicscluster centroids are comparable over timetopic ids silently reshuffle between runs
Anomaly / OOD gatesa distance threshold separates in-domain from outthe gate flips from letting everything through to blocking everything
Recommendationuser vectors and item vectors coexistrelevance collapses for exactly the users you re-embedded

Six surfaces, one root cause, and the vector database is only the first of them. That is why this lesson is called embedding ops rather than embedding migration: shipping a new encoder touches everything downstream that ever compared two numbers.

The five questions this lesson answers

Why did it break?
Training is invariant to rotation, so two runs land in different coordinates — Chapter 1
What does the fix cost?
Backfill arithmetic: tokens, GPU-hours, dollars, peak RAM, the write-rate gap — Chapter 2
Can I avoid the backfill?
Constrain the new encoder to the old space, and pay for it in accuracy — Chapter 3
How do I make it impossible?
Fingerprints, index manifests, refusal on mismatch, contract tests — Chapter 4
What breaks with no deploy at all?
Content and query drift, measured — Chapters 5 to 7, then the runbook
After the v2 deploy, the top result for a billing question is an unrelated SAML chunk with cosine similarity 0.985. What does that high score tell you?

Chapter 1: Why Spaces Do Not Transfer

The rotation in Chapter 0 looked like a convenient fiction chosen to make a point. It is not. It is the default. If you train the same architecture on the same data with the same recipe twice, changing only the random seed, you get two models whose coordinates do not match — and the reason is a two-line piece of algebra.

The objective cannot see the coordinates

Almost every modern text embedder is trained contrastively. Take a query q, its matching passage p+, and a pile of non-matching passages p. Push the matching pair together and the others apart. The loss for one example is

L = − ln [ exp(eq · ep+ / τ) ÷ ∑j exp(eq · epj / τ) ]

Every term in that expression is an inner product — the sum of the elementwise products of two vectors, written a · b. Notice what is not in the expression: any individual coordinate. The loss never asks “what is the value of dimension 412?” It only ever asks “how aligned are these two vectors?”

That is the whole story, and here is why. An orthogonal matrix R is a square matrix whose columns are unit vectors at right angles to each other. Equivalently, RTR = I — multiply it by its own transpose and you get the identity. Rotations and reflections are exactly the orthogonal matrices. Now apply R to every embedding in your model and recompute an inner product:

(R ea) · (R eb) = (R ea)T(R eb) = eaT RT R eb = eaT I eb = ea · eb

Unchanged. Every inner product in the model is unchanged, so every term of the loss is unchanged, so the loss is unchanged, so the gradient is unchanged. The optimizer has no signal whatsoever distinguishing your model from the rotated copy of your model. They are not similar solutions; they are the same point in loss space wearing different clothes.

Say it precisely. The optimum is not a point, it is an orbit — an entire continuous family of equally-optimal parameter settings related by orthogonal transformations. Training picks one member of the orbit for reasons that have nothing to do with quality: the initialization seed, the order the data happened to arrive in, which dropout masks fired, and the order a GPU kernel happened to sum a reduction in. Rerun with a different seed, land somewhere else on the orbit.

Check the algebra with actual numbers

Do not take RTR = I on trust. Build the 70° rotation from Chapter 0. With cos 70° = 0.342 and sin 70° = 0.940:

R = ┌ 0.342   −0.940 ┐
     └ 0.940    0.342 ┘

First column dotted with itself: 0.342² + 0.940² = 0.1170 + 0.8836 = 1.0006, which is 1 up to the three digits we kept. First column dotted with the second: 0.342(−0.940) + 0.940(0.342) = −0.3215 + 0.3215 = 0. Orthonormal columns, exactly as required.

Now take two embeddings and verify the inner product survives. Let e1 = [1.000, 0.000] and e2 = [0.966, 0.259] — that is 0° and 15°, our cat and kitten. Their inner product is

e1 · e2 = 1.000(0.966) + 0.000(0.259) = 0.966

Rotate both. For e1:

R e1 = [0.342(1.000) − 0.940(0.000),   0.940(1.000) + 0.342(0.000)] = [0.342, 0.940]

For e2, one coordinate at a time:

(R e2)[1] = 0.342(0.966) − 0.940(0.259) = 0.3304 − 0.2435 = 0.0869
(R e2)[2] = 0.940(0.966) + 0.342(0.259) = 0.9080 + 0.0886 = 0.9966

And the inner product of the rotated pair:

0.342(0.0869) + 0.940(0.9966) = 0.0297 + 0.9368 = 0.9665

0.966 before, 0.9665 after — identical to rounding. Both vectors moved a long way (the first went from [1, 0] to [0.342, 0.940]) and their relationship did not move at all. Every coordinate is different; every angle is the same.

It is not only rotation

Rotation is the clean, provable case. In practice the two spaces differ in a longer list of ways, all of them invisible to a shape check:

DifferenceDoes the loss notice?Effect on cross-space cosine
Orthogonal rotation / reflectionNo — provably invisibleTotal scramble, as above
Coordinate permutation (a special rotation)NoTotal scramble
Global rescaling, if the loss normalizesNoNone for cosine, fatal for raw dot product or L2
Different width (768 vs 1024)Comparison is undefined; this one at least crashes
Different pooling (CLS vs mean)Yes, different modelDifferent space entirely
Different instruction prefix on the queryYes, different inputsDifferent space, same weights — the sneakiest one
Genuinely better representations in v2Yes — this is the upgradeNo alignment exists, even in principle

Only the last row is the reason you wanted the upgrade. Everything above it is noise you inherit for free, and it is enough on its own to destroy retrieval.

Same geometry, different coordinates

Two training runs of the same recipe. Left is run A, right is run B. Turn the dial to rotate run B. Watch the numbers underneath: the within-run geometry (every pairwise angle) never changes by more than rounding, while the cross-run similarity of a document with its own twin falls off a cliff. Both models are equally good. Neither can read the other’s vectors.

run B rotation70°
documents5

The measurement that is invariant: gram matrices and CKA

If coordinates are arbitrary, how do you ever ask “are these two models representing the same thing?” You throw the coordinates away and keep only the relationships. Take a batch of n documents and embed them with model A into a matrix X of shape n × d. The gram matrix is

K = X XT    (shape n × n, entry i,j is ei · ej)

Every entry is an inner product, and we just proved inner products are rotation-invariant. Formally:

(X R)(X R)T = X R RT XT = X I XT = X XT = K

So K is a fingerprint of the geometry that survives any rotation. Centered kernel alignment (CKA) compares two models by comparing their gram matrices after centering both:

CKA(X, Y) = ⟨K̄, L̄⟩F ÷ ( ‖K̄‖F · ‖L̄‖F )

where L = YYT is model B’s gram matrix, the bar means column-and-row centered, and ⟨·,·⟩F is just the elementwise dot product of two matrices. It is a cosine similarity between relationship patterns rather than between vectors. CKA of 1 means “these two models agree perfectly about which documents are similar to which,” regardless of how either of them labels its axes.

The trap that catches people here. Measure CKA between your v1 and v2 encoders and you will typically get something high — 0.8, 0.9, higher for closely related checkpoints. It is tempting to read that as “the spaces are nearly the same, so mixing them should be nearly fine.” It is not. High CKA says the relationships agree. Cross-space cosine needs the coordinates to agree, and CKA is deliberately blind to coordinates. Two models can have CKA 0.99 and cross-space recall of 0.02. Compatibility and similarity-of-representation are different questions with different answers.

This is also the technical core of the Platonic Representation Hypothesis line of work: as models get bigger and better, their representations converge in the CKA sense — they carve the world up the same way — even while remaining mutually unreadable coordinate-wise. Convergence of geometry does not buy you interoperability of storage. Your index stores coordinates.

If the difference is a rotation, can we just undo it?

Excellent instinct, and it has a name and a closed-form answer. Take N texts, embed each with both models, giving X (old, N × d) and Y (new, N × d). Find the orthogonal matrix that best carries one to the other:

minimizeR ‖X R − Y‖F2    subject to   RTR = I

This is the orthogonal Procrustes problem, solved in 1966. Compute the singular value decomposition of the cross-covariance, XTY = U Σ VT, and the answer is

R̂ = U VT

Three lines of NumPy, no training loop:

pythonimport numpy as np

def fit_bridge(X_old, Y_new):        # both [N, 768], L2-normalized rows
    U, S, Vt = np.linalg.svd(X_old.T @ Y_new)   # [768,768] each
    return U @ Vt                            # R: [768, 768], orthogonal

R = fit_bridge(X_old, Y_new)
bridged = X_old_full @ R              # old gallery, pushed toward the new space
bridged /= np.linalg.norm(bridged, axis=1, keepdims=True)

And it partly works. Partly, because the two models are not related by an exact rotation — if they were, v2 would be exactly as good as v1 and there would be no reason to upgrade. The residual ‖XR̂ − Y‖ is the part of the upgrade that is genuinely new knowledge, and no linear map can manufacture it.

So the bridge is a stopgap, and a cheap one. Count the arithmetic. Applying a 768 × 768 matrix to one vector costs two floating-point operations per weight:

2 × 768 × 768 = 1,179,648 FLOPs per vector

Across the whole 4.2-million-chunk corpus:

4,200,000 × 1.18 × 106 = 4.95 × 1012 FLOPs

Compare that to re-embedding. A 110-million-parameter encoder over a 380-token chunk costs roughly 2 × params × tokens:

2 × 110 × 106 × 380 = 8.36 × 1010 FLOPs per chunk
× 4,200,000 chunks = 3.51 × 1017 FLOPs
ratio = 3.51 × 1017 ÷ 4.95 × 101271,000×

The linear bridge is about seventy thousand times cheaper than the real migration. It runs in under a minute on one GPU. It also stores in 768 × 768 × 4 bytes = 2.36 MB. That is an extraordinary cost-benefit ratio for a partial fix, which is exactly why it belongs in your incident toolkit: it is what you run at 14:05 on Tuesday to claw recall back from 0.11 to something survivable while the real backfill spins up.

Use it as a bridge, never as a destination. A fitted R is a second thing that can silently drift, a second thing that needs its own version, and a second thing that must be refit when either model changes. Ship it with an expiry date and a ticket.

The thirty-second diagnostic

Everything above suggests a test you can run during an incident, before you understand anything. Push the same twenty strings through both code paths and take the cosine of each corresponding pair.

pythonPROBE = ["reset my password", "annual invoice", ...]   # 20 short strings

a = embed_via_service_A(PROBE)     # [20, 768], normalized
b = embed_via_service_B(PROBE)     # [20, 768], normalized
same = (a * b).sum(-1)              # [20] — cosine of each pair with ITSELF
print(same.mean(), same.min())

How do you read the output? Derive the reference points instead of eyeballing them.

If the two paths are the same space, each string maps to the same vector and the cosine is 1.000, or 0.9999 with a precision difference. If the two paths are unrelated spaces, the pairs are effectively two independent random unit vectors in 768 dimensions. The cosine of two independent random unit vectors has mean 0 and standard deviation 1/√d:

1 ÷ √768 = 1 ÷ 27.71 = 0.0361

So under the “unrelated” hypothesis, three standard deviations is 0.108. Anything inside ±0.11 is indistinguishable from random. Two related-but-different encoders — the realistic case — land in a middle band, typically 0.10 to 0.45, because they share coarse structure without sharing coordinates.

Mean self-cosine across the probeDiagnosis
> 0.999Same space. Your bug is somewhere else.
0.95 – 0.999Same model, different precision or a minor numerical difference. Usually safe, but version it.
0.10 – 0.95Different spaces. Stop. This is the outage.
< 0.11Statistically indistinguishable from unrelated random vectors.

Twenty strings, two API calls, one dot product. This test takes half a minute and settles the single most expensive question in an embedding incident, which is why it belongs in the runbook rather than in a research notebook.

Concept to realization: what this means for storage

The practical conclusion of this chapter is one sentence, and it should change how you design the store:

An embedding is a derived artifact, not data. It is a cache of a computation over the text, valid only under one specific encoder. The text is the source of truth. If you have the text you can always rebuild the vectors; if you deleted the text to save space, a model upgrade is not a migration, it is a data loss event.

Teams delete the text more often than you would think, usually because “the vector store is the search index, and the documents live in the CMS” — until the CMS purges revisions, or the chunker changes and the old chunk boundaries are gone, or the source was a PDF that got extracted once by a pipeline that no longer exists. Rebuildability is a property you have to maintain, not one you have by default.

You measure CKA between your v1 and v2 encoders on 10,000 documents and get 0.94. What have you learned about whether v2 queries can safely hit the v1 index?

Chapter 2: The Re-embedding Migration

The honest fix is to recompute every vector with the new encoder. That is the migration. Before we discuss patterns, do the arithmetic, because almost everyone’s intuition about the cost is wrong in both directions: they overestimate the compute and badly underestimate everything else.

Step 1: how much is there?

Our corpus is 4.2 million chunks averaging 380 tokens. Total work:

4,200,000 × 380 = 1,596,000,000 tokens ≈ 1.6 billion

Two ways to buy that computation.

Option A, a hosted embedding API at $0.02 per million tokens:

1,596 million-token units × $0.02 = $31.92

Thirty-two dollars. The cost is not the problem. The rate limit is: at a typical 1 million tokens per minute ceiling,

1,596,000,000 ÷ 1,000,000 = 1,596 minutes = 26.6 hours

Option B, self-hosted on your own GPU. A 110M-parameter encoder at 380 tokens per chunk, fp16, batched, with realistic padding waste, does about 250 chunks per second on one mid-range accelerator. Sanity-check that against physics: 250 chunks/s × 380 tokens = 95,000 tokens/s, and at 2 × params FLOPs per token that is

2 × 110 × 106 × 95,000 = 2.09 × 1013 FLOP/s = 21 TFLOP/s

against a card with roughly 125 TFLOP/s of fp16 peak, i.e. about 17% utilization. That is a believable number for a standard inference stack with padded batches — not a fantasy, not sandbagging. So:

4,200,000 ÷ 250 = 16,800 seconds = 4.67 hours on one GPU
4.67 h × $1.006/h = $4.70
The compute is free. Five dollars, or thirty-two, and one working day. If your migration plan is dominated by an argument about GPU budget, you are arguing about the wrong line item. Everything expensive in a re-embed is storage, consistency, and cutover risk.

Step 2: what does it weigh?

768 float32 values is 3,072 bytes per vector. Across the corpus:

4,200,000 × 768 × 4 = 12,902,400,000 bytes = 12.9 GB

In float16, half that: 6.45 GB. Then the index structure itself. A graph index with M = 32 stores up to 2M = 64 neighbour ids at the base layer, 4 bytes each:

4,200,000 × 64 × 4 = 4,200,000 × 256 = 1.08 GB of graph

Fine on its own. Now the migration constraint: a shadow index means both versions are resident at once. Peak footprint during cutover:

12.9 (v1 vectors) + 12.9 (v2 vectors) + 1.08 + 1.08 (graphs) = 27.96 GB

Your steady state was 14 GB and your box has 32 GB. You are now within 4 GB of the ceiling, during the exact window when you are also running double the write traffic. This is the number that actually kills migrations, and it is the one nobody computes before the change-freeze meeting.

Step 3: the consistency window

The backfill takes 16,800 seconds. Your corpus is not frozen for 16,800 seconds. At a modest 30 document writes per second:

30 × 16,800 = 504,000 documents created or updated during the backfill
504,000 ÷ 4,200,000 = 12% of the corpus

If you do not handle that, one document in eight is either missing from the v2 index or present at a stale revision. And it is not a random eighth — it is the freshest eighth, which is also the most queried. A naive backfill produces a v2 index that is worst exactly where it matters most, which is why the naive backfill usually passes the golden-set evaluation and fails in production.

There are two clean answers, and you will typically use both:

MechanismHow it worksCost
Dual writeEvery write during the window embeds with v1 and v2 and lands in both indexes2× write-path compute and latency; both indexes always current
Watermark sweepRecord a timestamp before the backfill starts; after it finishes, re-embed everything modified after that markOne extra pass over a small tail; simple, but leaves a gap while it runs

Dual write is the primary; the watermark sweep is the reconciliation that catches whatever the dual write dropped (a failed enqueue, a retry storm, a partition that lagged). Run the sweep and expect it to find something. If it finds nothing, verify that it is actually running.

Step 4: what happens at scale

Change one number and watch every conclusion invert. Suppose the corpus is 900 million chunks instead of 4.2 million — a large product catalogue, a log corpus, a code index.

900,000,000 × 380 = 342 billion tokens
API: 342,000 × $0.02 = $6,840
GPU: 900,000,000 ÷ 250 = 3,600,000 s = 1,000 GPU-hours$1,006
on 1 GPU that is 42 days; on 64 GPUs it is 15.6 hours — same dollars, different calendar
storage (fp16): 900,000,000 × 768 × 2 = 1.38 × 1012 bytes = 1.38 TB

Now the cost is real but still not the blocker — the blocker is that a shadow index needs another 1.38 TB of hot memory, and that 42 days of single-GPU backfill against a live corpus means a consistency window longer than most of your documents’ lifetimes. This is the regime where you stop asking “how do we do the backfill” and start asking “how do we avoid needing one,” which is Chapter 3.

Migration cost calculator

Every number on this receipt is computed from the four dials with the same arithmetic we just did by hand. Push the corpus size up and watch which line turns red first — it is almost never the dollars. The last line is the one that decides whether you need dual write or can get away with a watermark sweep.

corpus (chunks)4.2M
tokens / chunk380
GPUs1
writes / second30

The seven-phase cutover

Here is the pattern in full. The organizing principle is one rule, and every phase is chosen to obey it: rollback must stay free until the last possible moment. Free means a config flag, not a restore.

1. Version the reads
Add the fingerprint field. Every read path filters on it. Ship this alone and verify nothing changed.
2. Dual write
New and updated documents get embedded by both encoders into both indexes. v2 index starts filling from the present forward.
3. Backfill, newest first
Walk history in reverse-recency order so the most-queried documents are covered first. Checkpoint the cursor.
4. Reconcile
Watermark sweep for anything the dual write missed. Count and compare row totals per version.
5. Shadow read
Sample live queries, run them against both indexes, log both result sets. No user sees v2. Measure overlap@8.
6. Canary and ramp
1% → 5% → 25% → 100%, with the gates from Chapter 6 checked at every step.
7. Soak, then decommission
Seven days at 100%, then stop dual write, drop the v1 index — but keep the v1 fingerprint in the version registry forever.

Phase 3 deserves a note. “Newest first” is not aesthetic. Query traffic against a document corpus is brutally skewed toward recent content — in a docs product it is common for the newest 10% of chunks to absorb more than half the retrievals. Backfilling newest-first means that after the first 10% of the work, you have already covered the majority of live traffic, and the shadow-read numbers in phase 5 become meaningful days earlier. Backfilling by primary key, which is what everyone does by accident, gives you a v2 index that is useless for evaluation until it is 100% complete.

Concept to realization: the write path, both versions

Here is what dual write actually looks like, including the part people skip — the failure semantics.

pythondef upsert_chunk(chunk_id: str, text: str):
    # v1 is the serving path: its failure is a user-visible failure.
    v1 = encoders["v1"].embed(text)                 # float32[768]
    store.upsert(chunk_id, v1, fingerprint=FP_V1)   # must succeed

    # v2 is the shadow path: its failure must NOT break serving,
    # but it must be recorded, or the reconcile sweep has nothing to find.
    try:
        v2 = encoders["v2"].embed(text)             # float32[768]
        store.upsert(chunk_id, v2, fingerprint=FP_V2)
    except Exception as e:
        backlog.push(chunk_id)                       # the sweep drains this
        metrics.incr("dualwrite.v2.failed")
        log.warning("v2 shadow write failed", chunk_id=chunk_id, err=str(e))

Three decisions in that snippet, each of which is the difference between a boring migration and a bad week. v1 first and unguarded, so a v2 outage cannot take down search. The v2 failure is caught but enqueued, so silence is impossible — an un-enqueued failure is a document that will be permanently missing from v2 and will never be noticed. And the fingerprint travels with the vector, not with the table, so a single store can hold both versions and a read can filter.

One index with a version column, or two separate indexes? Both work; the tradeoff is concrete:

One index + version filterTwo physical indexes
Query costFilter interacts badly with graph traversal — you may traverse many v1 nodes to find v2 neighboursClean; each index is homogeneous
MemorySame total, one allocatorSame total, easier to place on separate hosts
RollbackConfig flag on the filter valueConfig flag on the endpoint
Risk of accidental mixingHigh — forget the filter once and you get Chapter 0Low — the wrong index is a different address

For a migration specifically, prefer two physical indexes. The filter approach makes the catastrophic mistake (omitting one predicate) both easy and silent, and graph indexes are known to degrade badly under selective filters because the traversal keeps landing on nodes it must discard.

Your backfill takes 4.7 hours, you write 30 documents per second, and you skipped dual write because “we will just do a sweep afterwards.” What is the most likely production symptom?

Chapter 3: Backward-Compatible Training

Chapter 2 assumed the gallery must move. Chapter 3 asks the opposite question: what if we constrain the new encoder so that its queries land where the old gallery already is? Then the upgrade ships the day the model finishes training, with zero vectors rewritten, and the backfill becomes a background chore you do whenever it is convenient — or never.

This is backward-compatible training (BCT), introduced for face-recognition galleries where re-embedding was legally and operationally impossible, and it generalizes cleanly to text retrieval.

State the requirement as an equation

Write φold and φnew for the two encoders. The four things you can measure form a 2 × 2 grid, and only one cell is normally optimized:

Query encoderGallery encoderWhat it meansRecall@8 in our scenario
oldoldtoday’s production system0.860
newnewthe upgrade, after a full backfill0.905
newoldthe compatibility number — new queries, untouched index0.110 without BCT
oldnewthe reverse; matters while the backfill is half done0.104 without BCT

Backward compatibility is the demand that row three be close to row one. Formally: for every query q and document d, the score under the mixed pair should behave like the score under the old pair,

φnew(q) · φold(d) ≈ φold(q) · φold(d)

which, since this must hold for all d, is essentially the demand that φnew(q) ≈ φold(q) as vectors — not just as rankings. That looks like it forbids the new model from being new at all. Resolving that tension is the whole design.

The influence loss, derived

The original BCT construction is beautifully economical. The old system was trained with a classifier head κold sitting on top of the old embedding — a matrix of class prototypes, one row wc per class, living in old-embedding coordinates. Train the new encoder with its own loss plus a second loss in which the new features are pushed through the frozen old head:

LBCT = L( φnew, κnew )   +   λ · L( φnew, κold )

Why does that anchor the space? Because κold’s prototypes are fixed points in the old coordinate system. Requiring that φnew(x) be classified correctly by those fixed prototypes requires φnew(x) to point at the old prototype for its class. And the old gallery vectors also cluster around those same prototypes. Anchor both to the same landmarks and they become comparable.

Do the arithmetic on a toy

Three classes, two dimensions. The old head has prototypes at 0°, 120°, 240° — evenly spread, which is what a well-trained head does. A document of class 1 should embed near 0°.

Case A: the new model drifts 70° (the free, unconstrained outcome). A class-1 input now embeds at 70°. Its cosine against each frozen old prototype:

cos(70° − 0°) = 0.342    cos(70° − 120°) = cos 50° = 0.643    cos(70° − 240°) = cos 170° = −0.985

Class 2 wins. Now turn that into a loss. Cosine classifiers multiply by a scale s before the softmax; take s = 10. Logits are [3.42, 6.43, −9.85], so

e3.42 = 30.57    e6.43 = 620.17    e−9.85 = 0.0000529
sum = 30.57 + 620.17 + 0.00005 = 650.74
p(class 1) = 30.57 ÷ 650.74 = 0.0470
Linfluence = −ln(0.0470) = 3.06

Case B: the new model stays put. A class-1 input embeds at 0°. Logits are 10 × [1, cos 120°, cos 240°] = [10, −5, −5]:

e10 = 22,026.5    e−5 = 0.00674 (twice)    sum = 22,026.5
p(class 1) = 0.9999994  →  Linfluence = 0.0000006 ≈ 0

A loss of 3.06 versus essentially zero. That gap is a strong, well-shaped gradient pulling the new representation back into the old frame, and it costs nothing at inference — the old head is used only during training and then thrown away.

The mechanism in one line. The old classifier is a set of landmarks surveyed in the old coordinate system. Forcing the new model to hit the same landmarks forces it to adopt the same coordinates.

The text-retrieval version: an anchor loss

Text embedders usually have no classifier head. The analogue is to anchor directly on paired embeddings of the same text:

L = Lcontrastivenew)   +   λ · Ex [ 1 − cos( φnew(x), φold(x) ) ]

Understand the geometry of that second term, because its behaviour has a sharp edge. For unit vectors u = φnew(x) and v = φold(x), the gradient of (1 − u · v) with respect to u is simply −v. But u is constrained to the unit sphere, so only the part of that gradient tangent to the sphere can move it. Project out the radial component:

gtangent = −( v − (u · v) u )

The step therefore moves u along the great circle toward v, with magnitude

‖ v − (u · v)u ‖ = √(1 − (u · v)²) = |sin θ|

where θ is the angle between them. Read that carefully: the pull is strongest at 90°, and it goes to zero at 0° — which is what we want — but it also goes to zero at 180°. The antipode is a stationary point. A dimension of the new model that initialized exactly opposite its old counterpart will not be pulled home; it will sit there contributing a constant loss of 2. In practice the noise of SGD dislodges it, but if you ever see an anchor loss plateau at a suspiciously round number, this is why.

What compatibility costs

Nothing is free. Every unit of λ is a unit of freedom taken away from the new model, and the new model wanted that freedom in order to be better. Here is the sweep from our scenario:

λnew query / new gallerynew query / old galleryVerdict
00.9050.110Best model, needs a full backfill first
0.10.9030.594Anchor too weak to ship against the old index
0.30.8990.742Still below today’s 0.860 — a visible regression
1.00.8840.845Ship it — within 1.5 points of production, no vectors touched
3.00.8610.858Compatibility saturated; you are now paying for nothing
100.8220.859Worse than the model you are replacing

Read the λ = 1 row as an engineer. You keep

0.884 ÷ 0.905 = 97.7% of the upgrade

while day-one recall against the untouched index is 0.845 — 1.5 points below today’s 0.860, which is a real but survivable dip, and it climbs toward 0.884 as the lazy background backfill progresses. Compare that to the alternative: 42 days of blocked calendar in the large-corpus case, or a shadow index you do not have the memory for.

Then read the λ = 10 row as a warning. Chained hard enough to the old geometry, the new model inherits the old model’s mistakes. Compatibility is a leash, and past a certain tension the leash is what you are optimizing.

The compatibility leash

Teal is what the new model can do in its own space; warm is what it can do against the old, untouched gallery. The dashed line is today’s production recall — anything below it is a user-visible regression on day one. Slide λ and find the region where the warm curve clears the dashed line while the teal curve has not yet collapsed. That window is the entire engineering decision.

λ (anchor strength)1.0

The real payoff is not day one — it is the whole middle

Everyone frames BCT as “ship before the backfill.” That undersells it. The more important consequence shows up during the backfill, and you can compute it.

Let m be the fraction of the gallery that has been re-embedded so far. A relevant document is a v2 vector with probability m and a v1 vector otherwise. To a first approximation, recall blends:

R(m) = m · Rnew/new + (1 − m) · Rnew/old

Without BCT, plugging in 0.905 and 0.110:

R(0) = 0.110    R(0.25) = 0.309    R(0.5) = 0.508    R(0.75) = 0.706    R(1) = 0.905

Check the halfway point by hand: 0.5(0.905) + 0.5(0.110) = 0.4525 + 0.055 = 0.5075. That is the number to sit with. At the exact midpoint of your migration, recall is 0.508 against a production baseline of 0.860. The backfill is not a period of gradual improvement; it is a canyon you have to cross, and the canyon is deepest for as long as it takes to backfill the second half. With the large corpus from Chapter 2, that is weeks of production running at roughly half the recall it had.

Now with BCT at λ = 1, plugging in 0.884 and 0.845:

R(0) = 0.845    R(0.5) = 0.865    R(1) = 0.884

0.5(0.884) + 0.5(0.845) = 0.442 + 0.4225 = 0.8645. Monotone, shallow, and above today’s 0.860 from about the 40% mark onward. There is no canyon. That is the property you are buying with 2.3 points of ceiling, and it is worth far more than the day-one number that gets quoted.

The catch nobody mentions: a mixed index is biased toward the migrated half. The blend above assumes the two halves compete fairly. They do not. Against a v2 query, a v2 document of middling relevance may score 0.72 while a v1 document of equal relevance scores 0.61, because cross-space scores are systematically compressed. Both halves are in the same top-8 competition, so the v1 half is outranked on a technicality, and real recall during the migration is worse than the linear blend predicts.

Which gives the concrete operational rule from Chapter 2 a second, independent justification: serve from one version at a time. Build the v2 index alongside, evaluate it whole, and switch atomically. A half-migrated index that is being served is not a compromise between two systems; it is a third system with score-scale pathologies that neither of its parents had, and it is the one you never evaluated.

When BCT is not available, and what to do instead

BCT has one hard prerequisite: you must control the training of the new model, and you must decide to be compatible before you train it. If v2 is a hosted API model, or an off-the-shelf checkpoint, or a model someone else already finished training last quarter, BCT is not on the menu. Your options collapse to:

SituationBest toolTypical outcome
You train v2, corpus is huge, downtime intolerableBCT with a tuned λShip day one, backfill lazily, keep ~97% of the gain
You do not train v2, corpus is smallFull re-embed with dual writeHours of work, full gain, no residual complexity
You do not train v2, corpus is hugeLearned bridge (Chapter 1) during a long backfillPartial recovery immediately, full gain eventually
You want new gallery vectors to serve old queriesForward-compatible training: store side information now, transform laterStorage cost today buys freedom tomorrow
You expect frequent width changesMatryoshka-style nested representationsTruncation is free; rotation is still not

That last row is a common misconception worth killing. Matryoshka representation learning lets you truncate a 1024-dimensional vector to 256 and keep most of the quality — a genuinely useful property for cost control. It does not make two different models compatible. Truncating solves “the same model, a smaller slice.” Nothing in this lesson is about that problem.

Concept to realization: the training loop

pythonfor batch in loader:                       # batch.text: list[str], len B
    e_new = phi_new(batch.text)              # float32[B, 768]  requires grad
    with torch.no_grad():                    # old model is frozen, eval mode
        e_old = phi_old(batch.text)          # float32[B, 768]  no grad

    e_new = F.normalize(e_new, dim=-1)      # unit sphere, both
    e_old = F.normalize(e_old, dim=-1)

    loss_task   = info_nce(e_new, batch.positives, temperature=0.05)
    loss_anchor = (1.0 - (e_new * e_old).sum(-1)).mean()   # scalar

    (loss_task + LAM * loss_anchor).backward()

Three implementation notes that decide whether this works. Normalize before the anchor, or the loss rewards shrinking the norm instead of turning the vector. Compute e_old under no_grad — it is a fixed target, and letting gradient flow into it turns the anchor into a mutual-collapse objective where both models meet in the middle at a degenerate point. And anchor on the same distribution you will query: if you anchor only on your training corpus but your live queries are short keyword fragments, compatibility holds on the thing you measured and fails on the thing you serve.

Your BCT sweep shows λ = 3 gives new/old recall of 0.858 and λ = 1 gives 0.845, while new/new falls from 0.884 to 0.861. Which do you ship, and why?

Chapter 4: Versioning Discipline

Everything so far has been about surviving a version change. This chapter is about making the Chapter 0 outage structurally impossible — not “unlikely if everyone remembers,” but impossible in the sense that the code refuses to run.

A model name is not a version

The instinct is to store model = "bge-base-en-v1.5" next to each vector and call it done. That field is necessary and nowhere near sufficient, because the same model name produces different spaces under any of the following:

Thing that changedModel nameResulting space
Pooling switched from CLS to meanidenticalcompletely different
Query prefix added or droppedidenticalcompletely different
L2 normalization applied at write but not at queryidenticalscores no longer cosines
max_seq_len 512 → 256 (long chunks now truncate)identicaldifferent for the long tail only
Chunker changed 512 tokens → 256identicaldifferent corpus, not just different vectors
fp32 → fp16 inferenceidenticalsame space, cosine ≈ 0.9999
Upstream repo pushed a new revision to the same tagidenticalanything at all

The instruction-prefix row is the one that has burned the most teams. Many retrieval encoders expect the query to be prefixed with something like “Represent this sentence for searching relevant passages:” while passages get no prefix. Two services embed “billing plan”; one prepends the prefix and one does not; both call the same model, both produce 768 floats, and their outputs are in measurably different regions of the space. Same weights. Different space. No error.

Fingerprint the whole pipeline

The unit of versioning is not the model. It is the text-to-vector function, end to end. Hash everything that can change its output:

pythonimport hashlib, json

EMBED_SPEC = {
    "chunker":      {"kind": "recursive", "size": 512, "overlap": 64},
    "normalizer":   {"lowercase": False, "strip_html": True, "unicode": "NFKC"},
    "model_id":     "BAAI/bge-base-en-v1.5",
    "revision":     "a5beb1e",        # pin the commit, never a moving tag
    "pooling":      "cls",
    "normalize":    True,
    "max_seq_len":  512,
    "query_prefix": "Represent this sentence for searching relevant passages: ",
    "doc_prefix":   "",
    "dtype":        "float16",
    "dim":          768,
}

def fingerprint(spec: dict) -> str:
    # sort_keys makes the hash independent of dict ordering
    blob = json.dumps(spec, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode()).hexdigest()[:12]

FP = fingerprint(EMBED_SPEC)     # e.g. "a3f19c4e8b02" — 12 hex chars

Twelve hex characters is 48 bits, so an accidental collision needs on the order of 224 ≈ 16 million distinct specs before it becomes plausible. You will have dozens. It is enough, and it fits in a log line.

Property to hold onto: if two vectors carry the same fingerprint, they are comparable. If they carry different fingerprints, they are not — even if the difference is a field you believe is harmless. Let the hash decide, not your judgment at 2 a.m.

Where the fingerprint has to live

On every vector row
Non-null column. A vector without a fingerprint is unusable and should fail to insert.
In the index manifest
One fingerprint per index, checked at load. An index whose rows disagree with its manifest refuses to open.
On the query vector in flight
The query object carries the fingerprint of the encoder that made it, all the way to the search call.
In every cache key
Semantic caches, dedup tables, cluster assignments — anything keyed on geometry (Chapter 7).
In the version registry
Append-only history: fingerprint → full spec, forever. Old log lines stay interpretable after the index is gone.

Refuse, do not degrade

Here is the search path, written so that Chapter 0 cannot happen:

pythonclass IncompatibleEmbedding(RuntimeError): pass

def search(index, qvec, qfp, k=8):
    if qfp != index.manifest.fingerprint:
        # 503, page someone, DO NOT fall through to a nearest-neighbour call
        raise IncompatibleEmbedding(
            f"query fp={qfp} vs index fp={index.manifest.fingerprint}; "
            f"refusing to compare vectors from different spaces"
        )
    if qvec.shape[-1] != index.manifest.dim:
        raise IncompatibleEmbedding("dimension mismatch")   # the easy one
    return index.knn(qvec, k)

Two things about the order of those checks. The dimension check is second because it is the weak one: 768 == 768 proves nothing at all, and a system that only checks dimensions has a check that passes in exactly the case that hurts. And the fingerprint mismatch raises rather than logging a warning, because a warning in a retrieval path is a line in a file that nobody reads until the postmortem.

The tradeoff, stated plainly. Refusing means a hard outage: every query 503s until someone fixes the config. That feels terrible. Price the alternative. A hard outage is discovered in minutes, has an obvious cause, and is reverted with a flag. A silent mismatch produces weeks of confident wrong answers that get pasted into support replies and customer documents, and costs a quarter of trust in the product. Ten minutes of honest downtime is the cheapest incident in this lesson.

The contract test that catches the upstream you do not control

Pinning a revision protects you from the repo. It does not protect you from your own dependency upgrades: a library bump that changes the default pooling, a tokenizer update that handles a Unicode class differently, a new kernel that changes reduction order. The defence is a golden vector test in CI.

python# tests/test_embedding_contract.py
GOLDEN = np.load("tests/fixtures/golden_a3f19c4e8b02.npy")   # [20, 768]
PHRASES = open("tests/fixtures/golden_phrases.txt").read().splitlines()

def test_space_is_unchanged():
    got = embed_documents(PHRASES)                # [20, 768], normalized
    cos = (got * GOLDEN).sum(-1)                  # [20]
    assert cos.min() > 0.9999, (
        f"embedding space moved (min cos {cos.min():.6f}). "
        f"If this is intentional, bump EMBED_SPEC and regenerate the fixture."
    )

Why a cosine gate at 0.9999 rather than elementwise equality? Because legitimate, harmless variation exists. Running the same fp32 weights in fp16 perturbs each output slightly; measured on a normalized 768-dimensional vector, the cosine between the two typically lands around 0.9999 to 0.99999. An exact-equality test fails on every hardware change and gets deleted within a month. A cosine gate at 0.9999 passes precision noise and catches a pooling change, which moves cosine to something like 0.6.

And the assertion message is doing real work: it tells the engineer that the correct response to a red test is to bump the version, not to loosen the threshold. Half of version discipline is making the right action the obvious one at the moment of failure.

Normalize once, at write

A small operational rule with an outsized payoff. If your metric is cosine, store unit vectors and use inner product as the metric. Three consequences:

  1. Cosine and inner product become the same operation, so it is impossible to configure the index with the “wrong” metric.
  2. You cannot end up with a store containing a mix of normalized and unnormalized vectors, which produces a ranking dominated by whichever documents happened to have large norms.
  3. The per-query square root and division disappear from the hot path.

Put "normalize": true in the spec so it is part of the fingerprint, and assert it at insert time: assert abs(np.linalg.norm(v) - 1.0) < 1e-3. That single assertion has caught more real bugs than most monitoring dashboards.

Make the migration a state machine, not a set of feature flags

Most migrations are governed by four or five independent booleans: dual_write_enabled, backfill_running, shadow_read_pct, v2_traffic_pct, v1_index_loaded. Five booleans is thirty-two configurations, of which perhaps eight are coherent and several are actively dangerous — for instance, v2 traffic above zero while the backfill is at 40%, which Chapter 3 just showed produces a system nobody evaluated.

Collapse them into one field with a legal transition table:

StateServing fromLegal next statesIllegal because
BASELINEv1VERSIONED
VERSIONEDv1, fingerprint enforcedDUAL_WRITE, BASELINE
DUAL_WRITEv1BACKFILLING, VERSIONEDCannot serve v2 — the index only covers the present
BACKFILLINGv1RECONCILED, DUAL_WRITECannot shadow-read — a partial index gives a meaningless overlap number
RECONCILEDv1SHADOW, BACKFILLING
SHADOWv1 (v2 logged only)CANARY, RECONCILED
CANARYv1 + 1% v2RAMPING, SHADOW
RAMPINGsplit, atomic per requestSOAKED, CANARYA single request must never mix versions
SOAKEDv2DECOMMISSIONED, RAMPING
DECOMMISSIONEDv2 onlyNo path back; v1 index no longer exists

The value of writing it this way is that the dangerous configurations stop being reachable. “Serve v2 while backfilling” is not a flag someone can set at 3 a.m.; it is a transition that does not exist. And the state is one string in one place, so “where are we?” has an answer that fits in a status page rather than requiring an archaeology session across five config stores.

Log enough to have a postmortem at all

One structured line per search, containing the things that let you reconstruct an incident after the fact:

pythonlog.info("search",
    query_fp   = qfp,                  # which encoder made the query vector
    index_id   = index.manifest.index_id,
    index_fp   = index.manifest.fingerprint,
    state      = MIGRATION_STATE,      # the state machine above
    top1_score = float(scores[0]),    # feeds the drift and cache dashboards
    top8_ids   = ids[:8],             # enables overlap@8 after the fact
    latency_ms = dt,
)

Two of those fields are the ones people leave out and then wish they had. top1_score is what builds the score histogram that makes a version mismatch visible at a glance and what recalibrates every threshold in Chapter 7. top8_ids is what lets you compute overlap between two systems retroactively — replay yesterday’s logged results against today’s index and you get a change-magnitude number with no labels, no shadow traffic, and no waiting.

Concept to realization: the index manifest

pythonmanifest = {
    "index_id":      "docs-prod-2026-08-17",
    "fingerprint":   "a3f19c4e8b02",      # the text-to-vector function
    "spec":          EMBED_SPEC,           # full, human-readable, for the postmortem
    "corpus_snapshot": "cms@2026-08-17T04:00Z",
    "dim":           768,
    "metric":        "ip",                # inner product, because vectors are unit
    "count":         4_200_117,
    "build":         {"algo": "hnsw", "M": 32, "ef_construction": 200},
    "built_at":      "2026-08-17T06:14:22Z",
    "golden_recall8": 0.861,           # measured at build, before serving
}

The last field is the quiet hero. An index that records its own golden-set recall at build time means the question “did this index ever work?” has an answer that does not require rerunning anything, and a loader can refuse an index whose build-time recall is below a floor. It converts a class of production mysteries into a build-time failure.

Two services embed with the same pinned model, same revision, same pooling, same normalization. One prepends the model’s query instruction prefix; the other does not. What should the fingerprint do?

Chapter 5: Drift With No Model Change

Now change nothing. Same encoder, same fingerprint, same index, no deploys for six weeks. Retrieval quality still degrades. This is drift, and it is the failure mode that version discipline cannot touch, because nothing about your system changed — the world did.

Two drifts, different signatures

Content drift is the corpus moving. You launch a product line and 40,000 chunks of new vocabulary land in the index. A support team starts writing in German. An acquisition dumps a differently-structured knowledge base into the same store. The encoder still embeds everything competently, but the distribution of what lives in the index is no longer the one your thresholds, your clusters, and your evaluation set were built on.

Query drift is the users moving. An incident sends everyone asking about one outage. A new mobile surface funnels three-word queries into a system tuned on full sentences. Seasonality arrives. Marketing renames a feature and users start typing the new name, which appears nowhere in the corpus.

Why static evaluation misses both. Your golden set is a photograph of the query distribution on the day you built it. Drift means live traffic walks away from that photograph. Golden recall stays flat and reassuring while live relevance falls, because the golden set is measuring a population that no longer exists. A frozen eval set does not detect drift; it is the thing drift makes obsolete.

Signal 1: centroid shift, with the noise floor derived

The cheapest useful signal. Average all of yesterday’s query embeddings into one vector — the centroid — and compare it to the trailing four-week centroid by cosine. If they diverge, the shape of what people are asking has moved.

The obvious objection: centroids of finite samples wobble, so what counts as a real move? Derive the noise floor rather than guessing it.

Text embeddings are famously anisotropic — they do not fill the sphere evenly but cluster in a cone, so the population mean μ of unit vectors has a substantial norm. Take ‖μ‖ = 0.42, which is typical. Each query is a unit vector, so its total variance around the mean is 1 − ‖μ‖², spread over d = 768 dimensions:

per-coordinate variance = (1 − 0.42²) ÷ 768 = 0.8236 ÷ 768 = 1.0724 × 10−3

Average n = 50,000 queries and each coordinate’s variance drops by a factor of n:

1.0724 × 10−3 ÷ 50,000 = 2.145 × 10−8

The total squared length of the noise, summed over all 768 coordinates, and then its square root:

768 × 2.145 × 10−8 = 1.647 × 10−5  →  ‖noise‖ = 4.06 × 10−3

That noise is essentially perpendicular to μ in high dimensions, so the angle it induces is noise divided by signal:

θ ≈ 4.06 × 10−3 ÷ 0.42 = 9.66 × 10−3 radians

and for a small angle, cos θ ≈ 1 − θ²/2:

cos θ ≈ 1 − (9.66 × 10−3)² ÷ 2 = 1 − 4.67 × 10−5 = 0.99995
The operational number. With 50,000 queries a day, pure sampling noise moves the centroid cosine only to 0.99995. So a measured centroid cosine of 0.9995 is ten times the noise floor, and 0.982 — a number that looks like “basically unchanged” to anyone who has not done this arithmetic — is roughly four hundred times the noise floor. Centroid cosine is a fantastically sensitive instrument, and reading it on a human intuition scale will make you ignore every alarm it raises.

If deriving the floor makes you nervous, measure it instead: split last month’s queries into random halves two hundred times, compute the cosine between half-centroids each time, and take the first percentile. That bootstrap gives you the null distribution with no assumptions at all, and it is twenty lines of code.

Signal 2: PSI on a scalar projection

Centroid cosine collapses everything into one number and can miss a shift that moves mass around without moving the mean. The complementary tool is a histogram comparison — but you cannot histogram 768 dimensions, so first project down to a scalar. Three projections that work:

ProjectionWhat it detectsCost
Top principal component of the baseline embeddingsMovement along the dominant axis of variationOne PCA on a sample, then one dot product per query
Top-1 similarity to the corpus (best match score)“We have nothing good for this query”Free — you already computed it
Cosine to the baseline centroidRadial spread of the query cloudOne dot product per query

Then bin the scalar into 10 buckets whose edges are the baseline’s deciles — so by construction the baseline puts exactly 10% in each bucket — and compute the population stability index:

PSI = ∑i ( ai − ei ) · ln( ai ÷ ei )

with ei the expected (baseline) share and ai the actual (current) share.

Where that formula comes from

It is not arbitrary. Expand the two Kullback–Leibler divergences between the distributions:

KL(a ‖ e) = ∑i ai ln(ai/ei)     KL(e ‖ a) = ∑i ei ln(ei/ai) = −∑i ei ln(ai/ei)

Add them:

KL(a ‖ e) + KL(e ‖ a) = ∑i (ai − ei) ln(ai/ei) = PSI

PSI is exactly the symmetrized KL divergence. That explains its two most important properties: it is symmetric (swapping which distribution you call baseline gives the same number), and it diverges to infinity if any bin empties, because ln(0) does. Guard against that with a floor — replace a zero share with 0.5/n — or the day your German traffic vanishes your dashboard reports inf and the alert is useless.

A worked PSI, by hand

Baseline is 0.10 in every bin. This week’s shares, low bin to high:

a = [0.04, 0.05, 0.07, 0.09, 0.10, 0.11, 0.12, 0.13, 0.14, 0.15]

(They sum to 1.00 — check it: 0.04+0.05+0.07+0.09+0.10 = 0.35, and 0.11+0.12+0.13+0.14+0.15 = 0.65.) Mass has slid from the low bins to the high bins. Term by term, with (ae) · ln(a/e):

bin 1: (−0.06)(ln 0.4) = (−0.06)(−0.9163) = 0.05498
bin 2: (−0.05)(ln 0.5) = (−0.05)(−0.6931) = 0.03466
bin 3: (−0.03)(ln 0.7) = (−0.03)(−0.3567) = 0.01070
bin 4: (−0.01)(ln 0.9) = (−0.01)(−0.1054) = 0.00105
bin 5: (0.00)(ln 1.0) = 0.00000
bin 6: (0.01)(ln 1.1) = (0.01)(0.0953) = 0.00095
bin 7: (0.02)(ln 1.2) = (0.02)(0.1823) = 0.00365
bin 8: (0.03)(ln 1.3) = (0.03)(0.2624) = 0.00787
bin 9: (0.04)(ln 1.4) = (0.04)(0.3365) = 0.01346
bin 10: (0.05)(ln 1.5) = (0.05)(0.4055) = 0.02027

Sum them:

0.05498 + 0.03466 + 0.01070 + 0.00105 + 0 + 0.00095 + 0.00365 + 0.00787 + 0.01346 + 0.02027 = PSI = 0.148

Against the conventional bands from credit-risk monitoring, where PSI has been used for decades:

PSIReadingAction
< 0.10No meaningful shiftNothing
0.10 – 0.25Moderate shiftInvestigate; check whether the eval set is still representative
> 0.25Major shiftRefresh golden queries, re-tune thresholds, re-check clusters

Our 0.148 is squarely in “investigate.” Notice how lopsided the contributions are: bins 1 and 2 alone contribute 0.0896 of the 0.148, over 60%. PSI is dominated by bins that emptied out, not bins that filled up, because the logarithm punishes proportional loss far harder than proportional gain. That asymmetry is a feature: a bin going from 10% to 4% usually means something stopped working, and a bin going from 10% to 15% usually means something got popular.

What an emptied bin does, and why the floor matters

Change one thing: suppose bin 1 does not shrink to 0.04 but empties completely, because the traffic source that produced those queries was switched off. Then a1 = 0 and the term is

(0 − 0.10) · ln(0 ÷ 0.10) = (−0.10) · (−∞) = +∞

Your dashboard now reads inf and the alert is useless — it fires, but it tells you nothing about magnitude and it poisons any average you compute over bins or days. Apply the floor: replace a zero share with 0.5/n. With n = 50,000 that is 10−5:

(10−5 − 0.10) · ln(10−5 ÷ 0.10) = (−0.09999) · ln(10−4) = (−0.09999)(−9.2103) = 0.921

One empty bin contributes 0.921 all by itself, which swamps the 0.25 “major shift” band by a factor of nearly four. That is the right behaviour, not a bug in the floor: a segment of your traffic vanishing entirely is the largest distribution change that can happen, and PSI should say so loudly. The floor converts an uninterpretable infinity into a large, finite, comparable number.

The corpus side: content drift needs its own measurements

Both signals so far watch queries. Content drift lives on the other side and needs its own instruments, computed on a sample of the index rather than on traffic.

MeasurementDefinitionWhat a move means
Corpus centroid cosineMean of a fixed random sample of document vectors, compared week over weekThe mix of what you store has changed
CoverageFor a sample of live queries, the top-1 similarity achieved against the indexFalling = the corpus no longer answers what people ask
Cold regionsCluster the corpus; report clusters that received zero retrievals in 30 daysContent you pay to store and index and nobody ever reaches
Hot desertsCluster the queries; report clusters whose best coverage is below the OOD cutoffDemand with no supply — a content gap, and the single most actionable output of drift monitoring

That last row deserves emphasis because it converts a monitoring system into a product input. “Four hundred queries a day about the new billing flow, average top-1 similarity 0.44, nothing in the corpus above 0.55” is not an infrastructure alert. It is a documentation ticket with evidence attached, and it comes free from machinery you built to watch for regressions.

A worked reading. Suppose the corpus centroid cosine week over week is 0.9962 while the query centroid cosine is 0.9971. Both are far below the 0.99995 noise floor, so both moved for real. Two possibilities, and one extra measurement separates them: if coverage held steady, the corpus and the queries moved together — a product launch, working as intended, and the action is to refresh your frozen artifacts. If coverage fell, they moved apart — the questions went somewhere the content did not follow, and the action is content, not engineering.

Signal 3: the out-of-distribution rate

The most directly actionable of the three, because it measures the thing users experience. For each query, record the top-1 similarity. Establish the baseline 5th percentile — say it is 0.61. Then track the fraction of queries that fall below 0.61. By construction it starts at 5%. When it climbs, users are asking things your corpus cannot answer.

Set the alert with statistics, not vibes. The standard error of a proportion p over n samples is √(p(1−p)/n). With p = 0.05 and n = 50,000:

√( 0.05 × 0.95 ÷ 50,000 ) = √( 9.5 × 10−7 ) = 9.75 × 10−4
3σ = 2.92 × 10−3  →  alert above 5.29%

Which reveals how tight this instrument is at scale: a rise from 5.0% to 5.3% is already a three-sigma event. If your alert threshold is “10%,” you have a detector that fires only after the problem has been obvious to users for weeks.

Drift monitor — three signals, one twelve-week story

A new product line lands in week 5 and the queries follow it. No deploy, no model change, no code change. Slide the wave size up and scrub through the weeks. The three panels are the three signals we just derived, each with its own band. Watch the order in which they fire — centroid cosine moves first because it is the most sensitive, PSI crosses its band next, and the out-of-distribution rate is last because by then users are already complaining.

size of the new-content wave0.60
week now8

Drift is not always a bug

This is the part teams get wrong emotionally. Every signal above fires when the new product line launches. That launch is the company working. The corpus should contain the new vocabulary; users should be asking about it.

So the correct reading of a drift alarm is not “something is broken” but “the assumptions baked into your frozen artifacts are now stale.” The frozen artifacts are: the golden query set, every calibrated threshold (out-of-distribution cutoff, dedup cutoff, semantic-cache cutoff), the cluster assignments, and any hand-tuned reranker weights. Drift is the signal to refresh those, not to roll anything back.

The misconception: “we monitor retrieval quality, so we do not need drift monitoring.” You monitor retrieval quality on the golden set, and the golden set is one of the artifacts that drift invalidates. Drift monitoring is what tells you your quality monitoring has stopped measuring your product.

Concept to realization: the daily job

pythondef daily_drift_report(day_vecs, day_top1, base):
    # day_vecs: float32[n, 768] normalized query embeddings from the last 24h
    # day_top1: float32[n]      best similarity each query achieved
    # base:     baseline artifacts, frozen with the golden set

    c_now = day_vecs.mean(0)
    c_now /= np.linalg.norm(c_now)                       # [768]
    centroid_cos = float(c_now @ base.centroid)          # scalar, floor ~0.99995

    proj  = day_vecs @ base.pc1                             # [n] scalar projection
    share = np.histogram(proj, bins=base.decile_edges)[0] / len(proj)
    share = np.maximum(share, 0.5 / len(proj))            # the ln(0) guard
    psi   = float(((share - 0.1) * np.log(share / 0.1)).sum())

    ood   = float((day_top1 < base.p05_top1).mean())        # starts at 0.05
    se    = math.sqrt(0.05 * 0.95 / len(day_top1))
    return {
        "centroid_cos": centroid_cos, "centroid_alarm": centroid_cos < 0.9995,
        "psi": psi,                   "psi_alarm": psi > 0.10,
        "ood_rate": ood,             "ood_alarm": ood > 0.05 + 3 * se,
    }

Everything in that function is a dot product, a histogram, and a mean over one day of queries. On 50,000 queries it runs in under a second. There is no excuse for not having it, and its absence is why most teams learn about drift from a customer.

Your daily query centroid has a cosine of 0.9971 against the trailing baseline, computed over 50,000 queries. The derived sampling-noise floor is 0.99995. How should you read this?

Chapter 6: Evaluation in Production

Chapters 2 and 3 gave you ways to change the encoder. Chapter 5 gave you ways to notice the world changing. This chapter is the gate that stands between a candidate and production, and its central claim is uncomfortable: most embedding evaluations cannot detect the regression they are supposed to prevent, because they compare point estimates on samples that are far too small.

Build the golden set correctly

A golden set is a frozen list of queries, each with a judged set of documents that ought to be retrieved. Three construction rules that matter more than the size:

  1. Sample from real traffic, stratified by frequency. If you take the top 200 queries you have built a head-only set, and the head is the part that works. Draw from every frequency decile including the tail, where retrieval actually struggles.
  2. Judge documents, not chunk ids. Chunk ids change when the chunker changes; a golden set keyed on chunk ids silently dies at the next chunking tweak. Key on a stable document identifier plus a quoted snippet.
  3. Version it and record the judging date. Chapter 5 just told you it goes stale. A golden set with no date is a golden set nobody knows to refresh.

Which metric gates the deploy

MetricQuestion it answersUse it as a gate when
Recall@kDid the right document make the candidate set at all?Always, at the k you actually feed downstream
nDCG@10Is the ordering good?Results are shown to a human in order
MRRHow deep is the first correct hit?The interface shows one answer
Overlap@k vs currentHow much did anything change?Always — it needs no labels at all

For a retrieval-augmented generation stack, the primary gate is recall at the exact k you pass to the model. If the language model receives 8 chunks and reranks them itself, the ordering inside those 8 barely matters, but a document that misses the top 8 is invisible forever. Gate on recall@8, watch nDCG as a secondary.

nDCG by hand, once

Worth doing once so the number stops being magic. One query, graded relevance on a 0–3 scale. Your system returns, in order, documents graded 3, 0, 2, 1, then nothing relevant. Discounted cumulative gain uses gain 2rel − 1 and discount log2(rank + 1):

rank 1: (2³ − 1) ÷ log22 = 7 ÷ 1 = 7.000
rank 2: (2⁰ − 1) ÷ log23 = 0 ÷ 1.585 = 0.000
rank 3: (2² − 1) ÷ log24 = 3 ÷ 2 = 1.500
rank 4: (2¹ − 1) ÷ log25 = 1 ÷ 2.322 = 0.431
DCG = 7.000 + 0.000 + 1.500 + 0.431 = 8.931

The ideal ordering would have put the same three graded documents at ranks 1, 2, 3 as 3, 2, 1:

7 ÷ 1 + 3 ÷ log23 + 1 ÷ log24 = 7.000 + 1.893 + 0.500 = 9.393
nDCG = 8.931 ÷ 9.393 = 0.951

Notice how forgiving this is: a completely irrelevant document sitting at rank 2, above two relevant ones, cost under five points. That is why nDCG is a poor gate for a candidate-generation stage — it is designed to reward getting the top roughly right, and candidate generation cares about the whole set.

Recall@k by hand, and the definition trap inside it

Recall@k looks too simple to get wrong, and it is routinely gotten wrong. Take one query with three judged relevant documents, of which two appear in the top 8:

recall@8 = 2 ÷ 3 = 0.667

Now a second query with one judged relevant document, which does appear in the top 8:

recall@8 = 1 ÷ 1 = 1.000

Average over the two queries: (0.667 + 1.000)/2 = 0.833. That is macro recall, averaging the per-query rates. The alternative is micro recall, pooling first:

(2 + 1) ÷ (3 + 1) = 3 ÷ 4 = 0.750

Same data, two answers eight points apart, and both are called “recall@8.” Macro weights every query equally, which is what you want when queries represent users. Micro weights every judged document equally, which lets a handful of heavily-judged queries dominate. Pick macro, write it down in the evaluation spec, and never compare a number you computed one way against a number someone else computed the other way.

One more trap in the same neighbourhood: if a query has a relevant document that is not in the corpus at all, recall is capped below 1 for reasons that have nothing to do with the retriever. Judge relevance against what you actually indexed, or your gate will punish encoder changes for a content problem.

Overlap@k by hand

The label-free workhorse. Suppose for one query the two systems return

v1: {a, b, c, d, e, f, g, h}     v2: {a, c, e, i, j, k, l, m}

The intersection is {a, c, e}, so

overlap@8 = 3 ÷ 8 = 0.375

Average that over a few thousand live queries and you have a change-magnitude number that cost nothing, needed no judgments, and can be computed retroactively from the top8_ids you started logging in Chapter 4. An overlap of 0.375 sits in the “different system” band below — a big change, plausible for a genuine encoder upgrade, and an absolute requirement that you have labelled evidence before ramping.

The part everyone skips: is the difference real?

Say your candidate scores nDCG 0.941 against production’s 0.951 on 200 golden queries. A one-point drop. Ship or block?

Neither, yet, because you have not asked whether 0.010 is bigger than the noise. Per-query nDCG varies wildly — a standard deviation around 0.25 is normal, since some queries score 1.0 and some score 0. The standard error of the mean over 200 queries is

0.25 ÷ √200 = 0.25 ÷ 14.14 = 0.0177

The difference you are trying to resolve is 0.010 and your measurement error on each system alone is 0.0177. The comparison is meaningless.

Fix it with pairing. Run both systems on the same queries and analyze the per-query differences, which cancels the enormous query-to-query variation. A typical paired standard deviation is far smaller — take 0.08:

SEpaired = 0.08 ÷ √200 = 0.00566

Now 0.010 is 1.77 standard errors, which still does not clear the conventional 1.96. Pairing bought you a factor of three and it is still not enough. So compute the sample size you actually need instead of guessing.

Sizing the golden set from a non-inferiority requirement

State the requirement the way a deploy gate should: “I will ship if I can show the new system is not worse by more than δ = 0.010 nDCG.” That is a non-inferiority test. With an observed drop d and paired standard error SE, the condition is that the upper end of the confidence interval on the drop stays below the margin:

d + 1.96 · SE < δ

Suppose the observed drop is a small d = 0.004 and the paired sd is 0.08. Solve for n:

0.004 + 1.96 × (0.08 ÷ √n) < 0.010
1.96 × 0.08 ÷ √n < 0.006
√n > (1.96 × 0.08) ÷ 0.006 = 0.1568 ÷ 0.006 = 26.13
n > 26.13² = 682.9  →  n ≥ 683 queries
That is the answer to “how big should the golden set be?” Not 50, not “a few hundred,” but a number derived from the margin you care about and the variance you measured. Roughly 700 paired queries to resolve a one-point nDCG difference. If you cannot afford to judge 700, then be honest: your gate cannot see one-point regressions, and you should widen δ rather than pretend.

Label-free signals you get for free

Judging is expensive, so lean on the two comparisons that need no labels at all.

Overlap@k. Run the same live query through both indexes and measure how many of the top-k are shared. It is a pure change-magnitude measurement, and it is astonishingly informative:

Overlap@8What it meansWhat to do
> 0.90Cosmetic changeShip with light gates; the labelled evaluation cannot resolve a difference this small anyway
0.50 – 0.90Substantive but plausible upgradeFull paired evaluation before ramping
0.20 – 0.50Different systemOnly ship with strong labelled evidence and a slow ramp
< 0.20Suspect a version mismatch, not an upgradeCheck fingerprints before you check quality — this is what Chapter 0 looks like on a dashboard

That last row is the reason overlap@k belongs on the migration dashboard. Chapter 0’s outage would have shown overlap@8 near 0.02 within one minute of the deploy, with no labels, no golden set, and no waiting.

Score distribution. Log the top-1 similarity histogram for both systems. A healthy upgrade shifts it slightly. A version mismatch flattens and shifts it in a way that is visible at a glance — and a cache or threshold calibrated on the old histogram is now mis-calibrated, which is the bridge to Chapter 7.

Canary the index, not just the code

A canary deploy of a service routes a small share of traffic to new code. An embedding change needs a canary of the data: a second index, built over the same corpus snapshot, served to a small share of queries, with its own manifest.

Shadow (0% user-visible)
Sample 5% of live queries, run both, log both. Measure overlap@8 and score histograms. Users see only v1.
Canary (1%)
Real users on v2. Gates: recall@8 non-inferior, p99 latency within 10%, out-of-distribution rate flat, cost per query within budget.
Ramp (5 → 25 → 100%)
Re-check every gate at each step. A gate that only runs at 1% is a gate you have not run at scale.
Soak (7 days at 100%)
Weekly seasonality is invisible in a two-hour canary. Only after the soak do you drop the old index.

And gate on more than relevance. An encoder change moves latency (different sequence length handling), memory (different width), cost per query, and the distribution of similarity scores that every downstream threshold depends on. A candidate that improves recall by two points and doubles p99 has not passed.

A candidate encoder scores 0.010 nDCG below production on 200 paired golden queries, with a paired standard deviation of 0.08. What is the correct conclusion?

Chapter 7: Semantic Caching as an Ops Surface

A semantic cache is the most seductive object in this lesson. Embed the incoming question, look for a stored question whose embedding is within τ cosine, and if you find one, return the stored answer without calling the language model at all. It saves real money and real latency, it is fifty lines of code, and it is built out of exactly the machinery we have spent six chapters learning to distrust.

What makes it different from retrieval is one word: automatic. A retriever returns candidates and a downstream model gets to reject them. A cache returns a finished answer straight to the user. There is no second opinion. So a cache is a retrieval system whose false positives are shipped, and that changes the arithmetic completely.

Model it, then decide

Let p be the share of incoming queries that genuinely have a semantically equivalent entry already cached — the repeats. For those, the best cached neighbour scores high; call that distribution normal with mean 0.93 and standard deviation 0.04. For the other 1 − p, the closest cached entry is related but different, and its score is normal with mean 0.70 and standard deviation 0.08. Those two overlapping bell curves are the entire problem.

With Φ the standard normal cumulative function, at a threshold τ:

P(hit | same intent) = 1 − Φ( (τ − 0.93) ÷ 0.04 )
P(hit | different intent) = 1 − Φ( (τ − 0.70) ÷ 0.08 )
hit rate h = p · P(hit | same) + (1 − p) · P(hit | different)

The second term of that sum is the false-hit rate: the fraction of all traffic that receives a confident answer to a question it did not ask.

Run the numbers at three thresholds

Take p = 0.22 — 22% of traffic is a genuine repeat, which is a healthy support-desk figure. At τ = 0.90:

zsame = (0.90 − 0.93) ÷ 0.04 = −0.75  →  P(hit | same) = 1 − 0.2266 = 0.7734
zdiff = (0.90 − 0.70) ÷ 0.08 = 2.50  →  P(hit | different) = 1 − 0.9938 = 0.00621
true hits = 0.22 × 0.7734 = 0.17014    false hits = 0.78 × 0.00621 = 0.00484
h = 0.17014 + 0.00484 = 0.1750    false-hit share of hits f = 0.00484 ÷ 0.1750 = 2.77%

Per million queries: 175,000 cache hits, of which 4,844 are answers to a different question.

At τ = 0.95 the same arithmetic gives zsame = 0.50 and zdiff = 3.125, so P(hit|same) = 0.3085 and P(hit|different) = 0.000889. True hits 0.0679, false hits 0.000693, hit rate 6.86%, and 693 wrong answers per million. At τ = 0.85, zsame = −2.0 and zdiff = 1.875, giving a 23.9% hit rate and 23,709 wrong answers per million.

τHit rateWrong answers per millionGrowth factor vs τ = 0.95
0.956.9%693
0.9017.5%4,844
0.8523.9%23,70934×

Dropping the threshold from 0.95 to 0.85 multiplies your savings by 3.5 and your wrong answers by 34. Savings grow roughly linearly with the threshold move; false hits grow with the tail of a Gaussian, which is to say explosively. These two quantities are not on the same scale and never will be.

The break-even, derived

Convert both sides to money. Let ΔC be what you save on a hit — skipping a language-model call. A substantive retrieval-augmented answer with 4,000 input tokens at $3 per million and 500 output tokens at $15 per million costs

4,000 × $3/106 + 500 × $15/106 = $0.012 + $0.0075 = $0.0195 ≈ $0.02

Let L be what a wrong answer costs you: a support contact, a lost conversion, a small piece of trust. Put $0.50 on it. Net value per query is savings minus losses:

net = h · ΔC − (false hits) · L

Substitute h = true + false and group:

net = true · ΔC + false · (ΔC − L)

Since L > ΔC, the second term is a penalty. Set net to zero and divide by h to get the condition on the false-hit share f:

f < ΔC ÷ L
The whole cache policy in one inequality. With ΔC = $0.02 and L = $0.50, the cache is worth running only where the false-hit share is below 0.02/0.50 = 4%. Notice what is absent from that condition: the hit rate. How much you save does not enter the decision at all. You pick the threshold by capping the error rate, then take whatever savings that threshold happens to give you.

Check it against the table. At τ = 0.90, f = 2.77% < 4%: allowed. At τ = 0.85, f = 9.93% > 4%: forbidden, and indeed the net there is

0.21500 × $0.02 + 0.02371 × (−$0.48) = $0.00430 − $0.01138 = −$0.00708 per query

which is minus seven thousand dollars per million queries, from a feature installed to save money.

The uncomfortable case: when no threshold works

Raise L to $5 — a wrong answer in a medical, legal, or financial context. The condition becomes f < 0.02/5 = 0.4%. Push τ all the way to 0.99: zsame = 1.5 and zdiff = 3.625, giving true hits 0.0147 and false hits 0.000112, so

f = 0.000112 ÷ (0.0147 + 0.000112) = 0.76%

Still nearly twice the limit — at a threshold so tight that the cache now serves 1.5% of traffic. The different-intent distribution has a tail that does not thin out fast enough, so there is no threshold at which this cache is safe. Raising τ trades away all the value long before it buys enough safety.

The fix is not a number, it is a mechanism: put a cheap verifier between the hit and the user. A small model, or one fast language-model call on a short prompt, asked only “does this stored answer actually answer this new question, yes or no?” It reintroduces cost, but it caps the tail instead of hoping the tail is thin.

Price the verifier

Say the verifier costs Cv = $0.001 per hit, rejects 90% of false hits, and mistakenly rejects 2% of true hits. Every hit pays the verifier; only survivors are served:

net = (true × 0.98) · ΔC + (false × 0.10) · (ΔC − L) − h · Cv

Evaluate at τ = 0.90, where true = 0.17014, false = 0.004844, h = 0.17499, and L = $0.50:

0.16674 × $0.02 = $0.0033347
0.00048 × (−$0.48) = −$0.0002325
0.17499 × $0.001 = −$0.0001750
net = $0.0029272 per query = +$2,927 per million

Against the $1,078 per million the same threshold earned without a verifier. The verifier nearly triples the value of the cache, which is the opposite of what “adding a cost” sounds like it should do.

Now redo it at L = $5, the case where no threshold worked at all. Without the verifier, τ = 0.90 gives

0.17014 × $0.02 + 0.004844 × (−$4.98) = $0.0034028 − $0.0241231 = −$0.0207  →  −$20,720 per million

With the verifier:

$0.0033347 + 0.00048 × (−$4.98) − $0.0001750 = $0.0033347 − $0.0024123 − $0.0001750 = +$747 per million
The structural point. A threshold trades hit rate against error rate along a curve you do not control — it is set by the overlap of two distributions. A verifier changes the curve: it multiplies the error term by 0.10 while multiplying the value term by only 0.98. When tuning a parameter cannot reach your operating point, stop tuning and add a stage. That generalizes far beyond caches.
Cache threshold explorer — find the operating point

Teal is the score distribution for queries that genuinely repeat something cached; warm is for queries whose nearest cached entry is merely related. Everything right of the line is served from cache — teal correctly, warm wrongly. Move τ and watch the net dollars per million. Then raise the cost of a wrong answer and find the point where no threshold is profitable, which is the moment the honest answer becomes “add a verifier” rather than “tune the number.”

threshold τ0.900
cost of a wrong answer$0.50
share of traffic that repeats0.22

Why this belongs in an embedding-ops lesson

Because every number above is a property of one specific embedding space, and the cache is the surface where that dependency bites hardest.

EventWhat happens to the cacheRequired response
Encoder version bumpEvery stored key is in the old space; τ is calibrated on the old score distributionFingerprint the cache keys; a version change invalidates or partitions the whole cache
Content drift (Chapter 5)The score distributions move under a fixed τ, so f moves with no code changeRecalibrate τ on a schedule, driven by the drift signals
A false hit becomes popularOne wrong answer is served forever to a high-traffic queryTime-to-live plus a feedback hook: a thumbs-down evicts the entry
Corpus updated, answer now staleThe cached answer is correct for the question and wrong about the worldInvalidate on source-document change, not only on age

The first row is Chapter 0 replayed inside the cache, and it is even harder to see, because a cache does not log misses as errors and nobody has a dashboard for “answers that were confidently retrieved from the wrong bucket.” Bump the encoder without invalidating the cache and you get a system that serves old-space neighbours to new-space queries, indefinitely, silently.

python# the cache key must name the space, not just the query
def cache_lookup(question: str, fp: str, tau: float):
    q = embed_query(question)                       # float32[768], space = fp
    ns = cache.namespace(fp)                        # one index per fingerprint
    hit, score = ns.nearest(q)
    if hit is None or score < tau:
        return None
    if hit.source_rev != current_rev(hit.source_ids):   # world changed
        ns.evict(hit.key)
        return None
    metrics.observe("cache.hit_score", score)       # feeds recalibration
    return hit.answer

Namespacing by fingerprint is the important line. It makes a version bump degrade into a cold cache — a temporary cost increase, entirely visible on a dashboard — instead of a silent correctness failure. That is the pattern to internalize from this whole lesson: when a space changes, arrange for the system to lose performance rather than truth.

Skipping the language model saves $0.02 and a wrong answer costs $0.50. At τ = 0.85 the cache hit rate is 23.9% with a false-hit share of about 10%. What is the right move?

Chapter 8: The Runbook

Everything above, compressed into the document you actually want open when you are shipping an encoder change. Each phase has an exit criterion — a measurable thing that must be true before you move — and a rollback, which must stay cheap until the very end.

Preconditions: do not start without these

#PreconditionWhy it is a hard gate
P1The source text for every vector is retained and re-readableWithout it a re-embed is impossible, so every option in this lesson is closed (Chapter 1)
P2Every vector row carries a pipeline fingerprint; every index has a manifestYou cannot migrate between versions you cannot name (Chapter 4)
P3Read paths refuse a fingerprint mismatch rather than degradingOtherwise the migration’s own mistakes are silent
P4A golden set sized for the margin you care about (about 700 paired queries for one nDCG point)A smaller set cannot see the regression it exists to catch (Chapter 6)
P5Drift dashboards live: centroid cosine, PSI, out-of-distribution rateYou need a baseline from before the change to compare against
P6Capacity headroom for two full copies of the vectors and both graphsChapter 2’s 28 GB number; this is what actually stops migrations

If P1 fails, stop and fix that first; nothing else matters. If P6 fails, your options narrow to backward-compatible training or a learned bridge, because a shadow index is not physically available to you.

The decision: which migration are you doing?

Do you control the training of the new encoder?
No → skip to the size question. Yes → backward-compatible training is on the table.
Does a full backfill fit in an acceptable window?
Compute it: chunks ÷ (rate × GPUs). Under a day and you have the memory headroom → just re-embed.
Can you tolerate a day-one dip?
BCT at λ ≈ 1 buys ship-today at roughly 1.5 points below production, closing as the lazy backfill runs.
Neither is available?
Fit an orthogonal bridge on 100k paired embeddings for partial recovery while a long backfill grinds. Ship it with an expiry date.

The phases, with exit criteria

PhaseExit criterionRollback
0. BaselineGolden recall@8, nDCG@10, p99 latency, cost/query, top-1 score histogram all recorded for the current systemn/a
1. Version the reads100% of read paths filter on fingerprint; a deliberate mismatch in staging raises, not warnsRevert one commit; no data touched
2. Dual writev2 write success rate > 99.9% for 24 h; backlog queue drains to zeroTurn off the v2 branch; v1 path was never guarded by it
3. Backfill, newest firstCursor reaches the oldest document; row counts per fingerprint match within the known backlogStop the job; v2 index is unused so far
4. ReconcileWatermark sweep completes and finds fewer than 0.01% missing; a second sweep finds noneRe-run; idempotent by construction
5. Shadow readOverlap@8 in a plausible band (0.5–0.9); score histogram shifted, not scrambledStop sampling; users never saw v2
6. Canary 1%Paired recall@8 non-inferior at δ = 0.01; p99 within 10%; OOD rate flat; cost within budgetRouting flag to 0%
7. Ramp 5 / 25 / 100Every gate re-checked at each step, not just the firstRouting flag back one step
8. Soak 7 daysA full weekly cycle at 100% with no gate breachRouting flag; v1 index still resident
9. DecommissionDual write off, v1 index dropped, fingerprint retained in the registry foreverNone — this is the point of no return
Phase 9 is the only irreversible step, so treat it as a separate change on a separate day. The common failure is bundling “ramp to 100%” and “drop the old index” into one deploy to save a ticket. That converts every subsequent problem from a flag flip into a rebuild.

Post-conditions: the cleanup nobody schedules

A migration is not finished when traffic is at 100%. Every artifact calibrated on the old space is now wrong, and each one fails quietly in its own way:

ArtifactWhat is now staleFix
Semantic cacheKeys in the old space; τ calibrated on the old score distributionNamespace by fingerprint; recalibrate τ against the new distributions (Chapter 7)
Deduplication threshold“Cosine > 0.97 means duplicate” was measured on v1Re-measure on a labelled duplicate sample
Out-of-distribution cutoffThe 5th-percentile top-1 score has movedRecompute the percentile; reset the alert baseline
Cluster or topic idsCentroids are in the old space; ids reshuffleRe-cluster and publish an id mapping, or accept the break and version the ids
Drift baselinesCentroid and decile edges are from the old spaceRebuild every baseline from the first week of new-space traffic
Reranker / fusion weightsTuned against v1 score scalesRe-tune; score scales differ between encoders even when both are cosines

That table is the argument for keeping the number of things calibrated on raw similarity values as small as you possibly can. Every hard-coded 0.97 in your codebase is a hidden dependency on one model version.

The five that actually go wrong

SymptomMechanismDiagnosticFix
Confident, unrelated results immediately after a deployQuery encoder moved, gallery did notOverlap@8 near zero; top-1 scores still highRoll the encoder back; then do it properly with a fingerprint gate
Golden set fine, users complaining about recent contentBackfill without dual write; the missing 12% is the newest 12%Count rows per fingerprint bucketed by document ageWatermark sweep, then enable dual write and redo
Two services disagree about the same queryOne prepends the instruction prefix, one does notEmbed one string in both services and take the cosine — expect roughly 0.6, not 1.0Move the prefix into the fingerprinted spec; add the contract test
Quality decays slowly over two months, no deploysContent or query drift; frozen thresholds and a stale golden setCentroid cosine below its derived noise floor; PSI above 0.1Refresh golden set, recalibrate thresholds — do not roll anything back
Cache serves answers to different questionsEncoder bumped, cache not namespaced by fingerprintSample cache hits and read them; check the cache namespace against the current fingerprintNamespace keys by fingerprint; add a verifier if the loss per wrong answer is high

The one-page checklist

runbook# BEFORE
[ ] source text retained and re-readable for 100% of vectors
[ ] fingerprint on every row; manifest on every index; registry append-only
[ ] read path RAISES on mismatch (verified by a deliberate staging failure)
[ ] golden set sized from the margin: n >= (1.96 * sd_paired / (delta - d))^2
[ ] baselines captured: recall@8, nDCG@10, p99, cost/query, top-1 histogram
[ ] capacity: 2x vectors + 2x graph fits with headroom, during 2x write load

# DURING
[ ] dual write on, v2 branch guarded, failures enqueued not swallowed
[ ] backfill newest-first, cursor checkpointed, idempotent
[ ] reconcile sweep runs twice; second run finds nothing
[ ] shadow read: overlap@8 in band, score histogram shifted not scrambled
[ ] canary 1% -> gates -> 5% -> gates -> 25% -> gates -> 100%
[ ] soak seven days across a full weekly cycle

# AFTER
[ ] cache namespaced by fingerprint and recalibrated
[ ] dedup / OOD / rerank thresholds re-measured in the new space
[ ] drift baselines rebuilt from new-space traffic
[ ] golden set re-judged against the new corpus
[ ] v1 index dropped on a SEPARATE day, fingerprint kept forever
Traffic has been at 100% on the v2 index for two hours and every gate is green. What should you not do today?

Chapter 9: Connections

One idea has been underneath all nine chapters, and it is worth stating once more in the plainest possible form. An embedding is a coordinate, and coordinates are meaningless without the frame that produced them. Everything else in this lesson — the fingerprints, the dual writes, the anchor losses, the non-inferiority tests, the cache namespaces — is bookkeeping in service of that one sentence.

The strategies, side by side

StrategyShip latencyQuality you keepRequiresBest when
Full re-embedHours to weeks, set by corpus size100%Source text, 2× memory, dual writeSmall or medium corpus, memory headroom
Backward-compatible trainingDay one~97% of the new ceilingYou train the new model, and decide before trainingHuge corpus, or no shadow-index headroom
Learned orthogonal bridgeMinutesPartial, unquantified in advance~100k paired embeddingsIncident response, or a stopgap during a long backfill
Do nothing, monitorWhatever drift leaves youDrift dashboardsThe encoder is fine and the world is what moved
Forward-compatible trainingPlanned in advanceHigh, at a storage costStoring side information at write time, todayYou know a future upgrade is coming

The fourth row is the one to take seriously. A large share of “our retrieval got worse” incidents have no version change in them at all. Before you plan a migration, check whether the encoder is the thing that moved.

Where to go from here

Understand the object itself. Start at Vector Embeddings if any of the geometry here felt shaky, then Similarity Metrics for why cosine and not something else, and High-Dimensional Geometry for why anisotropy makes a centroid norm of 0.42 the normal case rather than a bug.

Understand where they are trained and stored. Contrastive Learning is the objective whose rotation invariance is the root cause of Chapter 1. Vector Databases covers the index structures whose memory footprint set the migration ceiling in Chapter 2, and Storage and Retrieval is the general theory of derived indexes, of which an embedding store is one instance.

Understand the system they sit inside. RAG is the consumer of everything here; Text Chunking is the pipeline stage that must be inside your fingerprint; Caching and CDNs gives the classical cache-invalidation background that Chapter 7 adapts to a probabilistic key.

Understand how to prove it works. Evaluation Statistics is the machinery behind Chapter 6’s sample-size derivation; Regression Testing for ML is the gate discipline generalized beyond retrieval; Embedding Benchmarks explains what a public leaderboard score does and does not tell you about your corpus; and GenAI Evaluation covers judging the answers rather than the retrieval.

Related operational surfaces. On-Device Embeddings multiplies every problem here by the number of client versions in the wild — a phone that has not updated in six months is a permanent old-space query source, which is the strongest argument for backward-compatible training that exists. AI Evaluation and Agent Evaluation extend the gating story to systems where retrieval is one step of many.

Five things to carry out of here

1. The text is the source of truth; the vector is a cache of a computation over it. Never delete the text.
2. Fingerprint the whole text-to-vector function — chunker, cleaner, prefix, pooling, normalization, precision, revision — not the model name.
3. On a version mismatch, refuse. Ten minutes of honest downtime is cheaper than three weeks of confident wrong answers.
4. Compute the noise floor before you read any monitoring number. A centroid cosine of 0.997 is a catastrophe and a one-point nDCG drop on 200 queries is nothing; neither is obvious without the arithmetic.
5. When a space changes, arrange for the system to lose performance rather than truth. A cold cache is a graph on a dashboard. A stale cache is an incident nobody detects.

References

  1. Shen, Xiong, Xia, Soatto. “Towards Backward-Compatible Representation Learning.” CVPR, 2020. arXiv:2003.11942 — the influence loss of Chapter 3.
  2. Ramanujan, Vasu, Farhadi, Tuzel, Pouransari. “Forward Compatible Training for Large-Scale Embedding Retrieval Systems.” CVPR, 2022. arXiv:2112.02805 — transform the old gallery instead of constraining the new model.
  3. Hu et al. “Learning Backward Compatible Embeddings.” KDD, 2022. arXiv:2206.03040 — the recommendation-system framing of the same problem.
  4. Kornblith, Norouzi, Lee, Hinton. “Similarity of Neural Network Representations Revisited.” ICML, 2019. arXiv:1905.00414 — CKA and why gram matrices are the invariant object.
  5. Huh, Cheung, Wang, Isola. “The Platonic Representation Hypothesis.” ICML, 2024. arXiv:2405.07987 — representations converge in geometry while staying mutually unreadable in coordinates.
  6. Schönemann. “A generalized solution of the orthogonal Procrustes problem.” Psychometrika 31(1), 1966 — the closed-form bridge of Chapter 1.
  7. Malkov, Yashunin. “Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs.” IEEE TPAMI, 2018. arXiv:1603.09320 — the index whose memory arithmetic drives Chapter 2.
  8. Muennighoff, Tazi, Magne, Reimers. “MTEB: Massive Text Embedding Benchmark.” EACL, 2023. arXiv:2210.07316 — where the “four points better” in Chapter 0 comes from, and why it does not transfer to your corpus.
  9. Kusupati et al. “Matryoshka Representation Learning.” NeurIPS, 2022. arXiv:2205.13147 — nested widths, which solve truncation and not compatibility.
  10. Gao, Yao, Chen. “SimCSE: Simple Contrastive Learning of Sentence Embeddings.” EMNLP, 2021. arXiv:2104.08821 — anisotropy and the alignment/uniformity view behind the centroid-norm assumption.
  11. Bang et al. “GPTCache: An Open-Source Semantic Cache for LLM Applications.” NLP-OSS workshop, 2023. arXiv:2308.02669 — the cache design Chapter 7 puts a price on.
Retrieval quality has been sliding for two months. There have been no deploys, the fingerprint on every vector and every query matches, and the golden set still scores 0.858 against a 0.860 baseline. What is the most likely explanation?
“Far better an approximate answer to the right question, which is often vague, than an exact answer to the wrong question, which can always be made precise.”
— John Tukey, 1962. A cross-space cosine of 0.985 is an exact answer to the wrong question, computed to seven decimal places, delivered on time. This lesson is the practice of making sure the question stays right.