Data pipelines, section 4 of 4, 4h

Pipelines for AI: keeping a RAG index in sync

By the end: You can build an ingestion pipeline that adds, updates and deletes documents in a vector index incrementally and correctly.

  • Detecting changes with content hashes
  • Deletions, the part everyone forgets
  • Quarantining documents that fail to parse
  • Carrying permissions and metadata
  • Re-embedding when the model changes

New to data pipelines? A pipeline is code that runs on a schedule to move data from where it is produced to where it gets used, reshaping it on the way: pull yesterday's records from a database, clean them, write them somewhere a model or a dashboard can read. Nobody starts it by hand. It runs nightly or hourly, whether or not anyone is watching. Why it matters

A vector index is a copy of your documents, and copies go stale. This section is the ingestion job that keeps one honest as the source changes: working out which documents actually changed, replacing their chunks, removing the ones that have been deleted, and keeping permissions attached to every chunk. Deletion is the half that is usually missing, and it is the half that has an assistant confidently citing a policy withdrawn last month to someone who was never allowed to read it.

Only process what changed#

import hashlib

def sync(source_docs, index):
    seen = set()
    for doc in source_docs:                       # id, text, updated_at, acl
        seen.add(doc.id)
        digest = hashlib.sha256(doc.text.encode()).hexdigest()
        if index.stored_hash(doc.id) == digest:
            continue                              # unchanged: skip parsing and embedding
        chunks = chunk(parse(doc))
        index.replace_document(doc.id, embed(chunks), digest=digest, acl=doc.acl)

    for stale_id in index.document_ids() - seen:  # gone from the source
        index.delete_document(stale_id)

The hash is what makes this affordable. Embedding is the expensive step, and on a corpus of any size almost nothing changes between runs, so skipping unchanged documents takes a nightly sync from hours to minutes. Hash the content you actually embed, after parsing and normalising, not the raw bytes: a PDF re-exported with a new timestamp has different bytes and identical text, and hashing the bytes re-embeds the whole corpus every time someone touches the export job.

import hashlib

def fingerprint(text, acl, chunk_size, model):
    """Everything that, if changed, means the stored chunks are wrong."""
    parts = [text, ",".join(sorted(acl)), str(chunk_size), model]
    return hashlib.sha256("|".join(parts).encode()).hexdigest()

doc = dict(text="Refunds are issued within 14 days.", acl=["support", "billing"])
a = fingerprint(doc["text"], doc["acl"], 512, "embed-v3")
b = fingerprint(doc["text"], ["billing", "support"], 512, "embed-v3")
c = fingerprint(doc["text"], ["billing"], 512, "embed-v3")
d = fingerprint(doc["text"], doc["acl"], 256, "embed-v3")
print(a == b, a == c, a == d)      # True False False
Sorting the permission list is not cosmetic: unsorted, the same permissions in a different order look like a change and re-embed the corpus. Chunk size and model are in there because changing either invalidates every stored vector.

Replacing a document means deleting its chunks first#

A document that shrinks from twelve chunks to eight leaves four orphans behind if you only overwrite by chunk id. They are still in the index, still retrievable, and they contain text that was removed from the source, which is the same incident as failing to delete a document, arrived at more subtly. Delete every chunk belonging to the document, then insert the new set.

def replace_document(index, doc_id, chunks, digest, acl):
    index.delete_where(doc_id=doc_id)              # all of them, by document
    for i, chunk in enumerate(chunks):
        index.insert(
            chunk_id=f"{doc_id}:{i}",
            vector=embed(chunk),
            text=chunk,
            doc_id=doc_id,                          # the key deletion needs
            digest=digest,
            acl=acl,
        )
The doc_id on every chunk is what makes deletion possible at all. An index keyed only by chunk id cannot answer the question the sync asks every night.

Deletions#

If a document is deleted or unpublished at the source but its chunks stay in the index, the assistant keeps quoting it. For a withdrawn policy or a document a user lost access to, that is a real incident. Every sync must compare the full set of source ids with the index and remove the difference.

The trap in that set difference is a partial source listing. If the crawl fails halfway and returns two hundred of two thousand documents, the difference is eighteen hundred documents and the sync cheerfully empties the index. Guard it: require the listing to have completed, and refuse to delete more than a small share of the index in one run without a human saying so.

def safe_delete(index_ids, source_ids, listing_complete, max_share=0.05):
    if not listing_complete:
        raise RuntimeError("source listing incomplete, refusing to delete")
    missing = index_ids - source_ids
    if len(missing) > max_share * len(index_ids):
        raise RuntimeError(
            f"{len(missing)} of {len(index_ids)} documents missing, needs review"
        )
    return missing

print(safe_delete({"a", "b", "c", "d"}, {"a", "b", "c"}, True, max_share=0.5))  # {'d'}
try:
    safe_delete({"a", "b", "c", "d"}, {"a"}, True, max_share=0.5)
except RuntimeError as e:
    print(e)                          # 3 of 4 documents missing, needs review
The share is a circuit breaker, not a rule. A genuine bulk deletion should be a decision someone makes, not something a crawler timeout does on your behalf at two in the morning.

When parsing fails#

Some documents will not parse cleanly: scanned pages, broken encodings, huge tables. Do not index garbage. Send them to a quarantine list with the error, alert when it grows, and review it regularly. The reason to alert on the size rather than on each failure is that the interesting signal is a change: three files that never parse are a known annoyance, and three hundred means the source system changed its export format last night.

def sync_one(doc, index, quarantine):
    try:
        chunks = chunk(parse(doc))
    except Exception as e:
        quarantine[doc.id] = f"{type(e).__name__}: {e}"
        return "quarantined"
    if not chunks or sum(len(c) for c in chunks) < 40:
        quarantine[doc.id] = "parsed to nothing, probably a scan"
        return "quarantined"
    replace_document(index, doc.id, chunks, doc.digest, doc.acl)
    return "indexed"
The empty-parse check matters more than the exception handler. A scanned page usually parses successfully into three characters of whitespace, raises nothing, and silently becomes a chunk that matches everything weakly.

Permissions travel with the chunk#

Store who may see each document on every chunk, and filter on it at query time. When permissions change at the source, the sync must update them even if the text did not change, which is why the permission list is inside the fingerprint above rather than beside it.

Filter before the search, not after. Retrieving the top twenty and then discarding the ones this user may not see returns four results for one person and twenty for another, and on a document set where most of it is restricted it returns nothing at all while the index plainly contains the answer. Every serious vector store takes a metadata filter that is applied during the search; use it, and test it with an account that should see almost nothing.

Permission changes are the sync nobody schedules

Text changes when someone edits a document, which is visible and generates an event. Access changes when someone leaves a team, which generates nothing your crawler sees. If the source exposes permissions only on request, you have to re-read them for every document on some schedule regardless of whether the text moved, and that schedule is the real bound on how stale your access control is.

New embedding model#

Changing the embedding model means re-embedding everything into a new index, comparing retrieval quality, and switching over, as with any migration. Store the model name with each vector so a mixed index can never happen silently. A mixed index does not error; it returns nearest neighbours in a space where half the points were placed by a different function, and the results are plausible and wrong.

1  build a second index with the new model, from the same source
2  replay a fixed set of real queries against both
3  compare recall at 10 on the same judgements, and eyeball the disagreements
4  switch reads to the new index, keep the old one for a week
5  delete the old index, once nobody has asked for a rollback
Step 2 is the one that needs to already exist. Without a saved query set with judgements, the comparison becomes somebody trying six searches they can think of and declaring it fine.

Cost it before you agree to it. A million chunks at roughly five hundred tokens each is five hundred million tokens through an embedding model, plus the storage for a second full index while both exist, plus whatever the re-crawl costs at the source. None of that is prohibitive and all of it is a surprise if the first estimate appears after the migration has started.

Where people go wrong

An ingestion job that only ever adds. Stale and withdrawn documents accumulate and the assistant confidently cites them.

If this comes up in an interview

How do you keep a RAG index up to date as documents change?

Run an incremental sync: hash each document's content, and permissions, to skip unchanged ones, re-chunk and re-embed changed ones by replacing all their chunks, delete chunks for documents that disappeared from the source, and quarantine documents that fail to parse. Record the embedding model with each vector.

Then they push further

A user complains the assistant cited a policy withdrawn last month. What failed?

Deletions were not propagated. The sync handled additions and updates but never removed documents missing from the source. Add a set difference between source ids and index ids to every run, and a test for it.

What gets marked up. Strong answers include deletions and permissions without being prompted.

Build this: Build a sync over a folder of documents into a local vector store. Test four cases: add, edit, delete and a permission change, plus a corrupted file that must be quarantined.

This is one section of Data pipelines, 18 hours across 4 sections. Read it in full to get the whole topic in order.