Wave-assembled from PR #3548 by @time-attack. Composition conflicts vs wave item #3993 resolved: EmbedResult keeps both chunkless_pages_healed and the PR's lock_skipped; KEY_FILES.md keeps the #3993 embed entry and takes this PR's migrate-embeddings entry. Co-Authored-By: Garry Tan <garrytan@gmail.com>
8.9 KiB
Embedding migration — moving a brain to another embedding provider
gbrain migrate embeddings re-embeds an entire brain onto a different
embedding provider/model, safely and resumably. It is the forward path off a
sunsetting provider (for example ZeroEntropy's hosted API, which shuts down
2026-09-04 and is the shipped default for brains that never picked a model) —
but it is provider-agnostic: any configured provider:model works as a
target.
Also reachable as gbrain retrieval-upgrade — the alias that gbrain doctor
repair hints and the README point at.
Quick start
# Preview the work + cost. Changes nothing.
gbrain migrate embeddings --to openai:text-embedding-3-small --dry-run
# Run it (interactive confirm shows chunk count + $ estimate first).
gbrain migrate embeddings --to openai:text-embedding-3-small
# Non-interactive (cron / scripts): --yes is required, else exit 2.
gbrain migrate embeddings --to voyage:voyage-3-large --yes
--dim <N> overrides the target width; it defaults to the provider recipe's
declared width and is required for recipes that don't declare one (litellm,
llama-server, and other bring-your-own-model providers).
Pick --dim = your brain's current column width when the target supports
it. A different width triggers the destructive schema transition (column +
index rebuild across all three dim-pinned tables); the same width skips it
entirely. gbrain doctor (check provider_sunset, for providers with an
announced shutdown) prints the paste-ready command with your actual width
already filled in — it reads the real vector(N) column, not the config
value, which can drift.
How affected brains find out (provider sunsets)
Two surfaces flag a brain whose embedding model (or reranker) is on a provider with an announced hosted-API shutdown, such as ZeroEntropy (2026-09-04):
gbrain doctor— theprovider_sunsetcheck warns on every run until the brain is off the provider. After the shutdown date it escalates tofailonly when embedded vectors actually exist on the dead provider (retrieval is genuinely down); a zero-vector brain whose config merely resolves to the dead default stayswarn, so doctor-as-CI-gate setups don't start exiting 1 on the date. The reranker side resolves through the same plane search actually reranks with (the mode bundle +search.reranker.*overrides). The message carries the paste-ready migration command with the brain's actual--dim. Accepted the risk?gbrain config set doctor.suppress_provider_sunset truesilences it.gbrain upgrade— a one-shot banner (gated byze_sunset_notice_shown) with the same two fixes.
Both state the full consequence: after the shutdown, existing vectors become unqueryable — query embedding uses the same endpoint as ingestion — not just new content.
What it does, in order
- Plan. Counts every chunk not already in the target embedding space — including chunks on pages with no recorded embedding signature (pages embedded before the v108 provenance stamp). Prices the re-embed from the pricing table; unknown providers print "estimate unavailable" instead of a fabricated number.
- Consent gate. Prints the plan; requires an interactive
yor--yes. Non-TTY without--yesrefuses with exit 2 (mirrors thereindex-codegate in spend-controls). Unlike the pure cost gates there,spend.posture=tokenmaxdoes not bypass this one: posture waives the spend ceiling, and this gate also guards a destructive schema rebuild. Undertokenmaxthe dollar figure is marked informational and the confirmation is still asked.--yesis the single scripted bypass. - Live probe. One tiny embed against the TARGET provider before any mutation — validates the API key, model id, and dimension support in a single call. A bad key fails here, with nothing changed.
- Env-override gate. Refuses when
GBRAIN_EMBEDDING_MODEL/GBRAIN_EMBEDDING_DIMENSIONSwould silently defeat the switch at runtime (the same guardze-switchuses).--ignore-env-overridefor people running deliberate experiments. - Apply. When the target width differs from the actual column width,
runs the same atomic schema transition
ze-switchuses, in one transaction. It rebuilds all three dim-pinned text-embedding-space columns —content_chunks.embedding,query_cache.embedding, andfacts.embedding— at the new width, preserving each column's type (vectorvshalfvec) and recreating its HNSW index. Missing any of the three leaves it silently broken: a narrowquery_cache.embeddingmakes every cache write and read fail by design (the cache swallows errors so it can never break search) for a permanent 0% hit rate, and a narrowfacts.embeddingfails every per-fact embed write. The image/multimodal columns ARE deliberately untouched — they use separate models whose dimensions are independent of the text embedding model. Writesembedding_model+embedding_dimensionsto BOTH config planes (file plane for the runtime gateway, DB plane for doctor), invalidates every chunk still in the old space — including NULL-signature pages — and purges the semantic query cache so stale cached results can't be served across the swap. - Re-embed. The standard embed pipeline (
embed --stale --catch-up) with per-source single-flight locks, rate-limit backoff, stderr progress, and optional DB-contention pacing (--pace[=mode]).
What the rebuild deletes
The dimension change deletes every stored embedding vector in the brain —
they are in the old model's space and unusable. They are not recoverable:
going back to the previous provider means paying for a second full re-embed.
content_chunks vectors are rebuilt by the re-embed pass, the query cache
refills on the next query, and fact embeddings are rewritten on their next
write (or a gbrain extract pass).
Resume after a kill
The NULL-embedding column is the checkpoint. If the run is killed (or some
pages fail to embed), re-run the same command: chunks already embedded on
the target are never re-embedded, the schema/config steps no-op, and the run
continues where it stopped. An in-flight marker (embedding_migration.state
in DB config) records the target; it is cleared only when the backlog drains
to zero.
One caveat after a HARD kill (SIGKILL, crash, power loss — not Ctrl-C): the
run's per-source single-flight embed lock is left behind, and an immediate
re-run skips the re-embed and reports the migration as paused. The command
says so explicitly (lock_skipped in --json); the lock expires on its own
after at most 60 minutes, then the same re-run resumes normally.
A page whose chunks straddle two stale batches is embedded correctly but not
stamped by the embed loop (which only stamps all-or-nothing per batch), so the
migration runs one reconcile pass after the drain that stamps every
fully-embedded page. Without it a large brain would report "incomplete" and the
re-run would pay again for those pages. --batch-size N tunes the batch
(default 2000).
--no-embed applies schema + config + invalidation and stops, so you can run
the (potentially long) re-embed later or in the background:
gbrain migrate embeddings --to openai:text-embedding-3-small --yes --no-embed
gbrain embed --stale --catch-up --include-null-signature --background
During the migration
While the re-embed runs, semantic search returns degraded (lexical-arm-only)
results for not-yet-re-embedded content. Pick a quiet window for large
brains, or use --pace to keep the DB responsive.
Pages without an embedding signature (#3391)
Pages embedded before provenance stamping have embedding_signature IS NULL
and are grandfathered by the routine stale sweep (so an upgrade never
surprise-re-embeds a whole corpus). After a provider swap that grandfather
clause would silently leave those pages in the OLD embedding space — mixed
vector spaces in one index, degrading retrieval with nothing in the logs.
gbrain migrate embeddingsalways includes them.- Plain
gbrain embed --stalewarns when a model swap leaves NULL-signature pages behind, andgbrain embed --stale --include-null-signaturere-embeds them.
Reranker
Migrating embeddings does not touch the reranker. If
search.reranker.model points at the outgoing provider, the plan prints a
warning; disable it (gbrain config set search.reranker.enabled false) or
point it at another provider.
Self-hosting instead of migrating
If the outgoing model's weights are available (zembed-1's are Apache-2.0),
serving them locally via llama-server / ollama / a LiteLLM proxy
preserves your existing vectors — no re-embed at all. Point
embedding_model at the local recipe and keep the same dimensions. The
migration command is for when you'd rather move to a hosted provider.