💡 Why toDataIntermediate✨ AI-assisted

Why Your Vector Store Needs a Deletion Path Before Launch

WittyTech Daily··4 min read
#rag#gdpr#vector-store#data-governance

A customer writes in: delete everything you hold about me. Your database team handles it by lunch. Then someone asks about the RAG index, and nobody can say which of the four million chunks came from that customer's contracts.

Vector store deletion is the part of a RAG launch that nobody scopes, because a vector index feels like a derived cache. It isn't. Every chunk and embedding is a copy of something a person may ask you to erase, and Article 17 of the GDPR says to erase it "without undue delay" when the grounds apply. One design decision, made before launch, turns that request into a query instead of a re-index.

Tag every chunk with a document and a subject at ingest

Put two IDs in every chunk's metadata: the source document it came from, and the person or customer it concerns. Index both fields so filtering is cheap. Use deterministic point IDs so re-ingesting a document overwrites its chunks instead of duplicating them.

import uuid
from qdrant_client import QdrantClient, models

client = QdrantClient(url='http://localhost:6333')
COLLECTION = 'support_docs'

# One-time setup: index the fields you will filter on at deletion time
for field in ('source_doc_id', 'subject_id'):
    client.create_payload_index(
        collection_name=COLLECTION,
        field_name=field,
        field_schema=models.PayloadSchemaType.KEYWORD,
    )

def ingest_chunk(doc_id, subject_id, idx, text, vector):
    client.upsert(
        collection_name=COLLECTION,
        points=[
            models.PointStruct(
                id=str(uuid.uuid5(uuid.NAMESPACE_URL, f'{doc_id}:{idx}')),
                vector=vector,
                payload={
                    'source_doc_id': doc_id,
                    'subject_id': subject_id,
                    'chunk_index': idx,
                    'text': text,
                },
            )
        ],
    )

When a document is updated, delete by source_doc_id first, then ingest, so a shorter new version leaves no stale chunks. If a chunk concerns several people, store all their IDs. A field you didn't tag at ingest can't be filtered on later, and the fix is a full re-index under a deadline.

Delete by filter instead of re-indexing

With the tags in place, erasure is one call. This uses the filter-based delete from the Qdrant points docs.

def by_field(key, value):
    return models.Filter(
        must=[models.FieldCondition(key=key, match=models.MatchValue(value=value))]
    )

def erase_subject(subject_id):
    client.delete(
        collection_name=COLLECTION,
        points_selector=models.FilterSelector(filter=by_field('subject_id', subject_id)),
        wait=True,
    )

Qdrant point changes are asynchronous, so pass wait=True and don't tell the customer "done" until the call returns. On Postgres with pgvector the vectors live in an ordinary table, so it is an ordinary DELETE:

CREATE TABLE chunks (
  id bigserial PRIMARY KEY,
  source_doc_id text NOT NULL,
  subject_id text NOT NULL,
  content text NOT NULL,
  embedding vector(1536)
);
CREATE INDEX ON chunks (subject_id);
CREATE INDEX chunks_embedding_idx ON chunks USING hnsw (embedding vector_cosine_ops);

DELETE FROM chunks WHERE subject_id = 'cust_8841';
REINDEX INDEX CONCURRENTLY chunks_embedding_idx;
VACUUM chunks;

The reindex-then-vacuum order follows the pgvector README, since vacuuming an HNSW index can take a while.

Deleted does not mean gone

Both stores delete lazily. Qdrant marks a record as deleted "to avoid slow disk operations during queries", and its vacuum optimizer removes it later, only once a segment's share of deleted vectors reaches deleted_threshold (0.2 in the docs' example) and the segment holds at least vacuum_min_vector_number vectors (1000 in the example). In Postgres, a DELETE leaves dead tuples until VACUUM runs.

Search stops returning the data immediately, but the bytes leave on the store's schedule, so write that into your retention policy.

Backups, caches and logs keep deleted data alive

The vector store is rarely the only copy. Check these before the first request:

  • Snapshots, backups and point-in-time recovery archives taken before the deletion.
  • Semantic caches and answer caches that stored a response built from the erased chunks.
  • Traces and prompt logs that captured the retrieved text.
  • Eval sets and fine-tuning data built from production traffic.
  • The raw documents in object storage, plus staging tables and read replicas.

You can't edit a backup, so keep an erasure ledger: a list of erased subject IDs with timestamps. After any restore, replay the ledger before the restored store serves a single query. Otherwise your disaster recovery plan quietly resurrects deleted customers.

Prove the vector store deletion with a test

A deletion path you haven't tested is a hope. Run this in CI against a scratch collection, and run a variant with a canary subject in production on a schedule.

def subject_count(subject_id):
    return client.count(
        collection_name=COLLECTION,
        count_filter=by_field('subject_id', subject_id),
        exact=True,
    ).count

def test_erasure_removes_subject(sample_vector):
    ingest_chunk('doc-test', 'subject-test', 0, 'Jane Test, 1 Example Street', sample_vector)
    assert subject_count('subject-test') == 1

    erase_subject('subject-test')

    assert subject_count('subject-test') == 0
    hits = client.query_points(COLLECTION, query=sample_vector, limit=20).points
    assert all(h.payload['subject_id'] != 'subject-test' for h in hits)

The second assertion matters: it searches with the erased chunk's own vector, the query most likely to surface it.

But embeddings are not personal data

The strongest objection is partly fair: a bare vector is not a name. Two things weaken it. First, most RAG stores keep the chunk text in the same record, so the record is plainly personal data. Second, research on embedding inversion keeps closing the gap. A study of Vec2Text, a method that reconstructs text from embeddings, concludes it "could pose a threat to current dense retrieval systems".

Whether a regulator would call a bare vector personal data is a question for your lawyers. Deleting it anyway costs one filter.

Start with the two tags

This week, add source_doc_id and subject_id to your ingest code, backfill existing chunks, and put the erasure test in CI.

In production the erasure request arrives on the customer's schedule, not yours, so the deletion path has to exist before your first customer does.

← More in Data