You migrate a live vector index with zero downtime by never letting reads touch an index that isn't fully populated yet. Concretely: create the Qdrant collection, start writing every new row to both stores, backfill the history idempotently by primary key, shadow-read both and diff the results, and flip reads to Qdrant only once they match. Keep writing to pgvector through a bake period so a rollback is one config flip. Everything below is the sequence, with the commands.

First, a gut check: you may not need to leave pgvector at all. Under ~10M vectors, on a team already running Postgres, one fewer service beats a faster nearest-neighbor query almost every time — and pgvector 0.8's iterative index scans made filtered search genuinely good. Migrate when metadata-heavy filtering, write volume, or the cost curve at high query volume actually strains the shared database. If that's you, keep reading.

The shape of a zero-downtime cutover#

There are four states, and you only ever move forward one at a time:

  1. Reads: pgvector. Writes: pgvector. — today.
  2. Reads: pgvector. Writes: both. — dual-write is on; new data lands in Qdrant.
  3. Reads: pgvector. Writes: both. Backfill running. — history streams into Qdrant.
  4. Reads: Qdrant. Writes: both. — verified, cut over, still safe to roll back.

Downtime only ever happens if you skip to step 4 before the index is whole. The discipline is: dual-write before backfill, so no write slips through the gap between "the backfiller passed this row" and "the app started writing to Qdrant."

1. Create the collection — get the dimension and metric right#

The vector size must equal your embedding dimension, and the distance metric must match what your model was trained for. Both are immutable once the collection exists, so this is the one step you cannot fix later without a rebuild.

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff

client = QdrantClient(url="https://your-cluster.qdrant.io", api_key="...")

client.create_collection(
    collection_name="documents",
    vectors_config=VectorParams(
        size=1536,                 # match your embedding model
        distance=Distance.COSINE,  # cosine for most text-embedding models
        on_disk=True,              # keep vectors on disk for large corpora
    ),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=128),
)

Most modern text-embedding models are trained for cosine similarity — use Distance.COSINE. Reach for DOT only if your vectors are already normalized and you want the speed, and EUCLID only if your model was trained for L2.

2. Turn on dual-write — before you touch the old rows#

Wrap the write path so every insert and update goes to both stores, keyed by the same primary key you use in Postgres. This is what makes the backfill safe: a row the backfiller hasn't reached yet still gets written by the app, and a row it already copied just gets overwritten with identical content.

from qdrant_client.models import PointStruct

def upsert_document(doc_id: int, embedding: list[float], meta: dict):
    # existing pgvector write stays exactly as it was
    pg.execute(
        "INSERT INTO documents (id, embedding, title, category) "
        "VALUES (%s, %s, %s, %s) "
        "ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding",
        (doc_id, embedding, meta["title"], meta["category"]),
    )
    # new: mirror the same row into Qdrant, same id
    client.upsert(
        collection_name="documents",
        points=[PointStruct(id=doc_id, vector=embedding, payload=meta)],
    )

Ship this first. Let it run. Every new write is now in Qdrant; only history is missing.

3. Backfill the history — idempotent, by primary key#

Two ways to move the existing rows. Use whichever fits, but both rely on reusing the Postgres PK as the Qdrant point ID so a re-run overwrites instead of duplicating.

Option A — Qdrant's migration container. It streams the table in resumable batches, tracks progress in a _migration_offsets collection, and keeps going even while your app is still inserting. Re-running the same command picks up where it stopped.

docker run --net=host --rm -it \
  registry.cloud.qdrant.io/library/qdrant-migration pg \
  --pg.url "postgresql://user:pass@localhost:5432/app" \
  --pg.table "documents" \
  --pg.key-column "id" \
  --pg.columns "id,embedding,title,category" \
  --qdrant.url "https://your-cluster.qdrant.io" \
  --qdrant.api-key "..." \
  --qdrant.collection "documents"

Option B — scroll and batch-upsert in code. More control, and it's the same primitives you already have. Page through Postgres by ID and push a few thousand points per batch:

BATCH = 2000
last_id = 0
while True:
    rows = pg.execute(
        "SELECT id, embedding, title, category FROM documents "
        "WHERE id > %s ORDER BY id LIMIT %s",
        (last_id, BATCH),
    ).fetchall()
    if not rows:
        break
    client.upsert(
        collection_name="documents",
        points=[
            PointStruct(id=r["id"], vector=list(r["embedding"]),
                        payload={"title": r["title"], "category": r["category"]})
            for r in rows
        ],
    )
    last_id = rows[-1]["id"]

Because IDs are stable, you can kill this loop and restart it any time. If it crashes at row 400,000, re-running from last_id = 0 simply overwrites the first 400,000 identical points — no duplicates, no corruption.

4. Shadow-read and diff before you trust it#

Don't eyeball it, and don't trust a single spot check. For a representative sample of real query vectors, ask both stores for the top-k results and measure how much the two sets actually agree. The number you want is overlap-at-k: of the ten IDs pgvector returns, how many does Qdrant also return? Track that average across the whole sample before you move a single read path over.

def overlap_at_k(query_vec, k=10):
    pg_ids = {r["id"] for r in pg_knn(query_vec, k)}   # your existing pgvector query
    q_hits = client.query_points("documents", query=query_vec, limit=k).points
    q_ids = {h.id for h in q_hits}
    return len(pg_ids & q_ids) / k

Run it across a few hundred production-shaped queries and track the average. Small ordering differences are expected — HNSW and pgvector's index don't rank ties identically — but the sets should agree strongly. When overlap is consistently high (call it ≥0.9 at k=10), you're safe to cut over.

5. Flip reads, then bake, then decommission#

Change one config value: reads now go to Qdrant. Keep dual-writing to pgvector. For the bake period — a day, a week, whatever your risk tolerance — pgvector is your instant rollback: if Qdrant misbehaves, flip the read flag back and you've lost nothing, because you never stopped writing to Postgres.

Only when Qdrant has carried production reads cleanly through the bake do you remove the pgvector write and drop the column. That last step is the only irreversible one, and by the time you take it, you've already watched Qdrant serve real traffic.


The one-line rule: dual-write before you backfill, verify before you flip, and keep the old store writable until you're sure. Do those three and "migration" stops being a maintenance window and becomes a config change. If you're still deciding whether the move is worth it, start with the pgvector vs Pinecone vs Qdrant decision — the cheapest migration is the one you correctly decide not to do.