Module 5: Incremental and Idempotent Ingestion
Reingesting without duplicating
Description
The previous three lessons built each piece separately: upsert_chunk (Lesson 03) writes a chunk only if its content changed; detect_changes (Lesson 04) classifies every document without touching ingest_document. This lesson ties them together into a single function, reingest(), which is the complete answer to the problem raised in Lesson 02: running ingestion two, ten, or a thousand times on the same corpus, without the chunk store ever ending up with a single extra row.
reingest() doesn't add any new concept — it's the orchestration of what you already have: first it asks what changed (detect_changes), then it does the expensive work (ingest_document + upsert_chunk) only for what needs it, and it purges whatever no longer exists. This lesson's result is the central piece of evidence for the whole module: reingesting the full canonical corpus, twice in a row, produces exactly the same store both times.
Connection to the module
This lesson combines content_hash/upsert_chunk (Lesson 03) with detect_changes (Lesson 04) into reingest(), the function Lessons 06, 07, and 08 use unchanged for the modified-document scenario, the deleted-document scenario, and the mini-project's final combined run.
Analogy: the clerk's full round
The clerk from earlier lessons already has their two tools: the cover label (doc_hash, for knowing which folders changed without opening them) and the per-page fingerprint notebook (content_hash, for knowing exactly which pages to refile inside a folder that did change). This lesson gives them their full morning routine: first they walk through every folder in the inbox looking only at the cover labels — a quick glance, nothing opened. For folders whose label didn't change, they do nothing else: leave them where they are. For new folders or folders with a different label, only then do they open them, scan their pages, and file only the ones that genuinely changed. And for any folder that had a record but is no longer in the tray, they pull its pages from the archive. That full routine, run end to end, is reingest().
reingest(): detect, process only what changed, purge what's gone
reingest() needs a third piece, alongside upsert_doc_chunks: what to do with a doc_id that detect_changes classified as deleted. The minimal version is simple — delete its chunks and its row in documents — and it's exactly what reingest() uses in this lesson; Lesson 07 comes back to this same function to confirm, with an explicit query, that the purge leaves no orphaned chunk.
def purge_doc(conn: sqlite3.Connection, doc_id: str) -> int:
"""Removes every chunk of `doc_id` from the store, plus its row in
`documents`. Returns how many chunk rows were deleted."""
cur = conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))
conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,))
return cur.rowcount
def reingest(conn: sqlite3.Connection, current_docs: dict[str, tuple[str, str]]) -> dict:
"""The full incremental ingestion pass: detect what changed, do the
expensive work (ingest_document + upsert_chunk) only for new/modified
docs, and purge chunks for anything that disappeared from current_docs."""
changes = detect_changes(conn, current_docs)
chunks_written = 0
for doc_id in changes.new + changes.modified:
fmt, raw_text = current_docs[doc_id]
chunks_written += upsert_doc_chunks(conn, doc_id, fmt, raw_text)
chunks_purged = 0
for doc_id in changes.deleted:
chunks_purged += purge_doc(conn, doc_id)
conn.commit()
return {
"new": changes.new,
"modified": changes.modified,
"deleted": changes.deleted,
"unchanged_count": len(changes.unchanged),
"chunks_written": chunks_written,
"chunks_purged": chunks_purged,
}
Three steps, in the same order as the analogy:
detect_changes(conn, current_docs)— the full classification from Lesson 04, without touchingingest_document.- For every
doc_idinnewormodified— never forunchanged— callupsert_doc_chunks, which only then runsingest_document(parse + chunk) and thenupsert_chunkfor each resulting chunk. - For every
doc_idindeleted, callpurge_doc(defined above; Lesson 07 goes deeper into it with the check that no chunk is left orphaned).
upsert_doc_chunks is the piece that was missing to connect ingest_document (Module 1) with upsert_chunk (Lesson 03):
from reservo_corpus import ingest_document
def upsert_doc_chunks(conn: sqlite3.Connection, doc_id: str, fmt: str, raw_text: str) -> int:
"""Re-ingests ONE document (parse + chunk) and upserts its chunks.
Returns how many chunks were actually written (changed), not the total
chunk count of the document."""
chunks = ingest_document(doc_id, fmt, raw_text)
written = sum(upsert_chunk(conn, c) for c in chunks)
conn.execute("""
INSERT INTO documents (doc_id, fmt, raw_hash, ingested_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(doc_id) DO UPDATE SET
fmt=excluded.fmt, raw_hash=excluded.raw_hash, ingested_at=excluded.ingested_at
""", (doc_id, fmt, doc_hash(raw_text), INGESTION_DATE))
return written
upsert_doc_chunks does two things: it calls ingest_document (the only time parse_markdown/parse_html/clean_text run in this entire lesson, and only for documents detect_changes flagged as new or modified), and then it registers the updated doc_hash in documents — so the next detect_changes run sees this document as unchanged, even if today it was new or modified.
The central evidence: reingest twice, zero duplicates
from reservo_corpus import RAW_DOCS
conn = create_store(":memory:")
print("--- run 1: first ingestion ---")
r1 = reingest(conn, RAW_DOCS)
total_1 = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print(f"new={len(r1['new'])} modified={len(r1['modified'])} deleted={len(r1['deleted'])} "
f"unchanged={r1['unchanged_count']} written={r1['chunks_written']} purged={r1['chunks_purged']}")
print("total chunks in store:", total_1)
print()
print("--- run 2: reingest the identical corpus ---")
r2 = reingest(conn, RAW_DOCS)
total_2 = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print(f"new={len(r2['new'])} modified={len(r2['modified'])} deleted={len(r2['deleted'])} "
f"unchanged={r2['unchanged_count']} written={r2['chunks_written']} purged={r2['chunks_purged']}")
print("total chunks in store:", total_2)
distinct = conn.execute("SELECT COUNT(DISTINCT chunk_id) FROM chunks").fetchone()[0]
print()
print("distinct chunk_id after 2 runs:", distinct)
print("matches build_corpus():", distinct == len(build_corpus()))
What to expect (run):
--- run 1: first ingestion ---
new=13 modified=0 deleted=0 unchanged=0 written=57 purged=0
total chunks in store: 57
--- run 2: reingest the identical corpus ---
new=0 modified=0 deleted=0 unchanged=13 written=0 purged=0
total chunks in store: 57
distinct chunk_id after 2 runs: 57
matches build_corpus(): True
This is the result this module opened with, now achieved: run 1 ingests all 13 documents from scratch (new=13, written=57). Run 2, on exactly the same RAW_DOCS, finds nothing new, modified, or deleted (unchanged=13) and writes not a single row (written=0). The total chunk count stays at 57 both times — not 114, like the naive ingestion from Lesson 02. distinct chunk_id matches len(build_corpus()) exactly: after two runs, the store has precisely the chunks the canonical corpus produces, not one more.
Common mistakes
- Calling
ingest_documentfor every document incurrent_docs, not justnew + modified. This is a subtle bug: the code "works" (the final result is correct), but it completely loses the efficiencydetect_changesmakes possible — it reparses documents that didn't change, every run, forever. Lesson 06 measures exactly how much work doing this right avoids. - Forgetting to register
doc_hashindocumentsinsideupsert_doc_chunks. Ifreingest()writes the chunks but never updates thedocumentstable, the nextdetect_changesrun will keep seeing that document asnew(becausedocumentswas never updated) and will repeatingest_document's work on every run, even though the chunks are already correctly saved. - Not committing (
conn.commit()) at the end ofreingest(). Without the commit, changes stay in the open transaction and don't actually persist in the database — an invisible problem with:memory:within the same process (whereconnstays the same object), but a real one for any file-backed store if the process ends before committing. - Confusing
chunks_written=0with "the run did nothing useful". Areingest()that reportswritten=0, purged=0on an unchanged corpus is exactly the correct behavior — it means the system cheaply confirmed the store is still in sync with the source. It's the same idea as Common Mistake 4 from Lesson 04:unchanged=13is a signal of success, not that "nothing happened".
Exercises
Exercise 1: A third identical run (Easy)
On the same conn from run 1 and run 2 of the worked example, call reingest(conn, RAW_DOCS) a third time. What do you expect for chunks_written and for the total chunk count in the store? Confirm by running it.
See solution
r3 = reingest(conn, RAW_DOCS)
total_3 = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print(f"written={r3['chunks_written']} purged={r3['chunks_purged']} unchanged={r3['unchanged_count']}")
print("total chunks:", total_3)
Expected output:
written=0 purged=0 unchanged=13
total chunks: 57
Explanation: no number of additional runs on the same unchanged corpus adds or removes a single row — the idempotency property doesn't depend on how many times it's invoked, only on whether the content actually changed. reingest() run 2, 3, or 300 times on unmodified RAW_DOCS always gives the same result: 57 chunks, 0 writes after the first run.
Exercise 2: The two tables stay in sync (Medium)
After the worked example's runs, confirm with a SQL query that the documents table has exactly 13 rows — one per doc_id in the corpus, not one more or fewer, regardless of reingest() having been called twice.
See solution
docs_count = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
print("documents table rows:", docs_count)
Expected output:
documents table rows: 13
Explanation: just like chunks, the documents table uses doc_id as a primary key with an UPSERT (ON CONFLICT(doc_id) DO UPDATE) inside upsert_doc_chunks — so no matter how many times reingest() processes the same doc_id, the row gets updated in place, never duplicated. The store's two tables — chunks and documents — stay in sync with each other after any number of runs: 57 chunks, spread across exactly 13 documents.
Exercise 3: The danger of an incomplete current_docs (Hard)
Build partial_docs as a dictionary with only the first 5 documents from RAW_DOCS (for example, dict(list(RAW_DOCS.items())[:5])) — simulating a bug where the code that reads the source directory only found 5 of the 13 files. On a store that already has all 13 documents ingested (as at the end of the worked example), call reingest(conn, partial_docs). What happens to the other 8 documents? Think through the answer before running it — this is the most dangerous mistake in the entire module.
See solution
partial_docs = dict(list(RAW_DOCS.items())[:5])
print("partial_docs has", len(partial_docs), "documents:", list(partial_docs.keys()))
r_partial = reingest(conn, partial_docs)
total_partial = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print("deleted:", r_partial["deleted"])
print("chunks_purged:", r_partial["chunks_purged"])
print("total chunks after:", total_partial)
Expected output:
partial_docs has 5 documents: ['cancellation-policy', 'no-show-policy', 'refund-policy', 'booking-faq', 'membership-tiers-faq']
deleted: ['boardroom-room-manual', 'focus-room-manual', 'lounge-room-manual', 'operations-manual-raw', 'payment-methods-faq', 'phonebooth-room-manual', 'studio-room-manual', 'wifi-and-equipment-faq']
chunks_purged: 37
total chunks after: 20
Explanation: reingest() purges the 8 documents missing from partial_docs — 37 chunks deleted, the store drops from 57 to 20. From detect_changes's point of view, there's no difference at all between "this document was genuinely deleted" and "this document didn't show up in current_docs because the code that builds that dictionary has a bug" — the two situations look exactly the same: a doc_id that was registered and is no longer in the current input. This is the real risk of an automatic purge system: it's only as trustworthy as the source of current_docs. If load_corpus() (Module 1, Lesson 08) had a bug that only reads 5 of 13 files — a misconfigured permission, a wrong extension filter, a half-mounted directory — reingest() would interpret the absence as a legitimate deletion and purge documents that actually still existed in the source. The practical takeaway: an automatic purge pipeline needs, in production, some additional safeguard — for example, refusing to purge more than some percentage of the corpus in a single run, or requiring manual confirmation if deleted exceeds a threshold — before blindly trusting that "not in current_docs" always means "deliberately deleted". This guide doesn't build that safeguard (it's out of scope), but knowing the risk is part of operating this system responsibly.
Summary and next step
reingest()tiesdetect_changes(Lesson 04) together withupsert_chunk(Lesson 03) into a single function: it detects what changed, processes onlynew/modifiedwithingest_document+upsert_chunk, and purgesdeleted— never touchingunchangedat all.- Run twice on the full canonical corpus: run 1 writes 57 chunks; run 2, on the same corpus, writes zero — the store stays at 57, not 114. This is the module's central piece of evidence.
- The
chunksanddocumentstables stay in sync with each other after any number of runs, thanks to the same primary-keyUPSERTpattern on both. - An incomplete
current_docsis indistinguishable, toreingest(), from a real deletion — a genuine risk of any pipeline with automatic purging, worth knowing about even though this guide doesn't solve it.
Next lesson: 06 — Updating only what changed. A document genuinely edited (refund-policy gains a sentence), and the confirmation that reingest() rewrites a single chunk, not all 57.
Additional resources
- Python —
sqlite3—commit()and why it's needed at the end of any function that writes, includingreingest(). - SQLite — Transactions — the model behind why a missing
commit()leaves changes unpersisted. production-rag-and-document-ingestion-guide— Module 1, Lesson 08 (08-mini-project-ingest-the-reservo-corpus.md):load_corpus(), the real function that, in a production system, builds thecurrent_docsthatreingest()receives — and whose correct behavior is the real safeguard against Exercise 3's risk.airflow-and-declarative-orchestration-guide(Data Engineering ecosystem) — where you learn to add retries, alerts, and threshold safeguards to a pipeline like this before running it unsupervised in production.