BlogRetrieval & eval7 min read

The document changed. The index did not.

Every RAG system drifts out of sync with its source. What triggers a re-embed, what it costs, and how to tell how stale you currently are.

The refund policy changed in June. The assistant is still quoting the old one in September, with a citation to a document whose current version says something different.

Nobody broke anything. The corpus was indexed once, at launch, and there was no part of the system whose job was to notice that a source document had been edited.

A vector index is a cache of your documents. Every cache needs an invalidation story, and RAG systems are routinely shipped without one.

Two kinds of stale, one of them silent

The document changed. Someone edited the policy. The chunk in the index is the old text, and it will be retrieved and cited confidently. This is the one that produces wrong answers.

The document was deleted. Removed from the source, still in the index. Retrieval surfaces content that no longer officially exists — occasionally a compliance problem in its own right, since "we deleted that" and "it is still being quoted to customers" is a bad combination.

Neither raises an error. Both look exactly like normal operation, which is why the first symptom is usually a customer disagreeing with your own product.

Content hashes, not timestamps

The trigger for re-embedding should be that the content actually changed, not that the file was touched:

create table indexed_documents (
  source_id     text primary key,
  content_hash  text not null,        -- hash of normalised text
  embed_model   text not null,        -- which model produced the vectors
  chunk_count   int  not null,
  indexed_at    timestamptz not null,
  source_seen_at timestamptz not null -- last time we confirmed it exists
);

mtime changes when a CMS re-saves a document with no edits, or when a sync job rewrites a file. Re-embedding on mtime means paying to re-embed an unchanged corpus regularly, which is how teams conclude that keeping an index fresh is expensive.

async function sync(doc: SourceDocument) {
  const hash = sha256(normalise(doc.text))
  const known = await db.maybeOne(
    `select content_hash from indexed_documents where source_id = $1`,
    [doc.id],
  )

  // Always update the liveness marker, even when nothing changed.
  // This is what makes deletion detectable.
  await db.query(
    `update indexed_documents set source_seen_at = now() where source_id = $1`,
    [doc.id],
  )

  if (known?.content_hash === hash) return { action: "unchanged", cost: 0 }

  await reindex(doc, hash)
  return { action: known ? "updated" : "created" }
}

Normalise before hashing — collapse whitespace, strip volatile boilerplate like Printed on {date} — or a footer with today's date invalidates every document every day.

Deletion by sweep

Deletions are harder because nothing arrives to tell you about them. The reliable pattern is a liveness marker plus a sweep:

-- Anything not seen in the last full sync cycle is gone from the source.
-- Requires that the sync visits every document, so run it against a
-- full enumeration rather than a changes feed.
select source_id from indexed_documents
where source_seen_at < $1;   -- timestamp when this sync cycle started

Soft-delete first. Mark the chunks excluded from retrieval, keep them for a grace period, and hard-delete after. A sync job that fails halfway through otherwise reads as "half the corpus was deleted" and takes the index with it.

Re-embed the chunks that changed

Editing one paragraph of a 40-page handbook should not cost 40 pages of embedding. Chunk-level hashing keeps the cost proportional to the edit:

async function reindex(doc: SourceDocument, docHash: string) {
  const chunks = chunk(doc)                    // structural chunking
  const existing = await getChunkHashes(doc.id)

  const changed = chunks.filter((c) => existing.get(c.id) !== c.hash)
  const removed = [...existing.keys()].filter((id) => !chunks.some((c) => c.id === id))

  // Only the changed chunks cost anything.
  const vectors = await embed(changed.map((c) => c.text))
  await upsertChunks(changed, vectors)
  await deleteChunks(removed)
}

The catch is chunk identity. If chunk ids are ordinal — doc-12, doc-13 — inserting a paragraph near the top shifts every subsequent chunk and everything re-embeds. Deriving the id from the heading path plus a content hash makes ids stable under insertion, which is the difference between re-embedding three chunks and three hundred.

Know how stale you currently are

Staleness should be a number on a dashboard, not something discovered from a support ticket:

select
  count(*)                                                    as documents,
  count(*) filter (where indexed_at < source_modified_at)     as stale,
  max(source_modified_at - indexed_at)                        as worst_lag,
  percentile_cont(0.95) within group (order by source_modified_at - indexed_at)
                                                              as p95_lag
from indexed_documents
join source_metadata using (source_id);

worst_lag is the alertable one. A p95 of four minutes with a worst case of eleven days means one document is not syncing at all — usually something that throws during chunking and gets silently retried forever.

Warn in the answer, not just on a dashboard

Where a document's index entry is behind its source, the honest move is to say so at answer time rather than only in monitoring:

const hits = await retrieve(query, filters)
const stale = hits.filter((h) => h.indexedAt < h.sourceModifiedAt)

if (stale.length) {
  answer.notices.push({
    kind: "possibly_outdated",
    sources: stale.map((h) => ({ id: h.sourceId, modified: h.sourceModifiedAt })),
  })
}

This composes with status filtering: superseded documents are excluded outright, and documents that are current-but-behind get a notice. The two failures are different and deserve different handling.

Cost

The reason freshness gets deferred is a belief that it is expensive. Usually it is not, once you are only re-embedding what changed.

Embedding is priced far below generation, and typical corpora change slowly — single-digit percentages a month for policy and documentation sets. Content-hash gating means the recurring cost is roughly corpus size × monthly change rate, which for most business corpora is a rounding error next to the query traffic.

The expensive version is re-embedding everything nightly because there is no change detection. That is a real cost, it is entirely avoidable, and it is usually what people are picturing when they decide freshness is too expensive.


More in filter first, then search, why your RAG returns wrong answers, and retrieval systems that are actually evaluated.

Something here

the audit is the cheapest way to find out for certain.