Skip to content

    Forward Deployed Engineer

    Keep RAG Data Current: Sync, Deletes, Messy Files

    Detect new, changed and removed documents by content hash, delete removed chunks, and extract scans, tables and images into pieces that cite their source.

    By Published 10 min read

    Share

    This lesson is part of Forward Deployed Engineer: RAG, Search and Data. Enroll for free, then sign in, to read it in full.

    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

    flowchart LR accTitle: One sync run accDescr: The current source documents and the stored fingerprints are compared. The comparison produces a plan with added, changed, removed and unchanged documents. A size check refuses a plan that deletes too much. The apply step deletes old chunks and adds new ones. S["Source documents"] --> C{"Fingerprint
    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

    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

    stateDiagram-v2 accTitle: Lifecycle of a document version accDescr: A document is added and its chunks are searchable. An edit replaces its chunks. A newer version marks it superseded, so searches that exclude superseded text no longer show it. Deleting it at the source removes its chunks. [*] --> Current: added Current --> Current: edited, chunks replaced Current --> Superseded: newer version marks it Superseded --> Removed: deleted at source Current --> Removed: deleted at source Removed --> [*]: chunks deleted

    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:

    Event at the source Plan bucket Chunk effect
    partner-programme-guide deleted removed 5 chunks deleted
    ds-vs-100 gets a new "Spare parts" section changed 5 chunks replaced by 6
    faq-licensing moved to partner-only changed 8 chunks replaced by 8, label changed
    ds-vs-110 added added 1 chunk added

    The plan summary is added 1, changed 2, removed 1, unchanged 27, and the index ends with 174 - 5 + 1 + 1 = 171 chunks. A public search for "software licensing" no longer returns the FAQ.

    NOTE

    Assumptions: the edit text and the new datasheet are invented; the counts come from the capstone data and test_m3_l9_sync.py.

    At the customer: "How do we know a deleted document is really gone?" The plan lists the removal, the chunk count drops by that document's chunks, and a test searches for a phrase only it contained and expects nothing.

    Messy sources

    load_documents reads .md files only, so scans, tables and images need an extraction step before the sync.

    Figure 3. The extraction step before the sync

    flowchart LR accTitle: Extraction step before the sync accDescr: A source file goes to an extractor chosen by file type. An unknown type stops with an error. The extractor returns pieces with text, source, region, kind and score. Triage sets weak or empty pieces aside for a person and passes the rest into a document with one heading per region, which the sync then plans and applies. F["Source file"] --> R{"Extractor by
    file type"} R -- "none" --> E["Error, not a skip"] R --> P["Pieces: text, source,
    region, kind, score"] P --> T{"Triage"} T -- "weak or empty" --> V["A person reviews"] T -- "accepted" --> D["Document: one heading
    per region"] D --> S["Sync plan and apply"]

    How to read this: every piece carries a source and a region; an unknown file type is an error, not a skip.

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_extract.py

    @dataclass(frozen=True)
    class Piece:
        text: str
        source: str  # file name, kept so the citation can name it
        region: str  # "block 2", "row 5", "line 3", "figure": where in the file
        kind: str  # one of KINDS
        confidence: float | None = None  # set for OCR only
    
        @property
        def citation(self) -> str:
            return f"{self.source}, {self.region}"

    The Extractor protocol is one method, extract(path) -> list[Piece]. The lesson ships four: plain text (a piece per block), CSV (a piece per row, header repeated), image captions (a person writes figure.png.caption.txt; the caption is indexed, not the image) and OcrExtractor, built only when an OCR package imports. There is no PDF extractor: pypdf is not in requirements.txt, and this lesson does not add it.

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_extract.py

    def triage(pieces: list[Piece], min_confidence: float = 0.9) -> Triage:
        """OCR pieces below the threshold, or without a score, and empty pieces go to a person, not to the index."""
        accepted, review = [], []
        for piece in pieces:
            low = piece.kind == "ocr" and (piece.confidence is None or piece.confidence < min_confidence)
            (review if low or not piece.text.strip() else accepted).append(piece)
        return Triage(tuple(accepted), tuple(review))

    The 0.9 is illustrative: set yours by reading a sample. Accepted pieces become one document with the region as a heading, so a hit can cite the page or figure:

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L9/m3_l9_extract.py

        body = "\n\n".join(f"## {p.region}\n\n{p.text}" for p in pieces)
        return Document(doc_id, title, skus, visibility, "current", None, body)

    The middle column is this course's suggestion, not a sourced fact.

    Source What you do What can go wrong
    Text file Plain-text extractor, one piece per block Blocks too long or too short to chunk well
    Text PDF Extract text per page, region page N Reading order wrong, headers repeated on every page
    Scanned PDF Render pages to images, run OCR, triage A misread part number looks like normal text
    Table or spreadsheet One piece per row, header repeated; hand a scanned table to a person Merged cells, hidden sheets, formulas shown as values
    Image or diagram Index a human-written caption and link the figure A fact only in the picture is never found

    What the primary documentation says (as of 2026-11-04; check before relying):

    • pypdf: an image-only page "may be minimal or visually empty", "pypdf is not OCR software", and "tables are just absolutely positioned text" (extract text).
    • Tesseract, another OCR engine: "does not support reading PDF files" (input formats); it works best at 300 DPI or more and has a known problem with tables without custom layout analysis (quality guide).
    • RapidOCR, the optional engine here: a call returns boxes, txts and scores, one per line, and you install an inference engine such as onnxruntime yourself (usage, install).

    Embedding the image itself: not built here

    Multimodal retrieval embeds the image as a vector, so a text query finds a picture by similarity instead of finding text read out of it. CLIP is one published example: trained on 400 million (image, text) pairs to predict which caption goes with which image (Radford et al., 2021). The capstone does none of this: it has no embedding model, only keyword search, and none was run for this page.

    Build it

    Work in capstone/fde-product-discovery-copilot. The lesson folder lessons/M3-L9/ holds m3_l9_sync.py, m3_l9_extract.py and their tests. Run:

    ./.venv/Scripts/python -m pytest lessons/M3-L9 -q

    Read test_removed_document_can_no_longer_be_retrieved and test_add_only_sync_still_serves_the_deleted_document side by side: the second one empties the plan's removed list before applying it, which is the whole difference.

    The extraction tests need no OCR package: they use a fake engine and a PNG made with the standard library; one test reads a real image and skips itself without the packages. To read a real image, install the optional packages into the .venv only (requirements.txt is unchanged) and run the demo, which draws two images with Pillow:

    ./.venv/Scripts/python -m pip install -r requirements-ocr.txt
    ./.venv/Scripts/python lessons/M3-L9/m3_l9_ocr_demo.py

    Output from a run on 2026-11-04 (Windows, Python 3.14.2, rapidocr 3.10.0, onnxruntime 1.31.1; RapidOCR's own log lines are left out):

    clean.png accepted 3 (3 ocr), set aside for review 0
      clean.png, line 1: 'Part LUM-VS-100 torque 12 Nm' score 0.992
      clean.png, line 2: 'Seal limit 90 degrees C' score 0.995
      clean.png, line 3: 'Order code LUM-0B5-17' score 0.996
    degraded.png accepted 3 (3 ocr), set aside for review 0
      degraded.png, line 1: 'Part LUM-VS-100 1orque 12 Nm' score 0.958
      degraded.png, line 2: 'Seal limit 90 degrees C' score 0.983
      degraded.png, line 3: 'Order code LUM-0B5-17' score 0.976

    In the degraded image "torque" was read as "1orque" with a score of 0.958, above the 0.9 threshold. A high score is not proof of a correct reading, so a person still checks a sample of accepted pieces, especially part numbers. Three further runs here printed identical scores; other versions or platforms may not.

    Decide first, then reveal.

    Sync decisions at Lumen (interactive when JavaScript is on)

    A product owner re-saves a datasheet on another computer. The text is the same but the line endings differ. What should the next sync report?

    • changed, because the file bytes differ
    • unchanged, because the body is normalised before hashing
    • added, because the file has a new timestamp
    Answer

    unchanged, because the body is normalised before hashing. The fingerprint normalises line endings and trailing spaces, so a re-save is not an edit. Hashing raw file bytes would re-index it for no reason.

    The shared folder is unmounted overnight and the source list comes back empty. The sync plan removes all 30 documents. What stops the wipe?

    • Nothing: an empty source is a valid state
    • check_plan refuses a plan that removes more than the allowed share
    • The hash comparison skips removed documents
    Answer

    check_plan refuses a plan that removes more than the allowed share. check_plan raises when removals exceed the limit, so the index stays at 174 chunks. Treating an empty source as valid is exactly the failure the check is for.

    A supplier sends a scanned datasheet. OCR returns one line with a score of 0.41. What does triage do with it?

    • Indexes it, because OCR text is text
    • Sets it aside for a person to review
    • Deletes the whole document
    Answer

    Sets it aside for a person to review. Pieces below the threshold go to review, not to the index. Indexing them silently would let a misread part number look like normal text.

    Exercise

    Task: add describe_change(old: Document, new: Document) -> list[str] that names which fields differ, for a log line such as visibility public -> partner_only. Acceptance criteria: a body-only edit returns ["body"]; a label change returns the message above; identical documents return an empty list. Done when new tests in lessons/M3-L9/ pass and the full capstone suite stays green.

    Hint 1: which fields

    Compare the fingerprint's fields one by one. Report the body as the word body; the text does not belong in a log.

    Hint 2: normalised body

    Compare normalise_text(old.body) with normalise_text(new.body), so a line-ending change is not reported as an edit.

    Common mistakes

    • Syncing add and replace only, so deleted documents stay searchable.
    • Hashing the body only, so a change of access label is never re-indexed.
    • Replacing chunks by id, which leaves orphans when a document gets shorter.
    • Letting an empty or unmounted source delete the whole index.
    • Indexing OCR text without its source, region and score, or treating a high score as proof of a correct reading.

    Knowledge check

    1. Why is the access label part of the fingerprint?

    Every chunk carries a copy of the label, so a label change must reach the index. If only the body were hashed, a document moved to partner-only would look unchanged and stay visible to everyone.

    2. Why delete all chunks of a changed document before adding the new ones?

    A new version can have fewer chunks than the old one. Overwriting by chunk id leaves the extra old chunks behind, and they can still be retrieved as if they were current.

    3. What does the delete guard protect against, and what does it not?

    It stops a plan that removes too large a share of the index, such as one from an unmounted source. It cannot detect one or two documents lost silently, so a person still reviews the plan summary.

    4. How does marking a version superseded differ from deleting it?

    A superseded document stays stored, with its chunks in the index under a changed status, and searches that exclude superseded text hide it. Deleting removes the chunks entirely, so keep old versions only if someone needs to cite them.

    5. Why does every extracted piece keep its source and region, and why is low-confidence OCR set aside?

    They let an answer cite the page or figure, because the region becomes the chunk heading. OCR can misread a part number and still return normal-looking text, so weak pieces go to a person, not the index.

    You can now

    • Build a fingerprint that covers text, access label and version.
    • Plan and apply an incremental sync, with deletes that remove retrievable chunks.
    • Add a guard that refuses a suspicious delete.
    • Extract scans, tables and captions into cited pieces, and set weak OCR aside.
    • Say what multimodal retrieval is, and that this capstone does not do it.

    Further reading

    Last verified: 2026-11-03

    Next

    Back to the core path: Context and citations

    Enroll to keep reading

    Lesson 9 of 10 · Forward Deployed Engineer: RAG, Search and Data

    Detect new, changed and removed documents by content hash, delete removed chunks, and extract scans, tables and images into pieces that cite their source.

    In the rest of this lesson

    • Messy sources
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: RAG, Search and Data, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments