What you'll learn: how to update a RAG index when source documents change (content hashes, real deletes, access labels), and how to extract scans, tables and images into pieces that keep their source and region, with low-confidence OCR set aside.
At a glance
Figure 1. One sync run: compare, plan, check, apply
compare"} H["Stored fingerprints"] --> C C --> P["Plan: added, changed,
removed, unchanged"] P --> G{"Deletes within
the limit?"} G -- "no" --> X["Refuse and alert"] G -- "yes" --> A["Delete old chunks,
add new chunks"] A --> I["Chunk index"]
How to read this: only the documents the plan names are touched; the check on the right stops an empty source from wiping the index.
- Hash what retrieval depends on (text, access label, version fields), not only the text.
- A removed document must lose its chunks, or it is still retrievable.
- Refuse a sync that would delete a large share of the index.
- Scans, tables and images are extracted into pieces that keep source, region and kind; weak OCR is set aside.
Scenario
Composite scenario, not a real company. Lumen Industrial Supply, a fictional distributor, runs a sales assistant over its 30 product documents. The product team edits datasheets every week. Last week they deleted a partner pricing guide from the shared folder, and the assistant kept quoting it. They also moved one FAQ from public to partner-only, and it stayed visible to everyone. A supplier has now sent a scanned datasheet.
What you will be able to do
- Compute a fingerprint that changes when text, access label or version fields change.
- Produce an added, changed, removed and unchanged plan from two states.
- Apply a plan so removed and shortened documents leave no retrievable chunks.
- Extract a scan, a table and an image into pieces whose citation names the file and the region, and set low-confidence OCR aside.
Prerequisites
- Document ingestion and metadata: the
Documentand chunk fields used here. - Chunking trade-offs and Access control in retrieval.
- Why retrieval needs fresh, labelled data: Data and retrieval.
Core ideas
Predict the failure first
Predict first. A sync adds new documents and replaces changed ones, but never deletes. Lumen deletes one document at the source. Can a user still retrieve it? Decide, then open the answer.
Answer
Yes. The index keeps every chunk until something deletes it. In the capstone data, an add-only sync after deleting the partner guide leaves 174 chunks, 5 of them from the deleted document, and a partner's search for "partner programme" still returns it first.
Fingerprint what retrieval depends on
A content hash is a fixed-length fingerprint of the input: same input, same fingerprint. Python's hashlib.sha256 makes one, and hexdigest() returns it as a hexadecimal string (as of 2026-11-04, hashlib documentation).
Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_sync.py
def fingerprint(doc: Document) -> str:
"""SHA-256 over every field that changes what retrieval may show, not only the body."""
payload = {
"title": doc.title,
"skus": sorted(doc.skus),
"visibility": doc.visibility,
"status": doc.status,
"superseded_by": doc.superseded_by,
"body": normalise_text(doc.body),
}
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
The access label and version fields are in the hash, so a visibility change alone counts. The body is normalised (line endings, trailing spaces), so a re-save does not trigger a re-index.
Plan, then apply
| Source state | Plan bucket | What apply does |
|---|---|---|
| Id not stored before | added | chunk it, add the chunks |
| Id stored, fingerprint differs | changed | delete all its old chunks, add new ones |
| Id stored, fingerprint equal | unchanged | nothing |
| Id stored, not in source | removed | delete its chunks and its record |
Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_sync.py
added, changed, unchanged = [], [], []
for doc in current:
old = previous.hashes.get(doc.doc_id)
if old is None:
added.append(doc.doc_id)
elif old != fingerprint(doc):
changed.append(doc.doc_id)
else:
unchanged.append(doc.doc_id)
removed = [i for i in previous.hashes if i not in set(ids)]
return SyncPlan(tuple(sorted(added)), tuple(sorted(changed)), tuple(sorted(removed)), tuple(sorted(unchanged)))
Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_sync.py
by_id = {d.doc_id: d for d in current}
for doc_id in plan.removed + plan.changed:
for ref in [r for r, c in index.chunks.items() if c.doc_id == doc_id]:
del index.chunks[ref]
for doc_id in plan.removed:
index.documents.pop(doc_id, None)
index.hashes.pop(doc_id, None)
for doc_id in plan.added + plan.changed:
doc = by_id[doc_id]
index.documents[doc_id] = doc
index.hashes[doc_id] = fingerprint(doc)
for chunk in chunk_document(doc, index.chunk_words, index.chunk_overlap):
index.chunks[chunk.ref] = chunk
index._bm25 = None
Changed documents are deleted first, then re-added: replacing by chunk id leaves the old chunks behind when a document shrinks.
Guard the delete
Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_sync.py
def check_plan(plan: SyncPlan, previous: SyncIndex, max_removed_share: float = 0.2) -> None:
"""Refuse a plan that deletes a large part of the index: an empty or unmounted source looks like that."""
total = len(previous.hashes)
if total and len(plan.removed) / total > max_removed_share:
raise SyncRefused(f"plan removes {len(plan.removed)} of {total} documents (limit {max_removed_share:.0%})")
The 0.2 is illustrative: set yours with the document owner, and let a person approve a plan above it.
The life of a version
Figure 2. What happens to a document's chunks over time
How to read this: Superseded is a label change, so it is a normal "changed" in the plan; only Removed deletes chunks.
Mark the old version with supersede(doc, new_id) and sync both: the old one is changed, the new one added. exclude_superseded=True then shows only current text.
Worked example
A Monday sync on the capstone's 30 documents (174 chunks). Four things happened at the source: