embedding migration
docs/guides/embedding-migration.md
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 remains the configless runtime fallback for existing brains
that never picked a model — new installs default to voyage:voyage-4) — 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 voyage:voyage-4 --dim 1024 --dry-run
# Run it (interactive confirm shows chunk count + $ estimate first).
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024
# Non-interactive (cron / scripts): --yes is required, else exit 2.
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --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).
Targets on a provider with an announced shutdown are refused (a paid re-embed
onto a dying API would strand the brain). Self-hosting a wire-compatible
endpoint behind a provider_base_urls override? --force-sunset-target is
the explicit escape hatch.
Recommended targets
voyage:voyage-4 --dim 1024(the new-install default). OneVOYAGE_API_KEYcovers embedding, thererank-2.5reranker, and the multimodal model; the voyage-4 family shares one embedding space, so you can later point the query model atvoyage-4-largeorvoyage-4-litewithout reindexing. Note: 1280 is not a valid Voyage width (valid: 256/512/1024/2048), so a legacy 1280d brain gets a one-time schema/HNSW index rebuild to 1024 — the command handles it, and it is resumable if killed.openai:text-embedding-3-small --dim 1280— the keep-your-width alternative: OpenAI's text-embedding-3 models support flexible dims, so a 1280d brain keeps its column (no schema rebuild). No reranker coverage on the OpenAI key.
Set the target's API key via export VOYAGE_API_KEY=... (or edit
~/.gbrain/config.json directly) — do NOT use gbrain config set voyage_api_key: that writes the DB plane, which the embedding pipeline never
reads.
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 target-aware paste-ready commands — the Voyage
command at its valid 1024 width, plus an OpenAI keep-width alternative with
your actual width filled in when that width is valid there — reading the real
vector(N) column, not the config value, which can drift.
How affected brains find out (provider sunsets)
Three surfaces flag a brain whose embedding model, reranker, or custom embedding columns are 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), and ZE-backed customembedding_columnsentries are flagged too. The message carries target-aware paste-ready migration commands (Voyage at 1024; OpenAI keep-width when your width is valid there). 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, plus a stage-2 banner per brain.- The v0.46.3 version migration (runs via
gbrain upgrade/gbrain apply-migrations) — detect-and-notify only: it checks the host brain's exposure (embedding, reranker, custom columns), prints the ACTION REQUIRED block, and files an agent action item pointing atskills/migrations/v0.46.3.0.mdin~/.gbrain/migrations/pending-host-work.jsonl. It never changes config or spends money on your behalf.
All of them 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. When
GBRAIN_EMBEDDING_MODEL/GBRAIN_EMBEDDING_DIMENSIONSare set and DISAGREE with the target, the live run refuses (config-says-new / runtime-embeds-old is the #1421 damage class);--ignore-env-overridefor deliberate experiments. When they AGREE with the target the run proceeds with a loud notice (env-first deployments are legitimate; keep the env in sync everywhere gbrain runs). Nothing load-bearing trusts the env either way: the "nothing to migrate" decision verifies the DATABASE (column widths, NULL censuses, signature census, the un-merged file plane), so a pre-set env var cannot fake a completed migration. - Apply. When the target width differs from the actual column width,
runs the atomic schema transition owned by
embedding-migration.ts(the survivor module), 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. Re-running with a DIFFERENT --to target while a migration is in
flight refuses and names both options: the exact resume command for the
original target, or the same command with --retarget to abandon it
deliberately (the marker records the superseded target in its history).
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
The migration handles the reranker in the same run (--reranker auto is the
default): when the ACTIVE reranker — resolved through the mode bundles, so
the common no-explicit-config case counts — is on the outgoing provider or a
sunsetting one, and the target provider ships a reranker, the run probes it
live and switches search.reranker.model under the same consent gate (config
write + query-cache purge in one transaction). Overrides: --reranker off
disables reranking, --reranker keep leaves it, --reranker <provider:model> picks explicitly (validated before anything runs). When the
target provider has no reranker (OpenAI), the run prints an ACTION line with
the exact commands instead of silently enabling a third provider:
gbrain config set search.reranker.model voyage:rerank-2.5 (needs
VOYAGE_API_KEY) or gbrain config set search.reranker.enabled false. A
failed reranker probe keeps the previous config and is reported as
switch_failed — never silent, never fatal to the migration.
Status (read-only, spend-free)
gbrain migrate embeddings --status [--json] reports every config plane (env
presence, file, DB — API keys as presence booleans only), actual column
widths (including facts / query_cache), NULL and chunkless censuses, the
page-signature census, the in-flight marker with the exact resume command,
and the last completion record including its smoke-check outcome. It is the
mid-incident "where am I?" surface; gbrain doctor's
embedding_migration_state check surfaces the same marker on every doctor
run.
Custom embedding columns
There is no automated off-ramp for custom embedding_columns entries:
migrate embeddings covers the primary column only. Re-declare each custom
column's config on the new provider and re-embed its content, or drop the
column config.
Self-hosting instead of migrating
If the outgoing model's weights are available (zembed-1's are Apache-2.0),
self-hosting preserves your existing vectors — no re-embed at all — but only
when the embedding signature doesn't change: keep the SAME model id
(zeroentropyai:zembed-1) and point its base URL at your endpoint with
gbrain config set provider_base_urls.zeroentropyai <url>. The endpoint
must speak ZeroEntropy's wire dialect (/models/embed,
{results: [...]} responses) — the model id routes through a ZE-specific
compat fetch, so a generic OpenAI-compatible llama-server or Ollama
endpoint will NOT work without a compat proxy in front. Switching the
provider id instead (e.g. llama-server:zembed-1) changes
pages.embedding_signature, and the next stale-embed pass re-embeds
everything — a full re-embed, not a zero-cost move. This path survives only
until the September removal release deletes the zeroentropyai recipe. The
migration command is for when you'd rather move to a hosted provider.