← ragproof.io

I replayed 13 months of real churn on a RAG index - 90% of live chunks were stale, orphaned or duplicated.

A reproducible teardown of what happens to a vector index after a year of real document churn, across pgvector, Qdrant and Chroma.

· Rostyslav Myronenko

Why I built this

I build, consult and teach in the GenAI domain, and I'm an active AWS Community Builder in AI Engineering category. One of my friends came to me with some version of "Why retrieval quality of our in-house RAG degrades over time?" - and I didn't have a satisfying answer other than "let me look". The root cause was removal of a bunch of docs belonging to another track, and this situation inspired me to write this post.

I built a version of the worst case on purpose: took a real corpus with real edit history, replayed a year of it the way real ingestion pipelines actually behave (including their bugs), and then measured exactly what's wrong with the result. Engine by engine, against a ground-truth ledger that knows what should be true at every point in time.

The corpus is the GitLab Handbook - a real company wiki, thousands of Markdown pages, years of git history, CC BY-SA 4.0 licensed (per GitLab's own "Our Handbook is open source: here's why", not the repo's boilerplate LICENSE file, which is silent on content). It reads exactly like the internal wiki or Confluence space most RAG buyers actually index. On top of it: 100 clearly-labeled synthetic employee records (Faker, seed=42, not real people) for a GDPR erasure demo, since a real company handbook by itself has no personal data to erase.

Method

Every single mutation - real or injected-bug - is logged to a SQLite ledger with the expected index state per engine. That ledger is what makes every number below checkable.

Findings

Full scorecard →

The headline: across all three engines, 89.73% of what's still live in the index fails at least one check - stale, orphaned or a duplicate. All three engines agree almost exactly with each other, because staleness and orphan status are pure ledger facts, not engine-dependent behavior. That figure is a real set union (chunk_id set operations against the ledger, not a sum of the rows below. Those three categories overlap heavily, so adding their individual percentages would double-count and isn't how this number was computed):

The good news, and it's a real finding, not an absence of testing: deletion itself held up. I queried for the 637 real git-deleted docs and the 40 GDPR-erased synthetic employees using their own original text as the worst-case query - the query most likely to surface a leak - at both top-5 and top-10, in all three engines. Zero leaked. I also checked storage-layer persistence directly (not just "does the API return it") - also zero. For pgvector specifically, the storage layer was dirty first - 254,060 dead tuples sat on disk right after the churn replay and got reclaimed by PostgreSQL's own background autovacuum (no manual VACUUM, ever) before this audit read the heap. The persistence window was real; it just closed on its own. This distinction matters: EDPB Guidelines 05/2019 are explicit that erasure has to be verifiable and irreversible - "the API stopped returning it" alone doesn't satisfy that and you have to check the storage layer too, which is what "Ghost Vectors" (https://arxiv.org/abs/2606.18497) found soft-deleted HNSW vectors failing on elsewhere. Here, both checks came back clean, in all three engines.

I don't want to overclaim a clean result any more than I'd want to overclaim a dirty one, so I cross-validated the detection mechanism itself. I ran the identical query-and-check pipeline against 200 chunks per engine that are known to still be live, confirming each finds itself. Pass rate: 97.5% (pgvector), 98.0% (Qdrant), 98.5% (Chroma) - the small residual gap is ordinary approximate-HNSW recall loss under untuned defaults, isolated by excluding chunks with exact-duplicate siblings (duplicate-tie crowding was initially misleading a naive version of this check down to 86%). If the detection pipeline can find a chunk that's definitely there 97–98% of the time, "zero found" for erased content is a real signal, not a broken probe.

Ground-truth verification

Every check above is scored against the ledger, not just eyeballed:

The methodology behind this post is the same one the paid audit runs on a customer's real index. "Trust me" wouldn't be good enough there, so it doesn't get a pass here either.

Honest limitations

Reproduce it

Please go to the experiment repo.

Why not just use X

The obvious question. Four adjacent categories exist, and none of them does this.

RAG eval tools

(Ragas, Arize Phoenix, LangSmith, DeepEval, TruLens, Future AGI) score answer quality (faithfulness, context precision, groundedness) against a sampled question set. Their own vendors' framing is that these metrics assume the retrieval index is trustworthy. That assumption is exactly what breaks here: a pipeline can score 0.95 faithfulness while serving a 13-month-old chunk, because the eval was built against the same stale corpus. Evals sample the questions you thought to ask; this is a census of what's actually in there. Phoenix's embedding-cluster view gets closest to index introspection, but it's a debugging aid, not an inventory.

Embedding PII scanners

(IronCore VectorLens, BigID) answer "is there sensitive data in these vectors?" - a different axis from "should this vector still be here at all?". VectorLens is the closest structural analogue to what I do: self-hosted, read-only, CLI, scans your own exported embeddings. It has no staleness, orphan, or duplicate detection so is a complementary tool.

Deletion-verification libraries

(mnemo) overlap the erasure check specifically, and genuinely so: as of mnemo's v1.9.9 release (July 17, 2026), mnemo's ErasureAuditor re-attempts recovery of an erased subject across registered stores using verbatim scans and NN-inversion, with soft-delete residue probes naming pgvector, Qdrant and Chroma by name. The difference is scope and wiring - it verifies deletion propagation for a named subject across stores you register in your application, which is a real and useful thing to have in-app. It doesn't census an external index for staleness, orphans or duplicates, and it needs adapters written into your codebase.

The vector databases themselves

are the subject of the audit, not the auditor. All of them soft-delete by design: pgvector accumulates dead tuples until VACUUM, Qdrant holds tombstones until a segment crosses deleted_threshold, Chroma's HNSW grows unbounded. None of them will tell you that a deleted source document is still in your top-k - which, to be clear, is precisely the check that came back clean here.

...and the one that isn't a product at all:

"Just write a SQL cron job comparing timestamps."

That is genuinely the mechanism, and for teams with a disciplined document_id→chunk→vector mapping, several of these checks do reduce to a query. The part that doesn't reduce is knowing whether the cron caught everything - which is what the ledger reconciliation above is for, and why the precision/recall numbers are in this post at all.

If you want this on your own index

rag-staleness-check

The checks that don't need a ledger - staleness (given your own last-modified metadata), orphans (given a source manifest), duplicates and a basic deleted-but-retrievable probe against ids you already know were deleted - are shipping as an open-source, read-only CLI, rag-staleness-check (Apache-2.0).

https://cal.com/rost-rostcamp-y74css/ragproof-discovery

The multi-engine version, the GDPR Article-17 reporting pack and the ledger-verified precision/recall harness that produced the numbers above are the paid audit that I run as an individual consultant - reach out if you want it run against your real index at https://cal.com/rost-rostcamp-y74css/ragproof-discovery.

← Back to ragproof.io