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.