Skip to main content

Migrate to a New Embedding Model

This runbook explains how to migrate an existing collection from one embedding model to another without rebuilding the collection. You add a new vector field for the new embedding representation, migrate existing and incoming data to it, validate the new representation, and then move production search to the new field.

When to use this runbook

Use this runbook when you need to replace the embedding representation of an existing collection while keeping the collection and its existing data in place, such as:

  • Upgrade to a new embedding model.

  • Switch embedding providers or model families.

  • Change the embedding configuration so that old and new vectors are incompatible.

  • Migrate without rebuilding the existing collection.

If you want to add an additional representation without replacing the existing one, such as image embeddings for multimodal retrieval, follow the multimodal retrieval runbook instead.

How migration works

Embeddings generated by different models generally belong to different vector spaces and should not be mixed in the same vector field. Instead of overwriting the current field, add a nullable vector field for the new representation and keep both fields available during the migration.

New and updated entities begin receiving the new representation before historical entities are backfilled. Production search continues to use the existing model and field until the new representation has been fully populated and validated. After the cutover, keep the original representation available during the rollback window.

python
┌─────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────┐
│ embedding_v1 │ │ embedding_v1 │ │ embedding_v1 │
│ serves production │ ───▶ │ embedding_v2 being │ ───▶ │ kept for rollback │
│ search │ │ populated │ │ embedding_v2 serves │
│ │ │ │ │ production search │
└─────────────────────┘ └─────────────────────────┘ └─────────────────────┘

v1 only v1 + v2 v2 primary

The key principle is to keep the old representation usable until the new one is complete, validated, and ready to serve production traffic.

Before you start

Before starting the migration, make sure that:

  • You have selected the new embedding model and confirmed its vector dimension and similarity metric.

  • You still have access to the source content used to generate embeddings for existing entities.

  • Each source record can be mapped to an existing entity through a stable primary key.

  • You have a fixed evaluation set that you can use to compare the current and new retrieval paths during migration validation.

  • You have enough embedding, write, indexing, and storage capacity to complete the migration without unacceptable impact on production traffic.

Step 1: Prepare the migration data

Before changing the collection or production application, prepare a clean and validated dataset for the new embedding representation. Generate the new embeddings from the authoritative source data, clean up duplicates or anomalous records when needed, and verify that the staged data is ready for backfill.

1

Collect the source content.

If the source content is stored in the existing collection, use a query iterator to read the entities in batches. Retrieve the primary key, the source field used to generate embeddings, and, if available, a version, timestamp, or content hash.

To export the data from Milvus, use the query iterator as follows:

python
iterator = client.query_iterator(
collection_name="documents",
batch_size=1000,
filter="",
output_fields=["id", "text", "content_version"],
)

for batch in iterator:
save(batch, "migration-source")

The prepared source data should look like:

plaintext
id | text | content_version
1001 | "Example document A" | 42
1002 | "Example document B" | 17

Before continuing, confirm that primary keys are present and unique and that every in-scope entity has the source content required by the new embedding model.

2

Generate embeddings with the new model.

Generate embedding_v2 from the prepared source data using the model and configuration qualified in the previous step. Preserve the primary key and source version with each generated vector.

The following pseudocode illustrates the processing flow:

python
# Pseudocode
source_records = read("migration-source.parquet")

generated = []
failed = []

for record in source_records:
try:
generated.append({
"id": record["id"],
"embedding_v2": embed(
record["text"],
model="new-embedding-model",
),
"content_version": record["content_version"],
})
except Exception as error:
failed.append({
"id": record["id"],
"reason": str(error),
})

save(generated, "embedding-v2.parquet")
save(failed, "embedding-v2-failures.json")

Keep failed primary keys and their failure reasons instead of silently skipping them.

3

Clean the staged data if needed.

Pipeline retries, repeated exports, or failed processing runs can introduce duplicate or anomalous records into the staged dataset. Clean these issues before backfill.

Use primary-key deduplication when the same entity appears multiple times. If you suspect near-duplicate or abnormal embeddings, use vector similarity deduplication or anomaly detection to identify records for review. For details, refer to Primary-Key Deduplication, Vector Similarity Deduplication, and Anomaly Detection.

Before continuing, confirm that no unexplained duplicate primary keys or suspicious records remain.

4

Validate the generated data.

Check that primary keys are present and unique, vectors have the expected dimension and valid numeric values, and every source record is accounted for.

The following pseudocode illustrates the validation:

python
# Pseudocode
source_records = read("migration-source.parquet")
generated = read("embedding-v2.parquet")
failed = read("embedding-v2-failures.json")

EXPECTED_DIM = 1024

source_ids = {record["id"] for record in source_records}
generated_ids = {record["id"] for record in generated}
failed_ids = {record["id"] for record in failed}

assert len(generated_ids) == len(generated)

for record in generated:
vector = record["embedding_v2"]
assert record["id"] is not None
assert len(vector) == EXPECTED_DIM
assert all(is_valid_number(value) for value in vector)

unaccounted_ids = source_ids - generated_ids - failed_ids

if unaccounted_ids:
raise ValueError(
f"{len(unaccounted_ids)} source records are unaccounted for"
)

Before continuing, make sure there are no unexplained missing records, invalid vectors, or primary-key conflicts.

5

Stage the migration data and record the baseline.

Save the validated migration dataset in a supported format such as Parquet, JSON, Lance, or CSV. Upload the staged data to an External Volume accessible to the backfill job. For how to create an External Volume, refer to External Volumes.

Record the source watermark or snapshot time, embedding model and version, vector dimension, staged dataset, and record counts. Keep this baseline with the migration artifacts for reconciliation and final validation.

yaml
migration_id: embedding-v2-2026-08
source_watermark: 2026-08-25T02:00:00Z
embedding_model: new-embedding-model
vector_dimension: 1024
source_records: 10000000
generated_embeddings: 9999850
failed_records: 150
staged_dataset: embedding-v2.parquet

The migration dataset is ready when every source record is either represented in the staged dataset or explicitly tracked as failed or excluded.

Step 2: Prepare readers and writers

Prepare the application to support both the current and new embedding representations before changing production traffic. Keep the read and write switches independent so you can move writes first, validate the new representation, and switch reads later.

For reads, define two retrieval profiles that always pair the query embedding model with the matching vector field:

plaintext
v1 = old embedding model + embedding_v1
v2 = new embedding model + embedding_v2

Do not encode a query with one model and search the vector field generated by another.

For writes, prepare the insert and update paths to generate embedding_v2 from the same source content used for the entity. Do not enable the new write path yet; you will switch production writes after adding the v2 field and index.

Keep the two controls separate, for example:

plaintext
write_profile = v1 | v1+v2
read_profile = v1 | v2

At the end of this step, production reads and writes should still use v1, but the application should be ready to enable v2 independently in the following migration steps.

Step 3: Add and index the v2 field

Add a nullable vector field for embeddings generated by the new model. Set its dimension to match the new embedding model.

python
from pymilvus import DataType

client.add_collection_field(
collection_name="documents",
field_name="embedding_v2",
data_type=DataType.FLOAT_VECTOR,
dim=1024,
nullable=True,
)

Existing entities have NULL in embedding_v2 until you populate the field during backfill. New entities can start writing embedding_v2 after the production writer is switched in the next step.

Create an index on the new vector field using the similarity metric selected for the new model:

python
index_params = client.prepare_index_params()

index_params.add_index(
field_name="embedding_v2",
index_type="AUTOINDEX",
metric_type="COSINE",
)

client.create_index(
collection_name="documents",
index_params=index_params,
)

Only entities with a non-null embedding_v2 value are included in the index and are searchable via the new field.

Before continuing, confirm that embedding_v2 is present in the collection schema and that its dimension and index configuration match the new embedding model. Keep production reads and writes on v1 until the writer is switched in the next step.

Step 4: Switch production writes

Update the production writer so that every new or updated entity receives both embedding_v1 and embedding_v2. Keep production reads on v1 while the historical backfill is still in progress.

Generate both embeddings from the same source content and version:

python
# Pseudocode
source = get_latest_source(record_id)

write_entity({
"id": source["id"],
"text": source["text"],
"content_version": source["content_version"],
"embedding_v1": embed(source["text"], model="current-model"),
"embedding_v2": embed(source["text"], model="new-model"),
})

Do not write only one representation when the other generation fails. Record the failed entity and retry it to keep both fields up to date during the migration.

Before starting the historical backfill, confirm that new and updated entities receive valid values in both vector fields, while production search continues to use embedding_v1.

Step 5: Backfill existing entities

Backfill embedding_v2 for entities that existed before the production writer was switched. Use the staged migration dataset prepared earlier and match each record to the existing entity by primary key.

Submit the staged Parquet, JSON, Lance, or CSV data from the External Volume to the backfill workflow. Map the primary key and embedding_v2 columns to the existing collection fields.

bash
export API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

curl --request POST \
--url "https://api.cloud.zilliz.com/v2/projects/{projectId}/jobs/backfill" \
--header "Authorization: Bearer ${API_KEY}" \
--header "Idempotency-Key: migrate-to-new-embedding-model-001" \
--header "Content-Type: application/json" \
--data '{
"collectionName": "documents",
"fields": ["embedding_v2"],
"input": {
"type": "volume",
"volumeId": "migration-data",
"path": "embedding-v2/",
"format": "parquet"
},
"columnMapping": {
"id": "id",
"embedding_v2": "embedding_v2"
},
"mode": "coalesce"
}'

The staged input should contain one row per existing entity:

plaintext
id | embedding_v2 | content_version
1001 | [0.12, 0.31, ...] | 42
1002 | [0.18, 0.27, ...] | 17

Keep production dual-write enabled while the backfill runs. Records created or updated after the migration baseline are handled by the production writer and reconciled in the next step.

Before continuing, confirm that the backfill job completed successfully and record its job ID, input dataset, source watermark, and any records that were skipped or failed. Do not treat a successful job as proof that the migration is ready for production reads; validate coverage and correctness in the later validation step.

Step 6: Reconcile changes made after the backfill

After the backfill completes, reconcile records that were created or updated after the migration baseline so that embedding_v2 reflects the latest source content.

Use the source watermark, snapshot time, or content_version recorded earlier to identify affected entities. Regenerate embedding_v2 from the latest source version and write it through the production writer.

python
# Pseudocode
baseline = read("migration-baseline.json")

for record in source_store.changed_after(baseline["source_watermark"]):
latest = source_store.read(record["id"])

if latest is None or latest["deleted"]:
continue

write_embedding_v2(
id=latest["id"],
embedding=embed(
latest["text"],
model="new-embedding-model",
),
content_version=latest["content_version"],
)

Do not reuse an embedding generated from an older source version. If the source changes again while the embedding is being generated, retry with the latest version.

Before continuing, confirm that all changes made after the migration baseline have either been applied to embedding_v2 or explicitly recorded for retry or review.

Step 7: Validate the migration results

Validate both the migrated data and the new retrieval path before moving production reads to embedding_v2. Use the migration baseline and the fixed evaluation set prepared earlier so that the checks are repeatable.

1

Check data coverage.

Compare the expected migration population with entities that now have embedding_v2. Every in-scope entity should be either populated or explicitly tracked as failed or excluded.

plaintext
expected: 10,000,000
populated: 9,999,850
excluded: 150
unexplained: 0
2

Check data freshness.

Confirm that embedding_v2 reflects the latest source version for entities that changed during the migration. Investigate any record whose stored version or content hash does not match the authoritative source.

3

Compare retrieval quality.

Run the same evaluation set against both retrieval profiles:

plaintext
v1 = old embedding model + embedding_v1
v2 = new embedding model + embedding_v2

Compare the retrieval metrics that matter to your application, such as Recall@K, MRR, or nDCG@K.

plaintext
v1 v2
Recall@10 0.82 0.87
nDCG@10 0.71 0.76

If your application relies on similarity-score thresholds, recalibrate them for v2 instead of reusing thresholds derived from the old embedding model.

4

Check production performance.

Test the v2 retrieval path under representative traffic and compare query-embedding latency, search latency, error rate, throughput, and model capacity with the current production path.

Do not switch production reads until there are no unexplained coverage or freshness gaps and the v2 retrieval path meets the quality and operational criteria defined for the migration.

Step 8: Switch production reads

After the migration passes validation, move production search from the v1 retrieval profile to v2. Switch the query embedding model and vector field together so that queries generated by the new model are always searched against embedding_v2.

Roll out the new read path gradually rather than switching all traffic at once. Start with a small portion of production traffic, monitor retrieval quality, latency, and errors, and increase traffic only as long as the validation criteria continue to hold.

Keep dual-write enabled and keep the v1 retrieval path available during the rollout. If the new path regresses, route reads back to the old embedding model and the old vector field without undoing the backfill or removing embedding_v2.

Continue until all production reads use the v2 retrieval profile and the service remains stable for the rollback window you defined.

Step 9: Complete the migration

After all production reads use the v2 retrieval profile and the rollback window has passed, stop generating embedding_v1 for new and updated entities and remove application logic that still depends on the old representation.

When your collection is compatible with Milvus v3.0.x or above, remove embedding_v1 from the collection:

python
client.drop_collection_field(
collection_name="documents",
field_name="embedding_v1",
)

Dropping the field also removes its associated index. The operation is rejected if embedding_v1 is the collection's last remaining vector field.

Afterward, verify that the old field is no longer present:

python
collection = client.describe_collection(
collection_name="documents"
)

print(collection["fields"])

Before closing the migration, confirm that production reads and writes use only the new embedding model and embedding_v2, and that no rollback or reconciliation workflow still depends on the old representation.

Failure handling and rollback

Keep the current v1 retrieval path available until the migration is complete. If a migration step fails, recover from that stage instead of undoing changes that have already completed successfully.

Failure stageRecovery
Preparing migration dataFix or regenerate failed records before changing the collection. Do not continue while the staged dataset has unexplained missing, duplicate, or invalid records.
Adding the v2 field or indexKeep production reads and writes on v1. Fix the schema or index configuration before switching writers.
Switching production writesKeep production reads on v1. Fix the writer and verify that new and updated entities consistently receive both representations before starting the backfill.
BackfillKeep dual-write enabled and production reads on v1. Retry the backfill with the same migration dataset when possible. If the snapshot or staged data is no longer valid, prepare a new baseline and rerun the affected portion.
ReconciliationKeep reads on v1 and retry records that are stale, failed, or still changing. Do not proceed while unexplained differences remain between the source and embedding_v2.
ValidationDo not switch production reads. Fix coverage, freshness, retrieval quality, or performance issues and repeat the affected validation checks.
Read rolloutRoute reads back to the v1 retrieval profile while keeping embedding_v2 and its index intact for diagnosis. Keep dual-write enabled, so v1 remains a valid rollback path.
After full cutoverKeep embedding_v1, its index, and the old model integration through the rollback window. If v2 regresses, switch production reads back to v1.

Do not remove embedding_v1 or stop maintaining it until the rollback window has closed. Once the old field and model integration are retired, rolling back requires a new migration rather than a traffic switch.

Ctrl I