1. The Use Case: "PolicySync" - Feeding CompliAgent
In the previous article we built CompliAgent, an enterprise multi-agent RAG system. But RAG is only as good as its corpus. In a Fortune 500, the corpus is a moving target:
25,000 policy documents across SharePoint, Confluence, email archives, and legal databases.
~400 documents change daily (new policies, amendments, revocations).
Chunking logic gets upgraded (e.g., moving from fixed-size to semantic chunking).
Embedding models get swapped (e.g., text-embedding-3-small → a domain-fine-tuned model).
Regulators occasionally demand "re-process everything from 2023-01-01 with the new retention tags."
A naive "run the ETL every night" approach fails on three counts:
It is not idempotent. Running it twice doubles the vectors.
It cannot recover. A failure at chunk #14,302 means restarting from scratch or writing brittle resume logic.
It cannot backfill intelligently. Re-embedding 25,000 docs because one chunker changed costs $900 in API fees and takes 6 hours.
We need an orchestrated, idempotent, backfillable ETL — and we need it to talk to the RAG agents so the system can heal itself.
Enter Apache Airflow + the CompliAgent Auditor agent.
2. Architecture
The critical insight: the RAG system is not just a consumer of the ETL - it is a feedback loop. When the Auditor agent detects a stale or missing citation, it can trigger a targeted Airflow backfill for just those documents.
3. The Three Pillars of Production ETL
3.1 Idempotency
A task is idempotent if running it N times produces the same result as running it once. In our pipeline:
| Stage | Idempotency Mechanism |
|---|---|
| Extract | Watermark-based: only pull docs with last_modified > last_run_watermark |
| Transform | Deterministic: same input → same chunk IDs (content-hash-derived) |
| Embed | Conditional: skip if embedding already exists for this content hash |
| Load | INSERT ... ON CONFLICT (doc_id, version) DO UPDATE — never blind INSERT |
The golden rule: every task must be safely re-runnable. Airflow will retry failed tasks; your code must welcome that.
3.2 Failure Handling
| Failure Type | Response |
|---|---|
| Transient (network blip, 429 from OpenAI) | Exponential backoff retries inside the task |
| Deterministic (bad JSON, schema mismatch) | Route to Dead Letter Queue (DLQ), alert, continue |
| Catastrophic (DB down, S3 outage) | Fail the DAG, page on-call, do not retry |
| Silent data quality (empty chunk, hallucinated metadata) | Data quality assertions via Great Expectations or custom checks |
3.3 Backfills
Airflow's logical_date (formerly execution_date) is the key. Each DAG run is parameterized by a date, and tasks read/write data partitioned by that date. This lets us:
Full backfill:
airflow dags backfill policy_sync --start-date 2023-01-01 --end-date 2026-07-14
Targeted backfill:
trigger a run with conf={"doc_ids": ["pol_123", "pol_456"]}
Partial rerun:
clear specific task instances and let the scheduler re-execute them
4. The Airflow DAG - Full Implementation
# dags/policy_sync.py
from datetime import datetime, timedelta
from airflow import DAG
from airflow.decorators import task
from airflow.models import Variable
from airflow.providers.postgres.hooks.postgres import PostgresHook
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from airflow.providers.openai.hooks.openai import OpenAIHook
from airflow.exceptions import AirflowSkipException, AirflowException
from airflow.utils.state import State
import hashlib, json
default_args = {
"owner": "data-eng",
"depends_on_past": False,
"retries": 3,
"retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True,
"max_retry_delay": timedelta(minutes=30),
"execution_timeout": timedelta(minutes=60),
"on_failure_callback": _notify_slack, # defined below
"sla": timedelta(hours=2),
}
dag = DAG(
dag_id="policy_sync",
default_args=default_args,
start_date=datetime(2023, 1, 1),
schedule="0 2 * * *", # daily at 02:00 UTC
catchup=False,
max_active_runs=1, # serialize runs to avoid watermark races
tags=["rag", "compliagent", "etl"],
params={
"doc_ids": [], # empty = full incremental; populated = targeted
"force_reembed": False,
},
)
4.1 Extract - Watermark-Based, Idempotent
@task(dag=dag)
def extract_policies(logical_date: datetime, **ctx) -> list[str]:
"""Pull only docs modified since the last successful run."""
params = ctx["params"]
pg = PostgresHook(postgres_conn_id="metadata_db")
# Targeted backfill: ignore watermark, use explicit doc_ids
if params.get("doc_ids"):
placeholders = ",".join(["%s"] * len(params["doc_ids"]))
rows = pg.get_records(
f"SELECT doc_id, source, uri FROM source_registry "
f"WHERE doc_id IN ({placeholders})",
params["doc_ids"],
)
else:
# Incremental: watermark = max(watermark) from last successful run
last_watermark = Variable.get(
"policy_sync_last_watermark",
default_var="1970-01-01T00:00:00Z",
)
rows = pg.get_records(
"SELECT doc_id, source, uri FROM source_registry "
"WHERE last_modified > %s",
(last_watermark,),
)
if not rows:
raise AirflowSkipException("No new or targeted documents.")
s3 = S3Hook(aws_conn_id="aws_default")
keys = []
for doc_id, source, uri in rows:
raw = fetch_from_source(source, uri) # SharePoint / Confluence / etc.
key = f"raw/policies/{logical_date:%Y-%m-%d}/{doc_id}.json"
s3.load_string(
json.dumps({"doc_id": doc_id, "content": raw, "fetched_at": datetime.utcnow().isoformat()}),
key=key,
replace=True, # idempotent: overwrite if exists
)
keys.append(key)
# Update watermark ONLY if this was an incremental run
if not params.get("doc_ids"):
new_watermark = pg.get_first(
"SELECT MAX(last_modified) FROM source_registry"
)[0]
Variable.set("policy_sync_last_watermark", new_watermark)
return keys
4.2 Transform - Deterministic Chunking with Content Hashes
@task(dag=dag)
def chunk_documents(raw_keys: list[str], logical_date: datetime) -> list[str]:
"""Chunk documents deterministically. Chunk ID = hash(doc_id + chunk_index + content)."""
s3 = S3Hook(aws_conn_id="aws_default")
out_keys = []
for key in raw_keys:
raw = json.loads(s3.read_key(key))
doc_id = raw["doc_id"]
chunks = semantic_chunk(raw["content"]) # your chunking library
chunk_records = []
for i, chunk in enumerate(chunks):
content_hash = hashlib.sha256(chunk.encode()).hexdigest()[:16]
chunk_id = f"{doc_id}#{i}#{content_hash}" # deterministic!
chunk_records.append({
"chunk_id": chunk_id,
"doc_id": doc_id,
"index": i,
"content": chunk,
"content_hash": content_hash,
"logical_date": logical_date.isoformat(),
})
out_key = f"processed/policies/{logical_date:%Y-%m-%d}/{doc_id}.jsonl"
s3.load_string(
"\n".join(json.dumps(r) for r in chunk_records),
key=out_key,
replace=True,
)
out_keys.append(out_key)
return out_keys
4.3 Embed - Conditional, Cost-Aware
@task(dag=dag)
def embed_chunks(processed_keys: list[str], logical_date: datetime, **ctx) -> list[str]:
"""Embed chunks, but skip if the content hash already has an embedding."""
force = ctx["params"].get("force_reembed", False)
pg = PostgresHook(postgres_conn_id="metadata_db")
s3 = S3Hook(aws_conn_id="aws_default")
# Build set of already-embedded content hashes
existing = {
row[0] for row in pg.get_records(
"SELECT content_hash FROM embeddings_registry"
)
}
embedded_keys = []
for key in processed_keys:
records = [json.loads(line) for line in s3.read_key(key).splitlines()]
to_embed = [r for r in records if force or r["content_hash"] not in existing]
if not to_embed:
continue
# Batch embed — OpenAI limit is 2048 inputs per call
texts = [r["content"] for r in to_embed]
vectors = batch_embed(texts) # wraps OpenAI with retry + rate-limit
for record, vec in zip(to_embed, vectors):
record["embedding"] = vec
out_key = f"embedded/policies/{logical_date:%Y-%m-%d}/{key.split('/')[-1]}"
s3.load_string(
"\n".join(json.dumps(r) for r in records),
key=out_key,
replace=True,
)
embedded_keys.append(out_key)
return embedded_keys

Join the conversation! Your thoughts help the community grow.