← Blog

Email automation

Email-agent incident: 312 mails uit verouderde RAG-index

Op dinsdagochtend stuurde een tussenpersoon in Den Bosch 312 polisaanvragen het verkeerde dekkingsbedrag. Oorzaak: een Pinecone-namespace die stilletjes verouderde tijdens een reindex.

Jacob Molkenboer· Oprichter · A Brand New Company· 18 jun 2026· 9 min
Crèmekleurige envelop met groen lint, carbonpapier-strookjes, koperen briefopener op donkergroene leren onderlegger.

Het eerste telefoontje kwam om 09:47 binnen. Een polishouder in Tilburg, rustig woedend, wilde weten waarom zijn aansprakelijkheidsdekking nu €250.000 was in plaats van de €1.000.000 die hij vorige maand had afgesloten. De accountmanager pakte de mailthread erbij. Daar stond het, in het vriendelijke Nederlands van de agent zelf: "Wij bevestigen uw dekkingslimiet aansprakelijkheid van EUR 250.000." Zelfverzekerd. Specifiek. Fout.

Om 10:15 drie telefoontjes erbij. Om 10:40 had de gedeelde new-business inbox 27 reply-all "huh?"-threads van klanten en tussenpersonen. We haalden de agent om 10:52 van de SMTP-relay. Tegen die tijd had hij er 312 verstuurd.

De agent draaide al veertien maanden zonder noemenswaardig incident. Het team vertrouwde hem op het standaard antwoordpad: binnenkomende polisaanvraag, de bijbehorende voorwaarden ophalen, een bevestiging opstellen, versturen via de relay met een from-header op de juiste accountmanager. Het team vertrouwde hem genoeg om voor bevestigingen onder €5.000 jaarpremie de "naar review queue"-toggle over te slaan.

Dit is het incident report. De oplossing aan het eind is het bewaren waard.

De versie in vijf minuten

Maandagavond herbouwde ons content-team de Pinecone-index met de productvoorwaarden van de tussenpersoon — zo'n 4.800 chunks over 11 productlijnen. Reindexen is een kwartaalklus. Het script schrijft een nieuwe namespace, draait een smoke query, swap de alias en verwijdert daarna de oude namespace na een grace period van 24 uur.

De smoke query slaagde. De alias-swap meldde succes. De 24 uur grace verstreek. De oude namespace werd op de eerstvolgende nachtelijke cron verwijderd.

Alleen wees de alias-swap nooit echt naar de nieuwe namespace. De Pinecone-SDK-call gaf een 200 terug, de wrapper erom logde swap_ok=true, en de alias op disk wees nog steeds naar de namespace die inmiddels was verwijderd. Iets dat Pinecone vrolijk oplost — totdat het dat niet meer doet — door terug te vallen op de meest recente namespace met dat prefix. In het project van deze klant was dat voorwaarden-2024Q3 — voorwaarden van vóór de productupdate van september 2025, waarbij drie standaard dekkingslimieten met een factor vier omlaag gingen.

De agent haalde oude chunks op. De chunks zagen er gezaghebbend uit. Het model schreef plichtsgetrouw een bevestiging met het oude bedrag erin. De relay verstuurde het. 312 keer, voordat iemand de discrepantie opmerkte in een menselijke reactie.

Een index die antwoorden teruggeeft is niet hetzelfde als een index die actuele antwoorden teruggeeft. Een vector store kan tegelijk verouderd en levendig zijn. Niks in de retrieval-call vertelt je dat.

Hoe de bestaande guardrails dit misten

De agent stond niet onbewaakt. Voordat we dinsdagochtend binnenliepen, had de pipeline:

  • een Pydantic-schemacheck op de gestructureerde output van het model, zodat de vorm van een bevestiging niet kon hallucineren;
  • een regex-pass die weigerde een mail te versturen met een euro-bedrag dat niet letterlijk in de opgehaalde context voorkwam;
  • een verzendlimiet per accountmanager (twaalf mails per minuut) die een doorgeslagen loop had opgevangen;
  • een "naar review queue"-toggle voor premies boven €5.000.

Geen van allen sloeg aan. Het euro-bedrag in de output stond inderdaad in de opgehaalde context — dat was juist het probleem. De context was fout, maar intern consistent met zichzelf en met de draft. Het Pydantic-schema was tevreden. De verzendlimiet was nergens in de buurt. De premies op de getroffen polisaanvragen lagen allemaal tussen €240 en €1.800 per jaar.

De les, scherper dan ons lief is: de output van het model toetsen aan zijn eigen opgehaalde context vangt geen retrieval op die stilletjes uit de verkeerde bron komt. De OWASP LLM Top 10 schaart dit type fout onder vector and embedding weaknesses, wat de nette manier is om te zeggen dat je een check op de bron zelf nodig hebt.

Hoe de swap er echt uitzag

De volgende ochtend liepen we samen met hun support team de Pinecone control-plane logs door. De relevante sequence, licht versimpeld:

# Monday 23:14 — reindex job on the deploy server
pinecone-cli upsert \
  --index voorwaarden-prod \
  --namespace voorwaarden-2026Q2-rc1 \
  --file ./chunks-2026Q2.jsonl

# 23:41 — smoke query against the new namespace, BY NAME
pinecone-cli query --namespace voorwaarden-2026Q2-rc1 \
  --vector @./smoke.vec --top-k 3
# returns three chunks from the new namespace. OK.

# 23:42 — alias swap (wrapper around metadata update)
./swap-alias.sh voorwaarden-current voorwaarden-2026Q2-rc1
# logs: swap_ok=true

# Tuesday 03:00 — nightly GC, deletes namespaces older than 24h with no alias
pinecone-cli delete --namespace voorwaarden-2026Q1
# (the still-aliased namespace, because the alias never moved)

Het swap-alias.sh script zat al minstens twee kwartalen op een kleine manier verkeerd. Het updatete een metadata-veld dat Pinecone ook exposeert als alias, maar dat niet de routing-alias is. De routing-alias zet je via een ander endpoint. De swap_ok=true van het script was gebaseerd op de HTTP 200 van de metadata-update, en die had niks te maken met waar queries daadwerkelijk heen gingen.

De vorige twee reindexen werkten desondanks omdat het script de nieuwe namespace ook hernoemde naar de canonieke naam voorwaarden-current als fallback, en queries op die naam goed resolveerden. Dit kwartaal heeft iemand (een van ons, eerlijk gezegd) het script opgeruimd en de rename-stap weggehaald omdat "de alias dat nu doet". De alias deed dat, eerlijk is eerlijk, niet.

De dual-index swap-gate

Je kunt dit op drie lagen oplossen. Wij doen het inmiddels op alle drie, omdat elke laag op zichzelf het incident zou hebben opgevangen, en we zijn nog niet klaar met ons schamen.

Laag 1: fingerprint de index, stempel de retrieval

Elke keer dat we een nieuwe namespace bouwen, berekenen we een SHA-256 over de gesorteerde chunk-IDs plus hun content-hashes en schrijven die naar een kleine Postgres-tabel. De vector-store-wrapper stempelt die fingerprint op elk retrieval-resultaat. De SMTP-relay weigert elke mail waarvan de retrieval-fingerprint niet in een kleine allowlist staat die door de deploy pipeline wordt bijgewerkt.

# rag/retrieve.py
def retrieve(query: str, k: int = 6) -> RetrievalResult:
    namespace = resolve_alias("voorwaarden-current")
    fingerprint = index_fingerprints.get(namespace)  # Postgres lookup
    chunks = pinecone.query(namespace=namespace, vector=embed(query), top_k=k)
    return RetrievalResult(
        chunks=chunks,
        namespace=namespace,
        fingerprint=fingerprint,
        retrieved_at=utcnow(),
    )

# relay/gate.py
ALLOWED_FINGERPRINTS = load_from_deploy_manifest()

def allow_send(mail: OutboundMail) -> bool:
    if mail.retrieval.fingerprint not in ALLOWED_FINGERPRINTS:
        hold(mail, reason="unknown_index_fingerprint")
        page_oncall()
        return False
    return True

Had dit maandagavond al gedraaid, dan had de relay alle 312 mails vastgehouden op het moment dat de agent uit de verouderde namespace ophaalde. De fingerprint van die namespace stond namelijk niet op de allowlist die de deploy pipeline had weggeschreven.

Laag 2: dual-index disagreement check

De eerste 72 uur na elke reindex laten we de vorige namespace in leven en draaien elke productiequery zowel tegen de nieuwe als de oude index. We berekenen een Jaccard-similarity over de top-6 chunk-IDs en een cosine-similarity over de chunk-content embeddings.

Zakt een van beide onder een per-product threshold (we tunen die uit de smoke set, meestal ~0,6 Jaccard voor stabiele producten en ~0,4 voor producten die echt zijn aangepast), dan gaat de draft van de agent naar de menselijke review queue met beide retrievals erbij. De reviewer ziet naast elkaar wat de oude en de nieuwe index teruggaven, en keurt het nieuwe gedrag goed of rolt terug.

Dit is duurder — twee retrievals per mail — maar alleen tijdens de 72-uurs overlap, en alleen voor retrieval-grounded outbound. Voor deze klant kost dat ons zo'n €11 per kwartaal aan Pinecone read units. Acceptabel.

Laag 3: een held-mail buffer met content-tripwire

De relay houdt elke retrieval-grounded mail 90 seconden vast voordat hij verstuurt. In die 90 seconden bekijkt een aparte consumer de held queue. Komen er binnen dat window meer dan 20 mails voorbij die dezelfde opgehaalde chunk citeren, dan pauzeert de queue en gaat de pager af. Twintig is voor deze klant ruwweg twee keer de normale piek van een maandagochtend new-business batch. Voor jou ligt het anders. Het punt is dat de tripwire kijkt naar wat er in de mail staat, niet naar hoeveel mails.

Laag 3 had dit incident gevangen op het moment dat dezelfde foute dekkingslimiet in de 21e outbound draft opdook. We hadden alsnog twintig foute mails verstuurd, wat 292 minder is dan 312 en ruim onder de drempel waarbij je klanten persoonlijk gaat bellen.

Onthou dit

Als je RAG-pipeline alleen de output van het model kan toetsen aan zijn eigen opgehaalde context, controleer je dat de agent intern consistent is — niet dat hij gelijk heeft. Voeg een check op de bron zelf toe.

Wat het de klant kostte

Drie hele dagen voor de operations lead en een van de accountmanagers — 312 klanten en tussenpersonen bellen, correcties versturen en twee polissen opnieuw uitgeven die al op de verkeerde voorwaarden waren geboekt. Geen escalatie richting toezichthouder — de AFM-relevante data (het IPID, het polisdocument zelf) klopte in de bijlage, en de body-tekst die met de bijlage in tegenspraak was, gold juridisch als een administratieve fout.

Vertrouwensschade is lastiger te tellen. Twee tussenpersonen uit het IB-netwerk waar de klant mee werkt vroegen beleefd wat er veranderd was. Wij stuurden ze een eerlijke one-pager. Ze bleven.

Wat we niet meer zouden doen

We zouden geen reindex meer maandagavond draaien vlak voor een drukke dinsdag. We reindexen nu vrijdagochtend, met een mens die het eerste uur overlap-traffic in de gaten houdt.

We zouden de ok=true van een wrapper-script niet meer vertrouwen voor een operatie die we niet onafhankelijk kunnen verifiëren met een andere call. Het swap-alias.sh script eindigt nu met een query via de alias op een canary-chunk die alleen in de nieuwe namespace bestaat, en valt luid om als de ID van de teruggekomen chunk niet die is die we net hebben geüpsert.

We zouden de review queue niet meer overslaan voor retrieval-grounded mail in de eerste 72 uur na een reindex, ongeacht de hoogte van de premie. De kosten van drie dagen extra reviewer-klikken zijn klein. De kosten van 312 zelfverzekerde, specifieke, foute mails zijn dat niet.

Wat je vandaag kunt doen

Trek de laatste geslaagde retrieval-log van je agent erbij en vraag jezelf af: als de index die hij ondervroeg stilletjes voor een oude kopie werd verwisseld, wat in de huidige pipeline zou dat opmerken? Is het eerlijke antwoord "de klant die antwoordt", dan zit je met hetzelfde gat als waar wij maandagochtend mee zaten. De kleinste zinvolle eerste stap is laag 1 — fingerprint de index, stempel de retrieval, weiger alles wat onbekend is bij de send boundary. Ongeveer zestig regels code en één Postgres-tabel.

Toen we de email-agent voor deze tussenpersoon in Den Bosch herbouwden, was het detail dat we hier wilden noemen dat het Pinecone metadata-update endpoint een 200 teruggeeft voor een no-op. Dat betekent dat het verkeerde script jarenlang overtuigend kan liegen. We hebben het opgelost door elke retrieval-grounded outbound standaard als verdacht te behandelen en versheid te bewijzen bij de relay, niet bij de retriever.

Kern

Toetst je RAG-pipeline alleen de output aan de eigen opgehaalde context, dan controleer je of de agent intern consistent is, niet of hij gelijk heeft.

FAQ

Waarom slaagde de smoke query als de alias kapot was?

De smoke query draaide rechtstreeks op de nieuwe namespace bij naam, niet via de alias. Het bewees dat de upsert had gewerkt. Het bewees niet dat productie-traffic ook echt bij de nieuwe namespace uit zou komen.

Is dit een Pinecone-specifieke fout?

Nee. Elke vector store met een alias heeft dezelfde failure mode. De dual-index swap-gate werkt net zo goed tegen pgvector, Weaviate, Qdrant of wat dan ook met een routing-pointer die stilletjes kan liegen over waar queries terechtkomen.

Hoe lang houden jullie de dual-index overlap draaien?

72 uur na een reindex, soms langer bij producten waar de voorwaarden vaak veranderen. De extra read units kosten een handvol euro's per kwartaal. Goedkoop vergeleken met één slechte dinsdag.

Hadden jullie de index-naam niet gewoon kunnen versioneren met de datum?

Jawel, en dat doen we inmiddels als secundair vangnet. Het alias-model is prima, maar alleen als elke swap geverifieerd wordt met een query via de alias op een canary-chunk die alleen in de nieuwe namespace bestaat.

ai agentsemail automationragcase studyknowledge baseautomation

Iets bouwen?

Start een project