DEV Community

Cover image for Your RAG Cache Can Resurrect a Deleted Document
Bobby Hall Jr
Bobby Hall Jr

Posted on

Your RAG Cache Can Resurrect a Deleted Document

You delete a document.

The search index catches up. The source URL returns 404. The admin screen says it is gone.

Then someone asks your assistant a question, and the assistant quotes it.

The model did not break into your database. Your application handed it an old copy.

That is the uncomfortable thing about adding a cache to a retrieval pipeline: you have created another place where yesterday's permission can survive today's decision.

Let's build a small TypeScript gate that makes that failure visible. No model, no vector database, no API key. Just cached references, current document state, and a test that proves deleted content stays out of the context we build.

Code: tiny-rag-revocation-gate

1. A cache hit answers the wrong question

Imagine an internal handbook at revision 7. Alex can read it, so a retrieval cache stores a result for a question about travel expenses.

Later, the handbook is deleted. The cache entry still has another twenty minutes to live.

A cache hit tells you that a previous retrieval produced a result. It does not tell you that the document still exists, that its content is current, or that Alex can still read it.

Those are different questions with different owners.

This becomes especially easy to miss when your vector index and your application database update independently. Pinecone documents eventual consistency: writes can take time to appear in query results. Its serverless API exposes log sequence numbers to check whether a query reflects a particular write.

That helps you reason about the index. It does not invalidate a separate application cache.

Even a perfectly fresh index cannot help a request that skips the index entirely.

2. Cache references, then load authorized content

For this example, the cache stores only this:

export type Candidate = {
  documentId: string;
  revision: number;
};
Enter fullscreen mode Exit fullscreen mode

No document text. No cached claim that a user is allowed to read it.

The current record lives behind a trusted adapter:

export type Document = {
  tenantId: string;
  documentId: string;
  revision: number;
  deleted: boolean;
  readers: readonly string[];
  text: string;
};

export type ReadSnapshot = (
  tenantId: string,
  documentIds: readonly string[]
) => Promise<readonly Document[]>;
Enter fullscreen mode Exit fullscreen mode

The adapter contract matters more than its name. It must return a coherent, authoritative snapshot of content and access state, or reject the read. Fetching text from one stale replica and permissions from another does not satisfy that contract.

In a real application, this might be a database query that joins documents and current grants under an explicitly chosen transaction isolation level. Here it is an in-memory fixture, so we can inspect the behavior without hiding it behind infrastructure.

The authenticated server supplies the tenant and user. The model does not get to choose either value.

Cached document references pass through an authoritative snapshot and tenant, reader, deletion, and revision checks before entering model context.

3. Make admission a separate step

The full implementation validates candidate IDs and positive integer revisions, caps each request at 100 candidates, and rejects conflicting revisions for the same document.

After loading the snapshot, the core decision is small:

for (const doc of snapshot) {
  if (seen.has(doc.documentId)) {
    throw new Error("Duplicate snapshot row");
  }
  seen.add(doc.documentId);

  if (doc.tenantId !== principal.tenantId) continue;
  if (!refs.has(doc.documentId)) continue;
  if (doc.deleted || !doc.readers.includes(principal.userId)) continue;
  if (doc.revision !== refs.get(doc.documentId)) continue;

  sources.push({
    documentId: doc.documentId,
    revision: doc.revision,
    text: doc.text
  });
}
Enter fullscreen mode Exit fullscreen mode

Notice where the text comes from: the current record, after the checks.

We do not ask the model to ignore revoked documents. We do not retrieve old text and hope a system prompt handles it correctly. The code building model context decides which sources can enter.

Azure AI Search's document-level access documentation describes enforcing permissions during query execution. Keep those retrieval filters. This gate adds a check at a later boundary, including when cached retrieval bypasses a new search.

It does not make cross-tenant retrieval acceptable. Prevent unauthorized retrieval upstream too, especially before logging, tracing, or reranking any content.

4. Do not silently upgrade a stale result

Suppose the cache refers to revision 7, but the current document is revision 9.

Why not substitute revision 9 and keep going?

Because the retrieval score belonged to revision 7. Revision 9 might discuss a different policy. Its relevance has not been established by that old result.

This example drops the candidate. The caller can run fresh retrieval if it needs more evidence.

That is a deliberate recall tradeoff. A safer result can be a smaller result.

There is another trap here: delete and recreate.

If you delete handbook at revision 7 and recreate it at revision 7, the old reference can accidentally match the new document. Revisions must never be reused for the same identity. A durable system can pair an immutable document incarnation ID with a monotonically increasing content version.

Our fixture recreates the handbook at revision 9. The stale revision-7 reference stays rejected.

5. Test the boundary without calling a model

The demo deliberately keeps its retrieval result unchanged while the underlying document changes:

before deletion: 1
after deletion: 0
after recreation, stale cache: 0
after fresh retrieval: 1
Enter fullscreen mode Exit fullscreen mode

Those numbers count sources admitted to context. They are not model accuracy scores.

The repository has 17 passing tests. They cover authorized content, deletion, revoked access, a wrong tenant, a stale revision, an unexpected document, missing records, an authority outage, duplicates, conflicting revisions, recreation, fresh retrieval, malformed revisions, request bounds, empty retrieval, and a missing principal.

The important outage test looks like this:

await assert.rejects(
  buildContext(principal, [candidate], async () => {
    throw new Error("offline");
  }),
  /offline/
);
Enter fullscreen mode Exit fullscreen mode

If the authority is unavailable, the operation fails. The caller must preserve that behavior. A catch block that falls back to yesterday's text would undo the entire design.

Run it locally:

git clone https://github.com/bobbyhalljr/tiny-rag-revocation-gate.git
cd tiny-rag-revocation-gate
npm test
npm run demo
Enter fullscreen mode Exit fullscreen mode

I tested the code on Node 25.6.0 using synthetic fixtures. It needs no dependencies. Node 22.18 or later can execute this erasable TypeScript syntax directly. Native type stripping does not type-check the program, so execution tests and a TypeScript compiler check are separate things.

6. Name the guarantee you actually have

This gate checks a snapshot. It cannot reach backward and remove content already sent to a model.

A permission change after snapshot acquisition is also outside this example's guarantee. If the product requires a stricter revocation boundary, define when revocation becomes effective and coordinate admission with that boundary. A second check can narrow a race window, but it does not magically make a database read and a remote model request atomic.

Generated answers need attention too. If you cache the final answer, this source gate will never run on an answer-cache hit. Track the answer's source dependencies and revalidate them, or bypass that cache when you cannot establish current access.

Deleting vectors is also different from deleting every copy. Logs, traces, backups, provider retention, and stored conversations are separate systems with their own handling. This little gate makes no complete-erasure or compliance claim.

One useful operational measure is how many candidates get rejected for stale state. A sudden rise can point to a broken invalidation path. Avoid recording rejected document bodies in those diagnostics.

The code is intentionally small because the lesson belongs in the application boundary, where you can test it.

A cache can save the cost of finding a document.

It should not get to decide whether that document still belongs in the conversation.

Top comments (0)