A RAG index that was accurate at launch decays daily: source documents change, get deleted, get superseded — and a full nightly re-embed is the batch-refresh anti-pattern with a token bill attached. The data-engineering answer is the one you already know: treat the vector store as a downstream consumer of your CDC stream.
Tier: Advanced · Workflow area: RAG data pipelines
Detect real content changes with hashes, re-chunk and re-embed only what changed, propagate deletes, and cut over versioned indexes for breaking changes. This playbook builds that sync loop and the reconciliation checks that prove the index tracks the source of truth.
Establish the source-of-truth change feed. Options in order of preference: an existing CDC stream (Debezium topics, warehouse streams/change tables), source-system webhooks, or — the universal fallback — a periodic crawl that diffs against stored state. Whatever the feed, normalize events to (doc_id, op ∈ {upsert, delete}, payload_ref, event_ts).
Detect content change with hashes, not timestamps. Files get re-saved, re-exported, and touched without changing meaning; updated_at alone triggers useless re-embeds. Diff a normalized content hash (same normalization as pb-27 dedup) against the pipeline's chunk table (pb-30/pb-31 schema already stores content_hash):
import duckdb, hashlib
def content_hash(text: str) -> str:
return hashlib.sha256(" ".join(text.split()).lower().encode()).hexdigest()
con = duckdb.connect("pipeline.db")
def classify(doc_id: str, new_text: str) -> str:
row = con.execute(
"SELECT DISTINCT content_hash FROM chunks WHERE doc_id = ?", [doc_id]).fetchall()
if not row:
return "new"
return "unchanged" if content_hash(new_text) in {r[0] for r in row} else "changed"
Refine to chunk-level diffing for long documents: re-chunk the new version, hash each chunk, and re-embed only chunks whose hash is absent — on large documents with small edits this cuts re-embed volume by 90%+.
Make upserts a transaction of three steps. For a changed document: (a) re-chunk and re-embed new/changed chunks via the pb-30 idempotent pipeline, (b) upsert vectors keyed by deterministic chunk_id, (c) delete vectors whose chunk_id belonged to the old version but not the new (WHERE doc_id = ? AND chunk_id NOT IN (...)). Skipping (c) is how indexes accumulate orphan chunks that retrieve stale text forever.
Propagate deletes as first-class events. A source delete must remove all chunks for the doc_id in the vector store and mark the chunk table. Deletes are the events most often dropped by ad-hoc sync scripts — and the ones with compliance teeth (right-to-erasure). Dead-letter failed deletes loudly; a failed upsert is staleness, a failed delete is exposure.
Reserve versioned cutover for breaking changes. Model swaps (pb-29), chunking changes (pb-28), enrichment rollout (pb-35): build a complete shadow collection (rag_chunks_v2), run the golden-set eval against it, then flip the retrieval function's target atomically and keep v1 for rollback. Never mix representations in one collection — incremental sync is for content changes, cutover is for representation changes.
Reconcile continuously — this is the metric layer. The measures that prove the sync works:
now() - max(event_ts applied), per source; alert at your SLA (e.g. 15 min for streams, 24 h for crawls).doc_id in the index — drift beyond a small tolerance pages someone.SELECT (SELECT count(DISTINCT doc_id) FROM source_docs WHERE NOT is_deleted)
- (SELECT count(DISTINCT doc_id) FROM chunks) AS doc_drift;
Expected output: doc_drift = 0 in steady state; any persistent nonzero value is a dropped-event bug, not noise.
The vector store is just another downstream consumer with a delivery SLA. Everything you know about CDC sinks — idempotent upserts, delete propagation, reconciliation, versioned cutover — applies verbatim; the only new part is that "row" means "chunk + embedding".
embedded_at timestamps), not all 100.updated_at at once — without content hashing, one upstream housekeeping job triggers a full-corpus re-embed bill.doc_id + position + content_hash (pb-31).CDC-driven sync usually runs entirely inside the client's estate — the feed, the queue, and the store are theirs — so the sensitive deliverable is not data movement but guarantees: put the freshness SLA, deletion SLA, and reconciliation report into the handover document, because "how fast does an erasure request leave the AI index" is a question their DPO will ask. Build the canary deletion probe with synthetic documents so it can run in production without touching real content, and if enrichment or hosted embeddings are part of re-embed (pb-30/pb-35), note that every incremental sync inherits those providers' approval scope — a new document class flowing into the CDC stream silently expands what gets sent externally unless the pipeline filters by classification.