Model migration (end-to-end recipe)
You're switching embedding models — a new model, a new dimensionality, or a new
distance metric. In Qdrant a vector's size and distance are immutable:
the engine will not alter them in place, and you must not recreate the whole
collection under a live service. The supported path is a three-beat dance:
add a new vector → re-embed your points into it → drop the old vector
revector owns the first and third beats as ordinary, reversible schema
operations. The middle beat — running your model over your points — is the
one thing a generic binary can't own, so revector shells out to your command via
the exec op. This page walks the whole
thing end-to-end, including the checkpoints where you stop and verify before
anything destructive happens.
If you only want the mechanics of the exec-hook itself, see Re-embedding (the exec-hook). This guide is the full recipe built on top of it.
Pick a strategy
There are two shapes, and the right one depends on which vector you're changing:
| Strategy | Use when | Cutover | Rollback |
|---|---|---|---|
| Named-vector swap (in place) | The collection uses named vectors and you can add a second one alongside the old. | Switch your query code from text_v1 to text_v2. | Roll back to "both vectors exist". |
| Collection rebuild + alias | You're changing the default/unnamed vector, or you want a clean blue/green collection. | switch_alias flips reads atomically. | switch_alias back to the old collection. |
The named-vector swap is the default — it's cheaper (no full copy of the collection) and keeps point ids stable. Reach for the alias rebuild when the vector you need to change is the unnamed default (which can't sit alongside a second default) or when you want a fully isolated new collection you can smoke- test before any traffic touches it.
Strategy 1 — Named-vector swap
Scenario: products has a named text_v1 vector (768-dim, an old model) and
you're moving to a 1024-dim model under the name text_v2.
Split the work across three migrations so each destructive boundary is its own deliberate, reversible step:
0005_add_text_v2_vector add the new (empty) vector reversible
0006_reembed_text_v2 exec-hook: fill it with the new model
0007_drop_text_v1_vector remove the old vector one-way
Migration 1 — add the new vector
revision: "0005_add_text_v2_vector"
down_revision: "0004_..."
description: add text_v2 vector for the new embedding model
up:
- op: create_vector
collection: products
name: text_v2
spec:
size: 1024
distance: Cosine
# down: auto-inverts to delete_vector (the vector is still empty here).
create_vector auto-reverses to
delete_vector, so this migration is freely reversible — at this point the new
vector holds no embeddings worth keeping.
Migration 2 — re-embed via the exec-hook
revision: "0006_reembed_text_v2"
down_revision: "0005_add_text_v2_vector"
description: re-embed products into text_v2 with the new model
up:
- op: exec
name: re-embed products → text_v2
command: "python scripts/reembed.py --collection products --target text_v2"
# `exec` has no automatic inverse. Re-running migration 1's down would already
# drop text_v2 and its data; only spell out a `down` here if you need a
# distinct compensating action.
down:
- op: exec
name: clear text_v2 (no-op placeholder)
command: "true"
The command runs via sh -c, inherits the environment and stdio, and a
non-zero exit aborts the migration — so make your script fail loudly. A
minimal, resumable re-embed script:
# scripts/reembed.py
import argparse
from qdrant_client import QdrantClient, models
from my_embeddings import embed # your new model
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--collection", required=True)
ap.add_argument("--target", required=True) # the new vector name
ap.add_argument("--source-field", default="text") # payload field to embed
ap.add_argument("--batch", type=int, default=256)
args = ap.parse_args()
client = QdrantClient(url="http://localhost:6334")
offset = None
while True:
points, offset = client.scroll(
collection_name=args.collection,
with_payload=True,
with_vectors=False,
limit=args.batch,
offset=offset,
)
if not points:
break
texts = [p.payload[args.source_field] for p in points]
vectors = embed(texts) # batch-encode with the NEW model
client.update_vectors(
collection_name=args.collection,
points=[
models.PointVectors(id=p.id, vector={args.target: v})
for p, v in zip(points, vectors)
],
)
if offset is None: # last page
break
if __name__ == "__main__":
main()
Notes that make this safe in production:
- Idempotent.
update_vectorsonly writes the namedtargetvector, so re-running after a crash just overwrites the same points — exactly what you want, since Qdrant has no transactional DDL and revector'sexecis resumable. - Only the target vector is touched. The old
text_v1and all payloads are left intact, which is what keeps migration 1's rollback lossless. - Fail loudly. Let exceptions propagate (non-zero exit) so a partial run aborts the migration instead of silently leaving half-embedded points.
Apply and verify
export REVECTOR_URL=http://localhost:6334
revector up # applies 0005 then 0006
revector status # confirm both are recorded as applied
Now verify before you drop anything. Point a few real queries at text_v2,
compare recall against text_v1, and confirm every point actually has the new
vector (a point missing from your --source-field would be skipped). This is
the checkpoint the three-migration split exists to give you.
If something's wrong, you can still roll all the way back — nothing destructive has happened yet:
revector down # undo 0006 (exec down)
revector down # undo 0005 → delete_vector text_v2
Migration 3 — drop the old vector (one-way)
Once you've cut your query code over to text_v2 and you're happy, retire the
old vector in its own migration:
revision: "0007_drop_text_v1_vector"
down_revision: "0006_reembed_text_v2"
description: drop the retired text_v1 vector
up:
- op: delete_vector
collection: products
name: text_v1
# No down: dropping a vector destroys its data. revector refuses the downgrade
# rather than pretend it's reversible.
revector up # applies 0007 — deletes text_v1
delete_vector is irreversible: revector will refuse a downgrade past this
point instead of pretending the old embeddings can come back. Keeping it in a
separate revision means everything before it stays rollback-able, and the
destructive step is an explicit, reviewable boundary.
Strategy 2 — Collection rebuild + alias
Use this when you're changing the default/unnamed vector, or you want a
fresh collection you can smoke-test in isolation before any read touches it.
Callers talk to a stable alias the whole time; you build products_v2
alongside products_v1 and flip the alias atomically.
Assume your app already reads through the products alias (if it reads the raw
collection name, add a create_alias first and switch your client to the
alias).
Migration 1 — build the new collection
revision: "0005_build_products_v2"
down_revision: "0004_..."
description: build products_v2 with the new model dimensions
up:
- op: create_collection
name: products_v2
spec:
vectors:
"":
size: 1024 # new model's dimensionality
distance: Cosine
hnsw_config: { m: 16, ef_construct: 128 }
# down: auto-inverts to delete_collection products_v2 (still empty).
Migration 2 — re-embed into the new collection
revision: "0006_reembed_products_v2"
down_revision: "0005_build_products_v2"
description: embed all points into products_v2 with the new model
up:
- op: exec
name: re-embed products_v1 → products_v2
command: "python scripts/reembed_into.py --source products_v1 --target products_v2"
Here the script reads points (and payloads) from products_v1, embeds with the
new model, and upserts full points into products_v2. Same idempotency and
fail-loud rules as Strategy 1; the difference is you're writing whole points
into a separate collection rather than one named vector in place.
Migration 3 — flip the alias (the zero-downtime cutover)
revision: "0007_switch_products_alias"
down_revision: "0006_reembed_products_v2"
description: point the products alias at products_v2
up:
- op: switch_alias
alias: products
to_collection: products_v2
# switch_alias does NOT record the previous target — spell out the inverse.
down:
- op: switch_alias
alias: products
to_collection: products_v1
switch_alias is atomic, so reads
move from v1 to v2 with no gap. Because you supplied a down, rolling this
migration back instantly repoints the alias to the old collection — your
blue/green safety net. Keep products_v1 around until you're confident, then
retire it in a final, one-way migration:
revision: "0008_drop_products_v1"
down_revision: "0007_switch_products_alias"
description: drop the retired products_v1 collection
up:
- op: delete_collection
name: products_v1
# No down — delete_collection destroys data; revector refuses the downgrade.
Checklist & gotchas
- Split destructive steps into their own migration.
delete_vector/delete_collectionare one-way; isolating them keeps every earlier step reversible and gives reviewers an explicit checkpoint. - Verify between re-embed and drop. Check recall and coverage on the new vector/collection before the destructive migration runs — that gap is the whole point of the split.
- Make the embed script resumable.
execre-runs cleanly after a failure; useupdate_vectors(named-vector swap) or idempotent upserts (rebuild) so a retry overwrites rather than duplicates. - Let the script fail loudly. A non-zero exit aborts the migration; swallow errors and you'll record a "successful" half-embedded state.
- Coordinate the model side with the
qdrant-model-migrationskill — revector orchestrates the schema and the cutover; that skill drives the embedding work itself. - Don't move points between clusters with revector. That's data movement —
use
qdrant/migration. revector only re-embeds in place via the exec-hook.