The re-embed ceremony
In this section
A store's vectors are only comparable to each other. Similarity is a distance in one space, and two vectors produced by different embedding arithmetic are not in the same space — even at the same model and the same width. When the engine that writes a store's vectors changes, everything written after the change ranks against everything written before it in a way that means nothing.
Brainy already refuses to serve that silently: a cold open whose configured model disagrees with the family stamp raises DiskAnnModelMismatchError, and its message names the cure — re-embed canonical under the new model and rebuild the vector family explicitly.
The re-embed ceremony is that cure, as a door.
// Cost it first. Writes nothing.
const plan = await brain.index.reembed({ dryRun: true, targetVariant: 'fp32-candle' })
console.log(plan.summary)
// Then run it.
const done = await brain.index.reembed({ targetVariant: 'fp32-candle' })
console.log(done.summary)TSWhat it guarantees
It is online. The doors stay open. Reads and writes are served for the whole walk, which on a real store is minutes to hours.
It serves the OLD space, whole, until it completes. Converged vectors go into a durable staging ledger under _cor_reembed/, which no read path consults. The live family keeps answering every query from the space it was already in. There is no interval in which a search ranks a new-space vector against an old-space one — the family is exchanged in a single act at the end.
It is resumable. The ledger's marker records the canonical cursor the staged prefix is complete through, plus the census of that prefix. A process killed two thirds of the way through resumes: nothing already embedded is embedded again, and the stamp is never left half-flipped, because the flip is one call.
It yields to your traffic. The walk runs in installments and gives up the rest of an installment the moment a foreground door moves. Embeds are batched.
It never runs by itself. Nothing schedules it. A ceremony re-embeds an entire corpus — the most expensive thing the engine can be asked to do — and only a person decides to spend that.
What it reports, by class
Every canonical row lands in exactly one class, and every class is counted. None of them is silently skipped.
class | what it is | what happens to it |
|---|---|---|
| a real vector at the model's width, and text to re-derive it from | re-embedded and converged |
| a real vector at a different width — a caller's own embedding function | untouched. Its space is the caller's, not ours. It is not in the new family either: no family of this width can hold it |
| a real vector at the model's width and no stored content | blocks the flip (see below) |
| no vector leg, an empty leg (a deferred embed that has not landed), or a zero-norm placeholder | nothing to converge; not in the family either way |
The report carries counts, walls and the stamp transition. It never carries row content.
Why a text-less row stops the ceremony
A row whose content is gone cannot be re-embedded, so its vector stays in the old space. Publishing a family stamped for the new space while some of its vectors sit in the old one would make the stamp false, and the stamp's only purpose is that the next open can trust it. So the flip refuses, by name, with the count.
The cures, in order of preference:
Give those rows their content back. Any ordinary write re-embeds them in the current space.
Delete them, if the content is genuinely gone.
Then re-run with { restage: true }. A plain re-run resumes from the staged prefix and would not re-survey rows repaired inside that prefix, so it would refuse again with the same count. restage discards the ledger and surveys from the beginning — it costs every embed again, which is why it is never the default.
Writes that land while it runs
An ordinary write can land on a row at any point between the ceremony staging it and the flip reaching it. Such a write went through the running engine's embedder, which is the space the ceremony is converging into, so it is already correct and it is newer than what the ceremony staged.
The flip reads each row before it writes it and compares against the vector the walk saw. A row nobody touched is converged; a row that was rewritten keeps its newer vector; a row that was deleted is not resurrected. All three are counted and named in the report.
The flip, and its one window
The flip has two halves, in this order:
canonical — the converged vectors are written into the entity tree;
the family — the vector family is republished as a projection of canonical, stamped with the new space, in one atomic act.
Between the two, canonical is partly in the new space while the family — and therefore every query — is entirely in the old one. That interval is safe to read through and it is resumable: the marker says flipping, and a ceremony that finds one finishes it rather than starting over. It is a pure write pass over the convertible rows, not a walk that embeds anything.
The one thing that must not happen in that window is an unrelated pass pulling canonical into the index, which would mix the spaces. The engine refuses rather than relying on you to remember: the incremental vector fill — the pass repairIndex() drives, and the one an open's read gate can trigger by itself — refuses by name while a flipping marker stands, and says that finishing the ceremony is the cure. It is: the ceremony's publish rebuilds the family from canonical and covers every row the fill would have added.
What it costs
Two terms, and they are very different sizes.
The embedder — one forward pass per convertible row. This dominates. The dry run measures a real sample batch and PROJECTS the wall from it, so the number you get is your model on your hardware. For a reference point: MEASURED on a development laptop, the native Candle engine at 384 components runs 31.1–31.6 ms/row (≈32 rows/s), and — worth knowing before you tune anything — that rate is flat across batch sizes 16, 32 and 64, so a bigger batch buys latency smoothing rather than throughput.
Everything else — the canonical read, the classification, the ledger, the canonical apply and the projection. MEASURED on a 14,000-row store at 384 components (
scripts/reembed-throughput.mjs, development laptop, 2026-08-31, two runs): 0.208–0.247 ms/row, 3,776–4,480 rows/s. That is 3–3.7 s of ceremony overhead against roughly 7½ minutes of embedding — under 1% of the wall.
The ledger costs disk while the ceremony runs: two f64 blocks per converged row (the new vector and the one it replaces), which is rows × dim × 16 bytes. It is deleted when the flip completes.
The scale ceiling, stated
The flip hands the new corpus to the native publish as one flat buffer, because a re-embed changes every vector and there is no old slot to retain. Peak residency is therefore rows × dim × 4 bytes, and the flip refuses by name above 2 GiB — roughly 1.3 M rows at 384 components — rather than discovering the ceiling as an OOM two hours in. Lifting it is a native-side change (a corpus file the builder mmaps), not a bigger number.
The precision variant
A store's embedding space is three things: the model id, the width, and the arithmetic that produced the vectors. The third one is why the same model at the same width can still be two spaces — a full-precision forward pass and an 8-bit fallback do not produce comparable vectors.
Pass it explicitly:
await brain.index.reembed({ targetVariant: 'fp32-candle' })TSA build whose embedding engine reports its own precision supplies it automatically. A build that cannot refuses rather than stamping a guess: the stamp is only worth having if it is true.