Deleting Is Not One Operation
09:14 on a Tuesday: legal requires every vector belonging to tenant Globex — 2.1 million of them — out of the store within the hour. The request arrives as one sentence, and there are three different operations hiding behind it. Which one you pick depends on what the store does cheaply and what your queries must guarantee in the meantime.
Per-id deletes work when you have the ids (the tenant's rows in Postgres) and the store has no instant bulk delete. Run them in batches so no single statement holds a lock while autovacuum and replicas fall behind:
DO $$
DECLARE n int;
BEGIN
LOOP
WITH doomed AS (
SELECT id FROM tickets
WHERE tenant_id = 'globex'
ORDER BY id
LIMIT 5000 -- bounded work per statement
)
DELETE FROM tickets WHERE id IN (SELECT id FROM doomed);
GET DIAGNOSTICS n = ROW_COUNT;
EXIT WHEN n = 0;
PERFORM pg_sleep(0.1); -- let replicas and autovacuum keep up
END LOOP;
END $$;
The loop terminates on a zero-row delete, so it is safe to re-run after a crash; the sleep is what keeps a 2.1M-row purge from turning into a replication-lag incident. On Pinecone the same intent is one call — index.delete(delete_all=True, namespace="tenant:globex") — followed immediately by a describe_index_stats() check that the namespace count reads zero.
Soft delete is the fallback when neither exists: bulk deletes are slow or unsupported, and the requirement is that queries stop returning the data, not that bytes vanish now.
write path: metadata.deleted = true (upsert same id, cheap)
query path: WHERE deleted != true (runs server-side, in the filter)
late path: batch purge by flag (off-hours, once counts settle)
The flag only works if every query carries it — see the iterative-scan discussion in Vector Search with pgvector for why a filter that falls off an ANN query fails silently, returning flagged rows without an error.
The failure modes, in the order they become expensive:
delete_allon the wrong namespace or index. Irreversible, and the call succeeds. Require a namespace argument from config, never a literal, and print the target before executing.- Ids you do not own. A purge scoped by id list is only as complete as the list; the durable version scopes by tenant key on the source table and verifies counts on both sides.
- Soft-delete flag missing from one query path. Deleted data reappears in exactly the surface nobody tests. Assert it in the retrieval contract tests, not just in code review.
- Secondary copies. Cached results, reranker inputs, exported files, and backups still hold the vectors after the index is clean. Deletion is a system property, not a storage property.
Changing the Embedding Model Without Downtime
The provider deprecates the embedding model behind your 12 million rows, or a newer model measurably wins on your eval set. Either way the migration has a hard property: the store must answer queries correctly at every minute of it. The shape that satisfies that is dual-write, backfill, shadow reads, cut over.
┌── old column + old index ──► query path (today: 100%)
rows ─────┤
└── new column + new index ──► shadow path (scored, not served)
▲
backfill in 200-row batches, throttled to provider rate limits
Phase 1 — add, do not replace. A new column keeps the old one authoritative:
ALTER TABLE tickets ADD COLUMN embedding_v2 vector(384);
CREATE INDEX tickets_embedding_v2_hnsw
ON tickets USING hnsw (embedding_v2 vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Phase 2 — backfill on a cursor. Ordering by id and filtering on the new column makes the job restartable:
UPDATE tickets
SET embedding_v2 = embed_remote_v2(body) -- thin wrapper around the provider call
WHERE id IN (
SELECT id FROM tickets
WHERE embedding_v2 IS NULL
ORDER BY id
LIMIT 200 -- one small batch; sleep between batches
);
Phase 3 — shadow reads. Run the query against both indexes and compare: how often does the new top-10 contain the old top-10, and does the disagreement move your eval score up or down? That measurement — not the vendor's benchmark — is the go/no-go, and Embedding Evaluation covers the scoring side.
Phase 4 — cut over with a return path. Swap the served column behind a flag (or move the index name onto the new column), ramp 1% → 10% → 100%, and keep the old column warm for a rollback window measured in weeks.
| Phase | Reads served from | Rollback cost |
|---|---|---|
| Dual-write | old index | none — nothing has moved |
| Backfill | old index | none |
| Shadow reads | old index | none — shadows are observed only |
| Ramped cutover | new index (percentage) | flip the flag back |
| Post-window | new index | drop the old column; a rollback is now a new backfill |
The version dimension matters too: if the id encodes the model (as it should — see the id scheme in Managed Vectors with Pinecone), old and new vectors can never be mixed inside one query, and a half-finished backfill degrades recall instead of returning garbage.
Separate Stores, Then Read the Growth Curve
One index shared by a test suite and production is two incidents waiting: test vectors show up in customer results, and a suite that calls delete_all during teardown takes the tenant with it. Separation is boring and cheap, and it also protects your evaluation — a benchmark that queries an index the tests write to is measuring the tests.
| Environment | What lives there | Who may write |
|---|---|---|
| dev | Seed fixtures, single tenant, tiny dimension is fine | Any developer, any time |
| staging | Production-shaped volume, anonymized copy | CI pipelines |
| production | Live traffic only | The ingestion service alone |
The separation has a second benefit: each environment's counts mean something. In production, total_vector_count rising while ticket creation is flat is a bug; in staging it is expected noise.
Growth forecasting is arithmetic you can do on a napkin, so nobody has to be surprised by a disk alert. Start from raw storage:
rows × dimension × 4 bytes -- float32
12,000,000 × 1536 × 4 = 73.7 GB raw vectors
× 1.5 (heap rows, HNSW graph, bloat) ≈ 110 GB measured on disk
+1.5M rows/month × 1536 × 4 × 1.5 ≈ +11 GB per month
=> next quarter's projection ≈ 143 GB
The × 1.5 is not a constant — measure it once against reality:
SELECT pg_size_pretty(pg_total_relation_size('tickets')) AS on_disk,
pg_size_pretty(pg_relation_size('tickets')) AS heap_only;
ANALYZE tickets;
On a managed store the equivalent is describe_index_stats() — total_vector_count weekly and index_fullness where it exists. Alert on the trend: project days-to-capacity from the last 30 days of growth and page when it drops under 90, which is a quarter of warning rather than a weekend of emergency sharding. Cost Budgeting turns that curve into a number for the finance review, and when the curve says this store will no longer fit the requirements, the choice of what to move to is Choosing a Vector Store.
Quiz
1. You must remove 2.1M rows from a Postgres table with pgvector during business hours. Why does the batched `DO $$ LOOP` delete beat a single `DELETE WHERE tenant_id = ...`?
2. The store offers no cheap bulk delete, but the requirement is only that queries stop returning the tenant's data. What do you implement?
3. During an embedding model migration, the backfill reaches 60% and the new model's scores look worse than the old one's. What does the phase design let you do?
4. A staging index is shared with the CI test suite. What is the specific engineering harm beyond the obvious risk of data loss?
Flashcards
Question
When do you soft-delete instead of hard-deleting vectors?
Click to reveal answer
Answer
When the store has no cheap bulk delete and the requirement is that queries stop returning the data. Set a metadata flag, filter it server-side on every query, then purge in batches off-hours.
Question
What are the four phases of an embedding model migration?
Click to reveal answer
Answer
Dual-write (add the new column), batched backfill on an id cursor, shadow reads scored but not served, then a percentage-ramped cutover with the old column kept warm for a rollback window.
Question
Why does a bulk delete need batching even when the store can handle the volume?
Click to reveal answer
Answer
Lock duration and replica lag scale with statement size; batches bound both, and a loop that exits on a zero-row batch is restartable after a crash.
Question
Why separate dev, staging, and production vector stores?
Click to reveal answer
Answer
Test vectors must never surface in customer results, teardown deletes must never touch live data, and evaluations only measure production behavior when the index holds only production data.
Question
How do you project index size a quarter ahead?
Click to reveal answer
Answer
rows × dimension × 4 bytes × measured overhead factor (heap + graph + bloat, ~1.5 measured once with pg_total_relation_size), plus monthly row growth; alert when projected days-to-capacity falls under 90.
Revision Notes
Key Takeaways
- 1. Deletion has three shapes — per-id batches, bulk delete, soft-delete flag — and choosing wrong trades a lock incident or a lingering row
- 2. A migration is safe only while the old path stays authoritative: dual-write, backfill, shadow, ramped cutover, then drop
- 3. Shadow reads are the go/no-go signal; your own eval set beats any vendor benchmark
- 4. Environment separation protects both live traffic and the integrity of your evaluations
- 5. Growth is arithmetic: measure the overhead factor once, project a quarter ahead, alert on the trend rather than the threshold
Interview Tips
- • Expect the deletion question: say which of the three forms you would use and how you verify completion on both the source table and the index
- • Walk the migration phases and name the rollback cost at each — a strong answer states that shadow reads are never served
- • Be ready to explain why a soft-delete filter must be in the query itself: an ANN candidate window plus a dropped filter returns deleted rows silently
- • Have numbers: your measured on-disk versus raw-vector overhead, monthly growth, and the alert that fires 90 days before it matters
Cheat Sheet
Index & Collection Operations — Cheat Sheet
Batched purge (restartable)
DO $$
DECLARE n int;
BEGIN
LOOP
WITH doomed AS (SELECT id FROM tickets WHERE tenant_id = 'globex'
ORDER BY id LIMIT 5000)
DELETE FROM tickets WHERE id IN (SELECT id FROM doomed);
GET DIAGNOSTICS n = ROW_COUNT;
EXIT WHEN n = 0;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
Managed store equivalents
index.delete(ids=[...], namespace="tenant:globex")
index.delete(delete_all=True, namespace="tenant:globex") # irreversible: print target first
index.describe_index_stats() # verify count hits 0
Soft delete
write: metadata.deleted = true (upsert same id)
read: filter {"deleted": {"$ne": True}} on EVERY query path
late: batch purge by flag off-hours
Model migration
ALTER TABLE tickets ADD COLUMN embedding_v2 vector(384);
CREATE INDEX ON tickets USING hnsw (embedding_v2 vector_cosine_ops) WITH (m = 16, ef_construction = 64);
UPDATE tickets SET embedding_v2 = embed_remote_v2(body)
WHERE id IN (SELECT id FROM tickets WHERE embedding_v2 IS NULL ORDER BY id LIMIT 200);
Phases: dual-write → backfill → shadow reads (scored, not served) → ramp 1/10/100% → drop old column after the rollback window.
Growth
SELECT pg_size_pretty(pg_total_relation_size('tickets')),
pg_size_pretty(pg_relation_size('tickets'));
ANALYZE tickets;
rows × dim × 4 × ~1.5 on disk; 1.5M rows/month at 1536 dims ≈ +11 GB/month. Alert when projected days-to-capacity < 90.
Continue Learning
Before You Start
- No prerequisites listed.