A new embedding model drops, the benchmarks look great, and someone on your team changes one line of config to point at it. Nothing crashes. The pipeline runs. Queries return results. Latency is fine. Two weeks later, support tickets pile up about the assistant “getting dumber,” and nobody can find a deploy that explains it.

This is the most common self-inflicted RAG outage I have seen, and it is entirely invisible to normal monitoring. Swapping embedding models does not throw an error. It quietly corrupts the geometry your retrieval depends on, and the only symptom is answers that are subtly, then badly, wrong.

Here is why it happens, and the migration pattern that prevents it.

Why Embeddings Are Not Portable

An embedding model maps text to a point in a high-dimensional space. Retrieval works because similar text lands near similar text, and you find neighbors by cosine or dot-product distance. The critical property: the coordinate system is defined by the model that produced the vectors. There is no shared, absolute meaning to dimension 412 across two different models.

When you store a vector index, you are storing a snapshot of one model’s coordinate system. Every document vector in that index was produced by model A. The moment you switch your query path to model B, you are asking B to describe a location and then searching for it among points that A placed. The two models do not agree on where anything is.

Query text: "reset my password"

Model A query vector:  [0.02, -0.91,  0.44, ...]  (A's coordinate system)
Model B query vector:  [0.71,  0.10, -0.33, ...]  (B's coordinate system)

Stored doc vectors in index: all produced by Model A

Searching with B's vector against A's points:
  -> nearest neighbors are essentially random
  -> no error, no warning, just garbage neighbors

The vector database does not know or care which model produced a vector. It stores floats and computes distances. A mismatched query still returns the “closest” stored vectors, they are just closest by accident. This is why the failure is silent. Every layer reports success.

The Naive Approach and Exactly Where It Breaks

The naive migration looks reasonable:

# config change, shipped on a Friday
EMBEDDING_MODEL = "voyage-3.5"   # was "voyage-3"

If your ingestion and query code share this constant, you have now split your system in two states depending on rollout timing:

State 1: query uses B, index still full of A vectors. This is the pure mismatch above. Retrieval quality collapses immediately for every query, but only if you never re-embed. Most teams do re-embed, which leads to the worse case.

State 2: you re-embed in place. Someone runs a backfill job that re-embeds documents with model B and overwrites the vectors in the same index. Now the index is a mix: documents processed before the backfill point are A, documents after are B. Your query is B. Retrieval silently prefers whichever subset happens to share B’s geometry, and ranking becomes a function of when a document was last indexed rather than relevance.

State 2 is insidious because it degrades gradually as the backfill progresses, so it never looks like a single bad event. Partial re-embedding is strictly worse than no re-embedding, because at least a fully-A index with an A query is internally consistent.

The other thing that breaks: dimensions. If model A outputs 1024-dim vectors and model B outputs 1536-dim, an in-place overwrite either errors (if your schema pins dimension) or, worse, some drivers will pad or truncate and keep going. Now you have vectors of two shapes in one index.

The root cause of all of this: the vector index and the model that produced it are a single unit, and the naive approach treats them as independent.

The Rule: Index and Model Are One Versioned Artifact

The fix starts with a mental model change. Do not think of “the vector index” as a mutable store you point different models at. Think of it as an immutable artifact tagged with the exact model and preprocessing that built it.

Every vector carries provenance:

@dataclass
class EmbeddingConfig:
    model: str          # "voyage-3.5"
    dimensions: int     # 1024
    normalize: bool     # True
    input_prefix: str   # some models need "search_document: " prefixes
    chunk_version: str  # "v3" - chunking strategy is part of the geometry too

    def index_name(self) -> str:
        return f"docs__{self.model}__{self.dimensions}__{self.chunk_version}".replace(".", "_")

Notice chunk_version is in there. Chunking is part of what determines a vector as much as the model is. If you change chunk size or overlap, you have changed the corpus the model sees, and the index is again a different artifact. Treat chunking changes with the same discipline as model changes.

The index name is derived from the config. You physically cannot write B vectors into A’s index because the name is different. Mismatch becomes impossible by construction instead of something you have to remember not to do.

The Dual-Index Migration Pattern

The safe migration keeps both indexes live at once and shifts read traffic deliberately. There is never a moment where the query model and the index model disagree.

                          +-------------------+
   ingest document ----> | embed with A (old)| ----> index_A  (live reads)
                    \     +-------------------+
                     \    +-------------------+
                      +-> | embed with B (new)| ----> index_B  (dark, warming)
                         +-------------------+

   query --> route by flag --> [A: index_A + A embed]  (default)
                            \-> [B: index_B + B embed]  (canary %)

The four phases:

Phase 1 - Dual write. Start embedding every newly ingested document with both A and B, writing to two separate indexes. New content is now consistent in both. Reads still go entirely to A. This costs you double embedding on the ingest path but nothing on reads.

Phase 2 - Backfill B. Run a batch job that reads your source documents (not A’s vectors) and embeds them with B into index_B. This is the expensive step. When it finishes, index_B is a complete, internally consistent B artifact. Index_A is untouched and still serving all reads.

Phase 3 - A/B evaluate and canary. Route a small percentage of live queries through the B path (B query embedding against index_B) and compare retrieval quality. Both paths are internally consistent, so you are measuring a real quality difference, not a mismatch. Ramp the percentage as B proves out.

Phase 4 - Cutover and retire. Once B wins on your metrics, flip the default to B, keep A as instant rollback for a week, then delete A and stop dual-writing.

The query router is the whole trick. The model and the index it queries are always chosen together:

ROUTES = {
    "A": (embed_with_a, index_a_client),
    "B": (embed_with_b, index_b_client),
}

def retrieve(query: str, k: int = 8, user_id: str = None):
    variant = pick_variant(user_id)          # "A" default, "B" for canary bucket
    embed_fn, index = ROUTES[variant]
    qvec = embed_fn(query)                    # model matches the index, always
    return index.search(qvec, k=k), variant

pick_variant should bucket by a stable hash of user or tenant id, not per-request random. You want a given user to see a consistent retrieval experience during the ramp, and you want to be able to compare cohorts.

Estimating the Re-Embedding Cost Before You Commit

Phase 2 is where migrations stall, because nobody scoped the cost and the backfill either blows a budget or takes a week and gets abandoned half-done (which lands you in the State-2 partial-index trap). Estimate it up front.

You need three numbers: total chunks, average tokens per chunk, and the model’s price and throughput.

def estimate_backfill(num_chunks: int,
                      avg_tokens_per_chunk: int,
                      price_per_1m_tokens: float,
                      rps_limit: int,
                      chunks_per_request: int = 96):
    total_tokens = num_chunks * avg_tokens_per_chunk
    cost = (total_tokens / 1_000_000) * price_per_1m_tokens

    requests = num_chunks / chunks_per_request
    seconds = requests / rps_limit
    hours = seconds / 3600

    return {
        "total_tokens_millions": round(total_tokens / 1_000_000, 1),
        "cost_usd": round(cost, 2),
        "wall_clock_hours": round(hours, 1),
    }

# 20M chunks, 300 tokens each, $0.12/1M tokens, 50 rps, batches of 96
print(estimate_backfill(20_000_000, 300, 0.12, 50, 96))
# {'total_tokens_millions': 6000.0, 'cost_usd': 720.0, 'wall_clock_hours': 1.16}

The dollar cost is usually the small number. For 20M chunks you are looking at a few hundred dollars of embedding API spend, which surprises people who expected it to be the blocker. The real constraints are elsewhere:

Cost dimensionWhat actually bites you
Embedding API spendUsually modest, hundreds to low thousands of dollars
Rate limitsThe true wall-clock bound; batch and respect concurrency caps
Vector DB write throughputBulk upsert can be slower than embedding; use batch import APIs
StorageYou are paying for two full indexes during migration, not one
Self-hosted GPU timeIf you run the model yourself, GPU hours dominate over API pricing

Two practical notes. First, embed from your source of truth, not from A’s index; you cannot recover text from vectors, and you want the current chunking applied. Second, make the backfill idempotent and checkpointed. It will get interrupted. A job keyed by (document_id, chunk_version) that can resume from the last committed offset is the difference between a clean migration and a corrupted partial index.

A/B Testing Retrieval Quality Before Cutover

This is the step teams skip, and it is the only thing that tells you the upgrade is actually an upgrade rather than a lateral move that also happens to reset your prompt tuning. “It is the newer model” is not evidence.

You need a labeled evaluation set: queries paired with the document chunks that should be retrieved. Building this once pays for itself across every future migration.

# eval_set: list of {"query": str, "relevant_chunk_ids": set[str]}

def recall_at_k(results_ids, relevant_ids, k):
    top = set(results_ids[:k])
    return len(top & relevant_ids) / len(relevant_ids)

def mrr(results_ids, relevant_ids):
    for rank, cid in enumerate(results_ids, start=1):
        if cid in relevant_ids:
            return 1.0 / rank
    return 0.0

def evaluate(index, embed_fn, eval_set, k=8):
    recalls, rrs = [], []
    for ex in eval_set:
        qvec = embed_fn(ex["query"])
        hits = [h.id for h in index.search(qvec, k=k)]
        recalls.append(recall_at_k(hits, ex["relevant_chunk_ids"], k))
        rrs.append(mrr(hits, ex["relevant_chunk_ids"]))
    return {"recall@k": sum(recalls) / len(recalls),
            "mrr": sum(rrs) / len(rrs)}

old = evaluate(index_a, embed_with_a, eval_set)
new = evaluate(index_b, embed_with_b, eval_set)
print(old, new)

Run this offline against both fully-built indexes before you send a single live query to B. Two things matter more than the raw numbers.

Look at per-query deltas, not just the average. A model can raise mean recall while destroying a specific query class you care about. Sort by regression and read the worst 20 by hand. If B improves FAQ retrieval but tanks on error-code lookups, and error codes are half your traffic, the average lied to you.

Where you have no labels, use live implicit signals during the canary. Click-through on cited sources, thumbs up/down, follow-up rephrase rate, and whether the LLM’s answer actually used the retrieved context. These are noisier than a labeled set but they measure the real distribution.

MetricWhat it tells youWhen to trust it
Recall@k on labeled setDid the right chunk make the top kOffline, pre-cutover gate
MRR / nDCGIs the right chunk ranked high, not just presentOffline, ranking quality
Answer-uses-context rateDid retrieval actually help the generationCanary, end-to-end signal
Thumbs down / rephrase rateUser-perceived qualityCanary, lagging but real

Set a cutover gate before you start: for example, B must be at least neutral on average recall and must not regress any query class by more than a small threshold. If B does not clear the bar, you keep A. A newer model is not a reason to migrate; a measured improvement is.

Handling Dimension and Distance Changes

One trap that survives even a careful dual-index migration: model B may use a different distance metric or expect different normalization. Some models are trained for cosine similarity, some for dot product, some ship pre-normalized. If index_B is configured for cosine but B’s vectors are meant for dot product, your ranking is wrong even though model and index “match” on name.

Bake the metric into the config and the index creation, and assert it on write:

def build_index(cfg: EmbeddingConfig):
    return client.create_index(
        name=cfg.index_name(),
        dimension=cfg.dimensions,
        metric="cosine" if cfg.normalize else "dotproduct",
    )

Also decide dimensions deliberately. Matryoshka-style models let you truncate vectors to trade quality for storage and speed. That truncation length is part of the artifact identity too. A 1024-dim and a 512-dim index from the same model are different indexes and must be evaluated separately.

Honest Assessment

What works: the dual-index pattern with derived index names is boring and reliable. It makes the silent-mismatch failure structurally impossible, gives you instant rollback, and turns “swap the model” from a Friday config change into a controlled migration with a gate. The cost estimate almost always shows the embedding spend is the cheap part, which unblocks the decision faster than people expect.

What does not work: doing any of this without a labeled evaluation set. Without it, you are cutting over on vibes and the newer model’s marketing, and half the time you will ship a regression on the query class that matters most to you. If you have no eval set today, that is the first thing to build, before you even look at a new model. It is also the highest-leverage artifact you will own, because every future upgrade reuses it.

What to actually do, in order: (1) put model, dimensions, metric, and chunk version into a config that derives the index name, so mismatch cannot happen; (2) build a labeled eval set of real queries from your logs, a few hundred is enough to start; (3) when a new model tempts you, run the dual-index migration, backfill idempotently, and gate the cutover on measured recall and per-class regressions, not on the release notes. Keep the old index for a week after cutover. The upgrade that looks free is the one that quietly breaks retrieval, and the only defense is refusing to trust a model change you have not measured.