Data pipelines
Moving and reshaping data reliably, on a schedule.
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.
Pipelines fail halfway. A run dies after writing half its rows, and the next run either duplicates them or skips them, and nobody notices until a month of numbers is quietly wrong. Everything downstream inherits that: the model trains on it, the eval scores against it, and the dashboard reports it with no sign that anything is amiss.
It builds on SQL and Python and sits under everything that consumes data in bulk: training sets, retrieval corpora, eval sets and the numbers that tell you a launch worked. It pairs with data quality and labelling, which is how you find out what the pipeline did to your data.
The design question is never the run that works, it is what happens when you run it again after it died halfway.
In more detailBatch and streaming ingestion, scheduling, idempotent reruns, backfills, and schema evolution. The recurring lesson is that pipelines fail partially and silently, so the design question is always what happens on the second run after a half-finished first one.
Learn first
Moving data reliably
By the end: You can build a pipeline that survives being run twice.
- Batch and streaming
- Idempotency and backfills
- Schema change
- Data quality checks
A pipeline is a job that moves data from where it is produced to where it is used, on a schedule, without anyone watching. The hard part is not moving it once but surviving everything that goes wrong afterwards: the run that fails halfway, the run that fires twice, the upstream column that quietly changes type. This section is about building one that can be re-run safely, which is the property that separates a pipeline from a script.
What went wrong on Monday What the pipeline needed
the job died after writing half the rows replace a partition, never append
a bug was found in last quarter's numbers a date parameter, not "yesterday"
upstream added a column a schema check that tolerates additions
upstream turned an integer into a string a schema check that fails on types
the export ran but produced 40 rows a volume check against recent history
an event arrived three days late a trailing reprocess windowPipelines fail partially#
The defining problem is that a pipeline stops halfway. Some data was written, some was not, and nothing recorded where it got to. Nothing in the target says which rows are from the failed run, because rows do not carry that. Every design decision follows from asking what happens on the second run.
Make reruns safe#
An idempotent pipeline produces the same result whether it runs once or five times. That is a property of how you write, not a claim you make in a docstring, so here it is in code. The target is a list of rows tagged with the day they belong to, and the difference between the two loaders is one line.
def load_append(table, day, rows):
return table + [(day, r) for r in rows]
def load_replace(table, day, rows):
kept = [row for row in table if row[0] != day] # drop the day first
return kept + [(day, r) for r in rows]
rows = ["a", "b", "c"]
t = []
t = load_append(t, "2026-03-01", rows)
t = load_append(t, "2026-03-01", rows) # the retry after a timeout
print(len(t)) # 6: the day is counted twice
t = []
t = load_replace(t, "2026-03-01", rows)
t = load_replace(t, "2026-03-01", rows) # the same retry
print(len(t)) # 3: identical to one runThat is only half of it. Delete-then-insert as two visible steps leaves a window in which the day exists but is empty, and a crash inside that window is worse than the duplicate it was meant to prevent: nothing errors, and a reader sees a real zero. Build the new state first, then publish it with one operation that either happens or does not.
def build(table, day, rows, fail_after=None):
"""Compute the whole new state. Publishing is the caller's single assignment."""
staged = [row for row in table if row[0] != day]
for i, r in enumerate(rows):
if fail_after is not None and i == fail_after:
raise RuntimeError("source timed out")
staged.append((day, r))
return staged
table = build([], "2026-03-01", ["a", "b", "c"])
try:
table = build(table, "2026-03-02", ["d", "e", "f"], fail_after=1)
except RuntimeError:
pass # the assignment never ran
print(len(table)) # 3: day two published nothing at all
print(sorted({d for d, _ in table})) # ['2026-03-01']Backfills#
At some point you will reprocess two years of history, usually because a transform was wrong. The loop over dates is the easy part. What actually bites is everything around it.
- The job must take a date. A pipeline that reads the clock cannot be backfilled at all, and rewriting it to take one is the first day of the work.
- Direction matters. Run forwards if a day's output depends on the day before it; run backwards from today if the recent months are the ones people are looking at.
- Throughput. Seven hundred days launched at once will saturate the source database and page whoever owns it. Cap the concurrency and run it in chunks.
- Cost. Seven hundred times the daily cost, paid in an afternoon rather than over two years. Work it out before you start, not from the invoice.
- The table is live while you rewrite it. For hours, readers see some days on the old logic and some on the new. Either say so, or backfill into a copy and swap at the end.
- It will be interrupted. Keep a ledger of finished days so the resume costs minutes rather than starting again.
import datetime as dt
def daterange(start, end):
day = start
while day <= end:
yield day
day += dt.timedelta(days=1)
def backfill(run, start, end, ledger, limit=None):
"""Resumable. Takes the same run(day) the scheduler calls every morning."""
done = set(ledger.read())
processed = 0
for day in daterange(start, end):
if day.isoformat() in done:
continue
run(day) # publishes the day, atomically
ledger.mark(day.isoformat()) # only now is it done
processed += 1
if limit is not None and processed >= limit:
break # one chunk per invocation
return processed
class MemoryLedger:
def __init__(self): self.done = []
def read(self): return list(self.done)
def mark(self, key): self.done.append(key)
seen, ledger = [], MemoryLedger()
backfill(seen.append, dt.date(2026, 3, 1), dt.date(2026, 3, 10), ledger, limit=4)
backfill(seen.append, dt.date(2026, 3, 1), dt.date(2026, 3, 10), ledger)
print(len(seen), len(set(seen))) # 10 10: nothing ran twiceSchemas change#
Three things can happen to an upstream schema and only one of them is safe. A column is added, which nothing downstream reads and nothing breaks. A column is removed or renamed, which breaks every consumer that reads it, loudly and immediately. Or a column keeps its name and changes type, which breaks nothing at load time and quietly corrupts everything computed from it. The third is the one worth writing code against.
EXPECTED = {"order_id": "int", "user_id": "int", "total": "float", "country": "str"}
def kind_of(value):
return {bool: "bool", int: "int", float: "float", str: "str"}.get(type(value), "other")
def diff_schema(expected, row):
observed = {k: kind_of(v) for k, v in row.items() if v is not None}
added = [k for k in observed if k not in expected]
missing = [k for k in expected if k not in row]
changed = [
(k, expected[k], observed[k])
for k in observed
if k in expected and observed[k] != expected[k]
and not (expected[k] == "float" and observed[k] == "int") # widening is safe
]
return added, missing, changed
row = {"order_id": 7, "user_id": 31, "total": "19.99", "country": "DE", "channel": "app"}
added, missing, changed = diff_schema(EXPECTED, row)
print(added) # ['channel'] log it, carry on
print(missing) # [] a removal would be here
print(changed) # [('total', 'float', 'str')] stop the runThe policy that follows is dull and works. Additions are allowed without ceremony. Type changes stop the run. A rename is not one change, it is three separated in time, and doing all three in one release is an outage.
week 0 producer writes both country and country_code nothing breaks
week 1 consumers move to country_code, one at a time country still written
week 3 producer stops writing country nothing reads it
schema version bumped, old version deletedQuality checks in the pipeline#
Two kinds, and most pipelines only have the first. Absolute checks say what must always be true of a batch. They catch broken data.
def check(df, expected_rows):
assert len(df) > 0, "empty batch"
assert df.id.is_unique, "duplicate ids"
assert df.total.notna().all(), "missing totals"
assert (df.total >= 0).all(), "negative totals"
# Volume check catches upstream failures that produce valid but tiny data
assert len(df) > 0.5 * expected_rows, f"only {len(df)} rows, expected {expected_rows}"Relative checks compare today against what the last few runs looked like. They catch data that is not broken, just wrong: a source that silently started filtering, a partner that changed a currency, a region that stopped reporting. Compare against the median of recent runs rather than the mean, or one bad day raises the bar for the whole week after it.
def check_relative(today, history, tolerance=0.5):
"""history: row counts from the last successful runs, most recent last."""
if len(history) < 7:
return # not enough to judge yet
typical = sorted(history)[len(history) // 2]
if today < typical * tolerance or today > typical / tolerance:
raise ValueError(f"{today} rows against a typical {typical}")
recent = [4000, 4200, 3900, 4100, 4050, 4300, 3950]
check_relative(4100, recent) # fine
try:
check_relative(40, recent) # the export that finished early
except ValueError as e:
print(e) # 40 rows against a typical 4050Fail the pipeline, do not write bad data
Writing suspect data and flagging it means downstream consumers use it before anyone reads the flag. Stopping is disruptive and recoverable; silent corruption spreads and is not. The check belongs between the transform and the publish, on the staged result, so a failure leaves yesterday's good data in place.
Batch or streaming#
Batch accumulates data and processes a period of it. Streaming processes each event as it arrives. The difference people quote is latency, but the difference that decides the design is what you can redo. A batch job's input for a period is a fixed set of files that still exist, so a rerun is exact. A streaming job's input is a position in a log, redoing means replaying from an offset, and everything the job already emitted has already been acted on downstream.
Batch Streaming
unit of work a period: an hour, a day one event
rerun reprocess the period rewind to an offset, replay
correct on retry free, if you replace only if every write is idempotent
late data lands in a later run needs a watermark and a policy
cost when idle nothing the cluster runs all night
debugging rerun it locally on files reproduce a moment in a stream
people to operate one a rotaSo the question is whether freshness changes a decision. Fraud, alerting and live personalisation, yes. Reports, training data and nearly all analytics, no. Ask whether anyone would act differently if this data were an hour old, and if the answer is no, run it hourly. A five-minute batch gets most of the benefit of streaming while keeping batch's recovery story, which is why micro-batch is where a lot of supposedly streaming systems end up.
Where people go wrong
Appending instead of replacing. The first rerun after a failure silently doubles a day of data, and the error surfaces weeks later as an inexplicable spike in a dashboard.
Build this: Build a pipeline that processes a date range, make it idempotent, add quality checks, then run it twice on the same date and confirm the output is identical.
Open this section on its own pageOrchestration, streaming and lineage
By the end: You can choose between batch and streaming and keep track of where data came from.
- Batch versus streaming
- Orchestrators and dependencies
- Late and out-of-order data
- Lineage
A pipeline that runs once is a script. Making it run every day, in the right order, and tell you exactly which step failed is a different job. This section covers choosing between batch and streaming honestly, since streaming sounds better and is considerably harder to operate; what an orchestrator gives you beyond a cron entry; what to do with events that arrive late or out of order; and lineage, which is being able to answer where a number came from when someone disputes it.
Batch or streaming#
Batch processes accumulated data on a schedule. Streaming processes each event as it arrives. Streaming sounds better and is much harder to operate. Choose it only when freshness genuinely matters to the decision the data feeds.
Batch, hourly or daily reports, training data, most analytics
Streaming, seconds fraud, alerting, live personalisation
Ask: would anyone act differently if this data were an hour old?
If not, batch.The part that is easy to underestimate is the operating cost. A batch job that fails is a red square and a rerun in the morning. A streaming job that fails is consumer lag climbing while someone works out whether it is safe to restart from the last committed offset, and lag that grows faster than the job can catch up is an outage with no natural end. Exactly-once, where a framework offers it, holds inside that framework's own state and its own sinks. The moment the job calls an external API or writes to a database it does not manage, you are back to at-least-once and the write has to be idempotent yourself.
Orchestration#
Pipelines have dependencies: this table needs that one to finish first. An orchestrator runs steps in dependency order, retries failures, and records what ran. The value is less in running things and more in knowing, after a failure, exactly which steps completed. The core of one is smaller than the products suggest, and writing it once makes clear what you are actually buying.
def run_dag(tasks, deps, attempts=2):
"""tasks: name -> callable. deps: name -> names that must finish first."""
state = {name: "pending" for name in tasks}
def ready(name):
return all(state[d] == "ok" for d in deps.get(name, ()))
progressed = True
while progressed:
progressed = False
for name, fn in tasks.items():
if state[name] != "pending" or not ready(name):
continue
for attempt in range(attempts):
try:
fn()
state[name] = "ok"
break
except Exception as e:
state[name] = f"failed: {e}"
progressed = True
# Anything still pending was blocked by an upstream failure, not broken itself.
for name in state:
if state[name] == "pending":
state[name] = "skipped: upstream failed"
return state
calls, tried = [], {"clean": 0}
def extract():
calls.append("extract")
def clean():
tried["clean"] += 1
if tried["clean"] == 1:
raise RuntimeError("bad row") # fails the first time, then works
calls.append("clean")
def report():
calls.append("report")
tasks = {"extract": extract, "clean": clean, "report": report}
deps = {"clean": ["extract"], "report": ["clean"]}
print(run_dag(tasks, deps))
# {'extract': 'ok', 'clean': 'ok', 'report': 'ok'} -- clean failed once and retried
print(calls) # ['extract', 'clean', 'report']What a real orchestrator adds on top is a scheduler that knows about dates, parallel execution with a concurrency cap, a record of every past run, and a page that shows which day of which task is red. Cron gives you none of that and is fine for one independent job. The moment a second job reads the first one's output, you need the dependency edge, because otherwise the second job runs on yesterday's data whenever the first one is late and nothing anywhere says so.
Late and out-of-order data#
Events arrive late, out of order, or twice. A phone was in a tunnel; a partner batches their exports; a retry duplicated a send. A pipeline that assumes ordered arrival produces aggregates that were right when computed and are wrong an hour later, which is the worst combination because nothing errors and the number moves after someone quoted it.
There are two clocks and keeping them apart is most of the battle. Event time is when the thing happened. Processing time is when your job saw it. Aggregate by event time or the Monday figure includes whatever straggled in on Tuesday. A watermark is your declared position in event time: the point past which you promise not to accept anything more.
from collections import defaultdict
def aggregate(events, allowed_lateness=2):
"""events: (event_time, processing_order) pairs, in arrival order.
Counts per event-time bucket, with a watermark that closes old buckets."""
counts, watermark, dropped = defaultdict(int), None, []
for event_time, _ in events:
watermark = event_time if watermark is None else max(watermark, event_time)
if event_time < watermark - allowed_lateness:
dropped.append(event_time) # too late: count it separately
continue
counts[event_time] += 1
return dict(counts), dropped
arrivals = [(10, 0), (11, 1), (9, 2), (12, 3), (8, 4), (12, 5)]
print(aggregate(arrivals))
# ({10: 1, 11: 1, 9: 1, 12: 2}, [8]) -- the event from time 8 arrived after the
# watermark reached 12, so it is dropped rather than silently changing a closed bucketDropping is one policy of three and you must choose explicitly. Drop, and the bucket is stable but slightly under-counts. Accept and restate, and the bucket is accurate but a number in a dashboard changes after someone screenshotted it. Send late events to a side output and correct in a nightly batch, which is what most systems that care about both end up doing. Say which you chose in the documentation the analysts read, because they will find the discrepancy before you do.
Duplicates are a separate problem from lateness
At-least-once delivery means the same event can arrive twice, minutes or hours apart. The fix is not ordering, it is a deduplication key: give every event an id at the producer, and keep a set of ids seen within the lateness window. Without a producer-assigned id there is nothing to deduplicate on, and adding one later means a migration.
Lineage#
Lineage records which inputs produced which outputs. When a number in a dashboard is wrong, lineage lets you trace it to the source rather than guessing. For ML it answers the question every audit asks: exactly which data was this model trained on. It is cheap to record at write time and impossible to reconstruct afterwards, which is why it is always missing when it is first needed.
import hashlib, json, datetime as dt
def manifest(output_path, inputs, code_version, row_count):
"""Write this next to every output. It is the whole of lineage at one step."""
return {
"output": output_path,
"written_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"code_version": code_version, # git sha of the transform
"inputs": [
{"path": p, "rows": n, "content_hash": h} for p, n, h in inputs
],
"rows": row_count,
}
def digest(rows):
return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()[:12]
raw = [{"id": 1, "total": 10}, {"id": 2, "total": 5}]
print(manifest(
"warehouse/daily/date=2026-03-01",
[("raw/orders/date=2026-03-01", len(raw), digest(raw))],
code_version="a91f0c3",
row_count=len(raw),
))Lineage is what makes a model reproducible
Without it, you cannot retrain the same model, cannot explain a result, and cannot remove data a user asked to be deleted. That last one is not a nice-to-have: a deletion request obliges you to find every derived table and every training set the record reached, and without lineage the honest answer is that you cannot. It is dull to build and indispensable later.
Where people go wrong
Building a streaming pipeline for data nobody needs in real time. It costs far more to run and debug than a batch job that would have served the same decisions.
Build this: Build the same aggregation as a daily batch and as a stream, then feed both late and duplicated events. Compare the results and the effort.
Open this section on its own pageA daily pipeline you can rerun safely
By the end: You can write a daily load that can be rerun or backfilled for any date without duplicating or losing data.
- Partition by date
- Write, check, then publish
- Replace the partition, never append blindly
- Backfills as a loop over dates
- Scheduling it
A scheduled job that loads yesterday's data will fail one morning, and someone will run it again. This section is about writing it so the rerun is harmless: partition the output by date, compute into a temporary location, check the result, and replace a whole day at once instead of appending. Get that property and every failure has the same fix, which is to run the day again, and backfilling a bug fix across three months is the same code in a loop rather than a special path.
The shape#
extract day D from source -> transform -> check -> replace partition D in the target
|
fail: publish nothing, alertIn code#
import duckdb, datetime as dt, pathlib, shutil
def run(day: dt.date):
src = f"raw/calls/date={day}/*.json"
out = pathlib.Path(f"warehouse/daily_usage/date={day}")
tmp = out.with_name(out.name + ".tmp")
con = duckdb.connect()
df = con.sql(f"""
SELECT user_id, model, sum(input_tokens) AS input_tokens,
sum(output_tokens) AS output_tokens, count(*) AS calls
FROM read_json_auto('{src}')
WHERE status = 'ok'
GROUP BY ALL
""").df()
check(df, day) # raises on bad data
tmp.mkdir(parents=True, exist_ok=True)
df.to_parquet(tmp / "part.parquet")
if out.exists():
shutil.rmtree(out)
tmp.rename(out) # publish: replace the whole day
def check(df, day):
assert len(df) > 0, f"{day}: no rows"
assert (df["input_tokens"] >= 0).all(), f"{day}: negative tokens"
assert df["user_id"].notna().all(), f"{day}: missing user ids"Because each run replaces exactly one day's partition, running it twice gives the same result as running it once. That property, idempotency, is what makes failures boring: rerun the day. Three details in that code are doing the work and are worth naming, because leaving any one of them out gives you a pipeline that looks idempotent and is not.
- The check runs on the staged result, before anything is published. Check after publishing and you have already corrupted the day you are about to complain about.
- The write goes to a temporary directory with a different name, so a half-written file is never at the path readers use.
- The last step is a rename, which the filesystem performs as one operation. Writing directly into the output directory and deleting the extra files afterwards is two operations, and a crash between them leaves a day that is part old and part new.
That third point has a limit worth knowing. The rmtree then rename above is itself two operations, so a crash in the gap leaves the day missing entirely rather than mixed. Missing is recoverable and mixed is not, which is why it is written in that order, but if you need the swap to be genuinely atomic then write to a new versioned directory and move a pointer, which is what table formats such as Iceberg and Delta do for you.
Proving it, rather than believing it#
Idempotency is a claim about behaviour under failure, so the test has to inject a failure. This runs without a warehouse: a dict stands in for the target, and the load is killed at a chosen row.
def load(target, day, rows, die_at=None):
"""Stage the whole day, then publish with one assignment."""
staged = []
for i, row in enumerate(rows):
if die_at is not None and i == die_at:
raise RuntimeError("killed mid-run")
staged.append(row)
target[day] = staged # the publish
return target
target, rows = {}, [1, 2, 3, 4]
for die_at in (None, 0, 2, 3): # clean run, then three crash points
try:
load(target, "2026-03-01", rows, die_at=die_at)
except RuntimeError:
pass
print(target) # {'2026-03-01': [1, 2, 3, 4]}
print(sum(target["2026-03-01"])) # 10, after four runs and three crashesWhen the target is a table, not files#
Same property, different mechanism. Delete the day's rows and insert the new ones inside one transaction, so no reader ever sees the gap and a failure rolls the whole thing back.
BEGIN;
DELETE FROM daily_usage WHERE usage_date = :day;
INSERT INTO daily_usage
SELECT :day, user_id, model, sum(input_tokens), sum(output_tokens), count(*)
FROM raw_calls
WHERE call_date = :day AND status = 'ok'
GROUP BY user_id, model;
COMMIT;The alternative is a merge on a natural key, which upserts row by row rather than replacing a period. It is the right shape when rows update individually, for instance a customer record that changes. It is the wrong shape for a daily aggregate, because a row that disappears from the source is never deleted by a merge: it simply keeps its old value forever, and nobody notices until someone reconciles the totals.
Data that keeps arriving#
Most sources are not finished with a day when the day ends. Payments settle, a partner exports late, a mobile client syncs when it next has signal. If the job only ever runs yesterday, those records land in a partition that was already published and are never counted.
def daily(today: dt.date, lookback: int = 7):
"""Reprocess a trailing window, not one day. Safe only because run() replaces."""
for offset in range(1, lookback + 1):
run(today - dt.timedelta(days=offset))This is the concrete payoff of idempotency. Without it, reprocessing a week every night would multiply a week of data by seven; with it, the trailing window is free of consequences and the only question is compute. It is also why the cost of the property is worth paying up front, before you know you need the window.
Backfills#
def backfill(start: dt.date, end: dt.date):
day = start
while day <= end:
run(day)
day += dt.timedelta(days=1)That loop is correct and will not survive contact with two years of history. Add three things before you start one: a ledger of days already finished so an interruption resumes, a concurrency cap so the source database is not saturated, and a number for what it will cost, obtained by timing one day and multiplying. The last is the one people skip, and the invoice arrives a month later attached to nobody's name.
The table is live while you rewrite it
For as long as a backfill runs, the output contains some days computed the old way and some the new way. Any report over that range is a mixture and its number is meaningless. Either tell the people reading it and pick a quiet window, or backfill into a separate table and swap at the end, which costs storage and removes the problem entirely.
Scheduling#
An orchestrator such as Airflow, Dagster or Prefect runs run(yesterday) every morning, retries on failure, runs backfills in parallel, and shows which days succeeded. Cron works for one job; an orchestrator earns its keep when jobs depend on each other.
Two settings decide whether it wakes you at three in the morning. Retries should be bounded and spaced, because the usual cause of a failure is an upstream system that is down and will not be up in ten seconds. And a job needs an alert on not having run at all, not only on having failed: a scheduler that stopped firing produces no failures, no red squares, and a table that quietly stops growing.
Where people go wrong
Appending each run's output to a table. The first retry duplicates data, and nobody notices until the numbers are questioned.
Build this: Build this pipeline over a week of sample JSON files. Kill it mid-run, rerun, and prove the output is identical. Change the transform, backfill the week, and confirm only the intended numbers changed.
Open this section on its own pagePipelines 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
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 FalseReplacing 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,
)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 reviewWhen 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"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 rollbackCost 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.
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.
Open this section on its own pageWhere you use this
A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.
- Stage 1, Chunks you can point at: Re-ingesting when a document changes, without renumbering everything downstream.
Now actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Build one that survives a rerun
10hProcess a date range into partitioned output, replacing rather than appending. Run it twice on the same date and diff the output. Then backfill a month.
Done when: Two runs on the same date produce byte-identical output, and the backfill used the same code path.
2Catch a broken upstream
10hAdd quality checks including a volume check against expectation. Then feed it a truncated export that is structurally valid but nearly empty, and confirm the pipeline stops rather than writing it.
Done when: The valid-but-tiny batch failed the pipeline instead of silently corrupting downstream data.
20 hours of practice
Interview questions
7 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
Moving data reliably
How do you make a data pipeline safe to rerun?
Idempotency: write to a partition keyed by the period being processed and replace it rather than appending, so a rerun produces the same result. Parameterise by date range so backfills use the same code path.
How do you handle an upstream schema change?
Validate incoming data against an expected schema and fail loudly rather than writing whatever arrived. Adding nullable columns is safe; removals and type changes need a versioned migration with both supported during the transition.
What quality checks would you put in a pipeline?
Row count against expectation, uniqueness of keys, null checks on required fields, range checks on numerics, and a volume check. The volume check is what catches an upstream failure that still produces structurally valid but nearly empty data.
Orchestration, streaming and lineage
When would you choose streaming over batch?
Only when freshness changes a decision, such as fraud detection or alerting. Streaming is much harder to operate, so the test is whether anyone would act differently if the data were an hour old.
How do you handle late-arriving data?
Decide explicitly how late an event may be and still be included, using a watermark, and define what happens to events after that. Assuming ordered arrival produces subtly wrong aggregates.
A daily pipeline you can rerun safely
A daily job failed halfway and was restarted. Now some rows appear twice. How do you prevent this?
Make each run idempotent: process a well-defined partition such as one day, write to a temporary location, validate, then replace the whole partition atomically. Reruns then overwrite rather than append, and a failed run publishes nothing.
Then they push further
The target is a database table, not files. Same idea?
Yes: delete and insert the day's rows inside one transaction, or use a merge on a natural key. Either way a rerun leaves the same final state.
Data for a day can keep arriving for a week. How do you handle that?
Reprocess a trailing window, for example the last seven days every run. Idempotent partition replacement makes that safe.
What gets marked up. Marked up for idempotency via partition replacement, atomic publishing, and handling late data by reprocessing a window.
Pipelines for AI: keeping a RAG index in sync
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.
Resources
Free to watch and read, picked for this topic.
For moving data reliably
For orchestration, streaming and lineage
For a daily pipeline you can rerun safely
For pipelines for AI: keeping a RAG index in sync
Full courses: the whole topic, start to finish