RAG
RAG-storing: negen uur verouderde Qdrant op een live agent
Op een dinsdagochtend begon een klantenservice-RAG-agent voor een Nederlandse verzekeraar polisartikelen te citeren die drie weken eerder waren ingetrokken. Negen uur later wisten we waarom.

Op een dinsdagochtend in februari begon een klantenservice-RAG-agent die wij voor een Nederlandse verzekeraar draaien polisartikelen te citeren die drie weken eerder waren ingetrokken. De agent was stellig. De artikelen waren dood. De negen uur daarna kreeg elke beller die naar opzegtermijnen vroeg een antwoord dat de verzekeraar een kleine claimzaak had kunnen kosten.
De architectuur was er een die de meeste teams in week drie opleveren. Postgres als bron van waarheid voor polissen, FAQ-items en beslistabellen. Een zelf-gehoste Qdrant-cluster als vector-index over chunks van die documenten. Een ingestion-job die elke nacht om 02:00 draaide om alles wat was gewijzigd opnieuw te embedden. Op de meeste dagen was die nachtelijke kloof onzichtbaar. Op deze dinsdag niet.
De fout die we hebben uitgerold
De trigger was een routinematige poliswijziging. Ops trok maandagmiddag drie artikelen uit de opzegflow. Ze drukten op opslaan in het CMS, de rij landde in Postgres en de relevante polis ging direct live voor menselijke medewerkers. De RAG-kant bewoog niet.
Dat op zich was een probleem dat we hadden gedocumenteerd en geaccepteerd. Een worst-case venster van 24 uur verouderdheid tussen een write in Postgres en een bijbehorende update in Qdrant. Operations was geïnformeerd. Het risico stond in de runbook.
De echte fout zat dieper. Toen de artikelen van maandag werden verwijderd, bleven de bijbehorende Qdrant-points gewoon in de collection staan. De nachtelijke job re-embeddet alles wat in Postgres aanwezig was. Hij vroeg nooit welke points in Qdrant geen overeenkomende Postgres-rij hadden. Er was geen tombstone-pad.
Dus haalde de agent dinsdagochtend chunks op waarvan de payloads nog de oude polistekst bevatten. De retriever joinde terug naar Postgres op entity id, vond niets, en de applicatiecode behandelde 'geen join' als 'geen extra metadata, ship de chunk'. Het payload-veld van de chunk ging direct het contextvenster in. Het model antwoordde uit de dode tekst, met volle overtuiging.
Er waren negen uur, twee klachten van klanten en een handmatige audit door een compliance-officer nodig om het patroon te zien. De fix die ochtend was lomp. We truncated de Qdrant-collection en draaiden de volledige embedding-job opnieuw vanuit Postgres. Om 18:00 was de agent schoon. Om 19:00 schreven we de post-mortem.
De post-mortem had één regel die ertoe deed. Er was geen fence. We hadden een job, geen garantie.
Waarom dual writes een eufemisme voor liegen zijn
De naïeve fix waar je na zo'n incident naar grijpt is dual write. Als applicatiecode Postgres update, schrijf dan in dezelfde handler ook naar Qdrant.
def update_policy(policy_id, new_body):
db.execute(
"UPDATE policies SET body = %s WHERE id = %s",
(new_body, policy_id),
)
chunks = chunk_and_embed(new_body)
qdrant.upsert(collection_name="policies", points=chunks)
Dit ziet er atomair uit. Dat is het niet. De twee calls gaan naar twee verschillende systemen over twee verschillende netwerkpaden. Beide kunnen onafhankelijk falen. Het proces kan ertussen crashen. Er is geen transactie die beide omvat. De kans op inconsistentie is niet nul, hij stapelt zich op met elke write.
Wat wij hadden was erger, want wij hadden een nachtelijke batch in plaats van een dual write. Maar dual write had ons niet gered. Dual write ruilt een venster van 24 uur verouderdheid in voor een kleiner, sluwer inconsistentie-venster. De bug-klasse is identiek. Twee onafhankelijke state machines waarvan de applicatie doet alsof het er één is.
Het patroon dat wel werkt is de transactional outbox. Het is ouder dan RAG, ouder dan vector stores. Microsoft documenteert het als het antwoord op 'betrouwbaar een bericht publiceren wanneer je een database-transactie commit'. Vervang 'indexeren in Qdrant' voor 'een bericht publiceren' en de vorm is dezelfde.
Het idee is simpel. Elke statuswijziging schrijft twee rijen in dezelfde Postgres-transactie. De ene rij is de werkelijke data-update. De andere is een event in een outbox-tabel. Een worker-proces leest de outbox, projecteert elk event in het downstream-systeem, en markeert het als verwerkt. Crasht de worker halverwege een batch, dan herhaalt hij. Weigert de downstream een write, dan probeert hij opnieuw. Wordt de bronrij verwijderd, dan draagt de outbox een delete-event en verwijdert de worker de bijbehorende points. Postgres is de enige plek waar writes worden onderhandeld. Al het andere is een projectie.
Het outbox-fence
Voor de verzekeraar kwamen we uit op één extra tabel, één version-kolom op elke geïndexeerde brontabel, en één worker. De outbox zelf is weinig opzienbarend.
CREATE TABLE rag_outbox (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
entity_kind TEXT NOT NULL,
entity_id UUID NOT NULL,
op TEXT NOT NULL CHECK (op IN ('upsert','delete')),
source_version BIGINT NOT NULL,
payload JSONB
);
CREATE INDEX rag_outbox_unprocessed
ON rag_outbox (id)
WHERE processed_at IS NULL;
De partiële index houdt de working set klein, ook als de tabel doorgroeit tot miljoenen rijen. We truncaten hem nooit. Het audit-spoor is te nuttig als zes weken later een klacht binnenkomt met de vraag waarom de agent zei wat hij zei.
De applicatie-write wordt één transactie.
def update_policy(policy_id, new_body):
with db.transaction():
row = db.fetch_one(
"UPDATE policies SET body = %s, version = version + 1 "
"WHERE id = %s RETURNING version",
(new_body, policy_id),
)
db.execute(
"INSERT INTO rag_outbox "
"(entity_kind, entity_id, op, source_version, payload) "
"VALUES (%s, %s, %s, %s, %s)",
("policy", policy_id, "upsert", row["version"],
json.dumps({"body": new_body})),
)
Nu committen de Postgres-update en het outbox-event samen, of geen van beide. Ze kunnen niet meer uit elkaar lopen. De version-kolom op de brontabel telt op bij elke wijziging en wordt naar het event gekopieerd. Die versie is het fence.
De worker drain de outbox in batches, met het standaard FOR UPDATE SKIP LOCKED-patroon zodat meerdere workers parallel kunnen draaien zonder elkaar in de weg te zitten.
def drain_outbox(batch_size=100):
rows = db.fetch_all(
"SELECT * FROM rag_outbox "
"WHERE processed_at IS NULL "
"ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED",
(batch_size,),
)
for row in rows:
if row["op"] == "delete":
qdrant.delete(
collection_name="policies",
points_selector=Filter(must=[
FieldCondition(
key="entity_id",
match=MatchValue(value=str(row["entity_id"])),
)
]),
)
else:
chunks = chunk_and_embed(row["payload"]["body"])
qdrant.upsert(
collection_name="policies",
points=[
PointStruct(
id=str(uuid4()),
vector=ch.vector,
payload={
"entity_id": str(row["entity_id"]),
"source_version": row["source_version"],
"text": ch.text,
},
) for ch in chunks
],
)
db.execute(
"UPDATE rag_outbox SET processed_at = now() WHERE id = %s",
(row["id"],),
)
Het entity_id-filter op de Qdrant-delete is belangrijk. Eén bronrij kan veel chunks opleveren, elk een eigen point. Verwijderen op source id in plaats van op point id zorgt dat je nooit chunks lekt als een rij wordt verwijderd. De Qdrant points-API ondersteunt filter-based deletes native, en dat is de kleine reden dat we Qdrant hebben aangehouden in plaats van iets eigens te bouwen.
De latency op het write-pad ging een paar milliseconden omhoog voor de extra insert. We merkten het in productie nooit. Het read-pad van de agent is waar gebruikers tijd voelen, en dat pad bleef onaangeroerd.
Versiecheck op het moment van retrieval
De outbox is noodzakelijk, maar niet voldoende. Hij garandeert dat elke Postgres-write uiteindelijk een downstream-effect oplevert. Hij zegt niets over het gat tussen 'uiteindelijk' en 'nu'. Voor een klantgerichte agent die in real-time polisvragen beantwoordt is dat gat van belang.
Het tweede fence draait dus op het moment van retrieval. Elk point in Qdrant draagt de source_version waarmee het is geïndexeerd. De retriever haalt zijn top-k op uit Qdrant, joint dan terug naar Postgres op entity_id, en vergelijkt de versies. Heeft Postgres een nieuwere versie dan de chunk claimt, dan is de chunk verouderd en valt hij af. Heeft Postgres helemaal geen rij, dan is de chunk een spook en valt hij af.
def retrieve(query, k=8):
hits = qdrant.search(
collection_name="policies",
query_vector=embed(query),
limit=k * 2,
)
entity_ids = [h.payload["entity_id"] for h in hits]
rows = db.fetch_all(
"SELECT id, version FROM policies WHERE id = ANY(%s)",
(entity_ids,),
)
current = {str(r["id"]): r["version"] for r in rows}
fresh = [
h for h in hits
if current.get(h.payload["entity_id"]) == h.payload["source_version"]
]
return fresh[:k]
Op de hardware van de verzekeraar voegt dit één Postgres-round-trip per query toe, ongeveer 4 ms over k=16. De mediane end-to-end latency van de agent wordt gedomineerd door de model-call, niet door de retrieval. We hebben voor en na gemeten. De p50 bewoog niet.
Wat veranderde was de garantie. Retrieval was 'vertrouw op de nachtelijke job'. Nu is het 'geverifieerd bij elke call'. Een chunk in Qdrant kan niet liegen tegen de prompt zonder eerst tegen Postgres te liegen, en Postgres liegt niet.
Reconciliatie als derde fence
De outbox dekt writes. De versiecheck dekt reads. Het derde fence dekt de gevallen die geen van beide voorzien. Een buggy chunker die stilletjes content laat vallen. Een handmatige reparatie die de applicatie omzeilt. Een Qdrant-collection die tijdens een hardware-swap wordt teruggezet vanuit een oude snapshot.
De reconciler is een script van 50 regels dat ieder uur draait. Hij pakt 1000 willekeurige Postgres-rijen, haalt de bijbehorende Qdrant-points op met een entity_id-filter en checkt drie dingen. Elke Postgres-rij heeft minstens één point. Elk point heeft een bijbehorende Postgres-rij. De source_version van elk point komt overeen met de Postgres-versie. Elke mismatch gaat naar een Slack-kanaal met de entity id en welke check faalde.
In de eerste week dat de reconciler draaide, vingen we twee bugs op waar we niet naar zochten. Beide zaten in onze chunking-stap, waar een specifiek markdown-tabelformaat nul output produceerde. Geen van beide zou in tests opgevallen zijn, want tests gaan uit van de input. Reconciliatie niet. Die kijkt naar wat er staat en vraagt of het er hoort te staan.
Als je vector store-write niet binnen dezelfde database-transactie zit als je write naar de bron van waarheid, heb je geen eventual consistency. Je hebt eventual divergence, en de agent zal het niet weten.
Wat er in ons standaardtemplate veranderde
Toen we de RAG-agent van de verzekeraar herbouwden op het nieuwe fence, was de verrassing hoeveel simpeler de prompt werd. Zodra retrieval te vertrouwen was, schreven we geen defensieve taal meer in de system message, zoals 'als de polis recent is gewijzigd, geef voorkeur aan de meest recente versie'. Het fence deed het werk dat de prompt probeerde te doen. De toon van de agent ging vooruit op een interne benchmark, maar de echte winst was operationeel. Niemand werd om 02:00 nog gepaged, en de compliance-officer las geen transcripts meer op zondag.
Het kleinste wat je vandaag kunt doen: open je RAG-repo, vind de plek waar je naar je vector store schrijft, en check of die call binnen dezelfde database-transactie zit als de write naar de bron van waarheid. Zo niet, dan ben je één crash verwijderd van dezelfde bug die wij hebben uitgerold. De fix is twee tabellen en een worker.
Kern
Als je vector store-write niet in dezelfde transactie zit als je write naar de bron van waarheid, ben je één crash verwijderd van het serveren van verouderde antwoorden met overtuiging.
FAQ
Waarom niet gewoon pgvector gebruiken en Qdrant helemaal overslaan?
Single-store setups omzeilen deze klasse bugs van nature. We hielden Qdrant aan vanwege filter-performance op een collection van 40M points, maar pgvector is de juiste default voor alles onder een paar miljoen chunks.
Werkt de versiecheck op het moment van retrieval ook met hybrid search?
Ja. De versievergelijking draait op de samengevoegde resultaatset nadat BM25- en vector-scores zijn gecombineerd. De check staat los van hoe de kandidaten zijn opgehaald.
Hoe vaak moet de reconciliatie-worker draaien?
Eenmaal per uur is genoeg voor de meeste agents. Indexeer je high-stakes data zoals medische of juridische content, dan vangt elke 10 minuten met een kleinere sample drift sneller op zonder beide stores te belasten.
Wat als de outbox-worker stevig achterop raakt?
Alert op outbox-lag, niet alleen op errors. Wij pagen wanneer onverwerkte events ouder dan 60 seconden meer dan 500 rijen tellen. De versiecheck bij retrieval houdt verouderde chunks ondertussen sowieso uit de prompt terwijl de worker bijtrekt.