Document Ingestion Pipeline with Dedupe and Tombstones
Build the ingestion side of a RAG index. Documents arrive with an id and a version, possibly out of order and repeated. Chunk and embed them, never pay to embed the same content twice, re-embed only what changed on update, and handle deletes so late writes cannot resurrect a deleted document.
1Ingest and chunk
ingest(doc_id, version, text) splits text into paragraph-bounded chunks of at most max_chars and stores an embedding per chunk via an injected embed function.
2Content-hash dedupe
Skip documents whose full-text hash is unchanged. Key chunks by SHA-256 so identical chunks across documents are embedded once and reference-counted.
3Incremental re-chunking
On update, embed only chunk hashes that are new and evict chunks whose reference count drops to zero. Ignore writes whose version is not newer than the stored one.
4Tombstones and deletes
delete(doc_id, version, now) evicts the document's chunks and records a tombstone that rejects older writes, even for a delete that arrives before the create. A newer version resurrects the document; gc_tombstones(now, grace) drops old tombstones.
Make chunk boundaries content-defined (paragraphs) so an edit only shifts local chunks, then make the chunk hash the identity: embeddings become a refcounted content-addressed store. Increment new refs before decrementing old ones so a chunk shared by the old and new version is never evicted and re-embedded. Versions plus tombstones turn an unordered event stream into last-writer-wins.
Try it first.
tests.py · 19 passing
calls: list[str] = []
def fake_embed(t: str) -> list[float]:
calls.append(t)
return [float(len(t))]
p = Pipeline(fake_embed, max_chars=10)
assert chunk("abc\n\n\n\ndefghijklmnop", 10) == ["abc", "defghijklm", "nop"]
# stage 1: ingest + chunk
o = p.ingest("d1", 1, "alpha\n\nbeta\n\ngamma")
assert o == Outcome("new", 3, 0) and len(p.vectors) == 3
# stage 2: content-hash dedupe (whole doc and cross-doc chunks)
assert p.ingest("d1", 2, "alpha\n\nbeta\n\ngamma") == Outcome("unchanged", 0, 0)
assert p.ingest("d2", 1, "beta\n\ndelta") == Outcome("new", 1, 0)
assert p.refs[sha("beta")] == 2 and len(calls) == 4
# stage 3: incremental re-chunking only embeds what changed
assert p.ingest("d1", 3, "alpha\n\nBETA\n\ngamma") == Outcome("updated", 1, 0) # "beta" still used by d2
assert p.ingest("d1", 4, "alpha\n\nBETA") == Outcome("updated", 0, 1) # gamma evicted
assert p.ingest("d1", 3, "late write") == Outcome("stale", 0, 0)
assert calls == ["alpha", "beta", "gamma", "delta", "BETA"]
# stage 4: tombstones
assert p.delete("d2", 2, now=100.0) == Outcome("deleted", 0, 2) # beta + delta gone
assert sha("beta") not in p.vectors and sha("alpha") in p.vectors
assert p.ingest("d2", 1, "beta\n\ndelta").status == "stale" # out-of-order replay blocked
assert p.delete("d3", 5, now=100.0).status == "deleted" # delete before create
assert p.ingest("d3", 4, "early").status == "stale"
assert p.gc_tombstones(now=150.0, grace=100.0) == 0
assert p.gc_tombstones(now=200.0, grace=100.0) == 2
assert p.ingest("d2", 3, "delta").status == "new" # newer version resurrects
assert sorted(p.texts.values()) == ["BETA", "alpha", "delta"]
assert all(p.refs[h] >= 1 for h in p.vectors)What breaks at scale? Tombstone GC opens a replay window, so set grace longer than the maximum queue lag; fixed-size windows inside long paragraphs still cascade, so use rolling-hash (FastCDC-style) boundaries; and the refcount store must be updated transactionally with the vector index, typically via an outbox so a crash cannot leak or double-free vectors.