← Blog

Email automation

Email-agent post-mortem: verouderde RAG-index, 340 foute mails

Om 10:47 op een dinsdag vond een douane-expediteur in Utrecht 340 zelfverzekerde antwoorden in haar inbox. Allemaal gebouwd op tariefdata van januari. Zo gebeurde het.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 jun 2026· 9 min
Crèmekleurige envelop met groen lint, messing zakhorloge en carbonpapier op een donkergroene leren onderlegger.

Om 10:47 op een dinsdagochtend opende een douanemedewerker op de tweede verdieping van een Utrechtse expediteur met 28 mensen haar Outlook-zoekvenster en typte de naam van een Poolse importeur. Ze vond zeventien antwoorden die de email-agent in de nacht had verstuurd. Elk antwoord noemde een HS-code die in de EU-tariefupdate van januari 2026 was uitgefaseerd. Nog geen enkele klant had teruggemaild. Dat was het enige goede nieuws.

Tegen de tijd dat de operations lead ons belde, stond de teller op 340. Drie uur aan zelfverzekerde automatische antwoorden, stuk voor stuk verkeerd op een kleine maar dure manier. Dit is wat er gebeurde, waarom niets afging, en de vier-stappen pre-deploy gate die we nu draaien op elke retrieval-laag die douane-data raakt.

Wat de agent deed

De expediteur draait douane-aangiftes via het AGS-portaal voor zo'n veertig importeurs per maand. Hun email-agent stond negen weken live. Wij hadden 'm gebouwd om douane-vragen te triëren, de dagelijkse vragen die importeurs stellen over classificatie, BTW-codes en oorsprongsdocumenten. Ongeveer 60% van die vragen herhaalt zich genoeg dat een RAG-gebaseerde agent ze binnen minuten in plaats van uren kan beantwoorden. De rest gaat naar het operations-team.

De retrieval-laag indexeerde twee corpora: de Europese tariefdatabase (HS-codes, douanetarieven, BTW-mappings) en de interne notities van de expediteur zelf (Incoterms-eigenaardigheden per importeur, terugkerende zendingspatronen, classificatieprecedenten). De agent stikte die twee samen en stelde een antwoord op. Een mens keurde alles boven €15.000 aangegeven waarde goed. Onder die drempel werden low-confidence antwoorden vastgehouden voor review. High-confidence antwoorden gingen direct de deur uit.

De hot-swap

Een junior developer bij de expediteur had de index twee weken eerder overgenomen. Het team vroeg hem het HS-code-corpus te verversen met de nieuwe tariefnummers en de laatste goederencode-naar-BTW-mappings. Hij deed het zorgvuldig. Hij haalde de nieuwe dataset van de gedeelde schijf, draaide de embedder, verving de FAISS-index, herstartte de worker en zag de health check op groen springen.

Hij heeft de embedding-model version string niet opgehoogd. Het model zelf was niet veranderd, alleen het corpus eronder. Vanuit het perspectief van de agent was er niets bewogen. De query-embedder, de cache-laag, de confidence-threshold, allemaal zeiden ze "nog steeds versie 4.2, alles in orde."

Wat hij miste: het bestand dat hij van de gedeelde schijf trok, was een verouderde export van augustus 2025. De douane-lead had het canonieke bestand in oktober bijgewerkt, maar nooit opnieuw geüpload naar de document store. De verse index die hij bouwde liep dus acht maanden achter op de live TARIC-data. De versietag gaf geen enkel signaal.

Waarom niets afging

Dit is de failure mode waar we ons zorgen om maken bij retrieval-lagen. Er is geen dimension mismatch als alleen de onderliggende documenten verschuiven. De retriever geeft plausibele chunks terug. De agent genereert vloeiende Nederlandse tekst. De confidence scores zien eruit zoals altijd. Geen exception in Sentry, geen PagerDuty-alert, geen Slack-melding.

De agent bleef antwoorden. Specifiek voor HS-code 8517.62.00 (routers en draadloze netwerkapparatuur) gaf hij in drie uur tijd aan negentien verschillende klanten een verouderd tarief. Het werkelijke tarief was met 1,4 procentpunt verschoven. Klein genoeg dat niemand het in real time opmerkte. Groot genoeg dat een klant bij een zending netwerkswitches van €40.000 wegliep in de overtuiging dat hij voor €560 minder kon inklaren dan in werkelijkheid kon. Vermenigvuldig dat over 340 threads en je komt uit op het bedrag van de rekening die we niet wilden schrijven.

Waarschuwing

De failure mode is stilte, geen ruis. Als de health checks van je retrieval-laag alleen afgaan op fouten, heb je geen health checks. Je hebt een rookmelder die op hetzelfde circuit zit als de brand.

De vier-stappen pre-deploy gate

We hebben de index om 11:12 teruggerold en nog dezelfde dag correcties naar elke geraakte thread gestuurd. De rollback kostte een middag. De pre-deploy gate kostte een week om te bouwen, en die draaien we nu op elke retrieval-laag die de expediteur uitrolt, ook de lagen die geen douane-data raken. Het patroon werkt breder.

1. Corpus diff tegen de vorige snapshot

Voordat de nieuwe index live gaat, draaien we een diff tegen de laatste known-good snapshot. Geen byte diff. Een semantische diff: hoeveel documenten zijn toegevoegd, verwijderd of voor meer dan 30% gewijzigd, en wat is de datumrange van de bronbestanden. Als meer dan 10% van het corpus ouder is dan de mediane documentleeftijd van de vorige snapshot, blokkeert de deploy direct. Die ene regel had de augustus-export gevangen.

from statistics import median


class DeployBlocked(Exception):
    pass


def corpus_age_check(new_docs, prev_snapshot):
    prev_median = median(d.source_date for d in prev_snapshot.docs)
    older = sum(1 for d in new_docs if d.source_date < prev_median)
    ratio = older / len(new_docs)
    if ratio > 0.10:
        raise DeployBlocked(
            f"{older} of {len(new_docs)} docs older than "
            f"previous median ({prev_median.isoformat()}). "
            f"Hard stop. Re-pull the source files."
        )

2. Vijftig golden queries

Een kleine set vragen met bekend-juiste antwoorden, bevroren in een YAML-bestand, in eigendom bij de douane-lead (niet bij engineering). Elke pre-deploy run beantwoordt alle vijftig. Als meer dan twee antwoorden afwijken van de gouden standaard, stopt de deploy en kijkt een mens naar de diff. De set wordt elk kwartaal bijgewerkt, nooit stilletjes. We kozen vijftig omdat dat het aantal is dat de douane-lead bereid is in één sessie opnieuw door te lopen. Het juiste aantal voor jouw team is wat zij ook daadwerkelijk gaan bekijken.

3. Manifest met content hash en model card

Elke index draagt nu een JSON-manifest. Het query-pad leest 'm bij boot. Als de embedding-model-version in het manifest bit-voor-bit identiek is aan die van de vorige index, dwingt het deploy-script de developer om of de versie op te hogen, of een --no-bump flag mee te geven met een geschreven reden. Die flag stuurt een Slack-bericht naar het engineering-kanaal. Hij is bewust lastig te omzeilen.

{
  "embedding_model": "text-embedding-3-large",
  "embedding_model_version": "2026-06-17.1",
  "corpus_content_hash": "sha256:7c4f9a2e...",
  "corpus_source_date_range": ["2026-01-01", "2026-06-15"],
  "document_count": 8412,
  "built_by": "thijs@example.nl",
  "built_at": "2026-06-17T09:14:00Z"
}

4. Tien minuten shadow run

De nieuwe index komt naast de oude omhoog, niet in plaats ervan. Tien minuten lang gaat elke live query naar beide. De twee sets antwoorden worden tegen elkaar gescoord op simpele semantische gelijkenis. Als de divergence rate boven 5% ligt, promoveert de nieuwe index niet. De oude blijft serveren. Een mens krijgt een melding.

Tien minuten is genoeg voor deze expediteur omdat dinsdagochtend hun piek is. Voor corpora met lager volume draaien we de shadow tegen een opgenomen query log van de week ervoor.

De opruimactie

340 antwoorden in drie uur is klein genoeg dat een mens persoonlijke follow-ups kan schrijven. Dat hebben we gedaan. De correcties gingen in batches van vijftig de deur uit, elke batch ondertekend door de douane-lead, elke mail beginnend met dezelfde eerste zin: "We moeten een antwoord corrigeren dat we je vandaag eerder hebben gestuurd." Geen slagen om de arm, geen "door een technisch probleem." Specifieke HS-code, specifiek herzien tarief, specifiek bedrag.

Twee klanten vroegen de dag erna hoe we het hadden opgemerkt. Allebei zijn ze gebleven.

Wat we behielden en wat we veranderden

We hielden de agent. We hielden het RAG-patroon. Het volume aan douane-vragen dat de expediteur krijgt is reëel, en de kosten van een mens die voor de derde keer die week elke classificatievraag moet lezen waren hoger dan de kosten van één slechte dinsdag. Wat we veranderden was de aanname dat een stilstaand model hetzelfde is als een stilstaand antwoord.

De douane-lead tekent nu elke gate-output af voor een deploy live gaat. Ze schrijft geen code. Ze leest de diff, scant de golden-query resultaten en zet een vinkje. Dat checkpoint van vijf minuten is het belangrijkste onderdeel van het systeem. De gate bestaat zodat haar review op een klein, begrijpelijk verschil zit, niet op het hele corpus.

Sinds de uitrol heeft de expediteur 23 index-updates door de gate gehaald. Twee werden geblokkeerd. Eén had een verouderde export vergelijkbaar met het oorspronkelijke incident, gevangen bij de corpus diff. De andere was een schone update waar de douane-lead een verschuiving in de formulering in een BTW-interpretatienotitie zag die engineering niet had opgemerkt. De agent zou correct hebben geantwoord, maar in een toon die zij niet wilde. Kleine zaak. Gevangen voordat het de deur uit ging.

Het ding van vijf minuten dat je vandaag kunt doen

Toen we de email-agent voor deze Utrechtse klant bouwden, was het ding waar we tegenaan liepen dat retrieval-fouten er identiek uitzien aan retrieval-successen totdat iemand stroomafwaarts ze opmerkt. We hebben het opgelost door elke index-swap als een deploy te behandelen en er een vier-stappen gate voor te zetten.

Open het manifest-bestand voor je RAG-index. Als er geen content hash, embedding-model version en corpus-datumrange in staan, voeg ze nu toe. De gate is moeilijker te bouwen. Het manifest is het fundament. Zonder dat kun je niet vaststellen of de index van gisteren dezelfde is als die van vandaag, en kun je niet terugrollen als dat niet zo is.

Kern

Als je RAG-index geen manifest heeft met content hash en embedding-model version, is een stilstaand model geen bewijs dat het antwoord ook is blijven staan.

FAQ

Wat is een RAG hot-swap?

Een retrieval-augmented generation index ter plekke vervangen terwijl de agent blijft draaien. Een veelgebruikt patroon, en een risicovol patroon wanneer de nieuwe index stilletjes afwijkt van wat de versietags van de agent nog beweren.

Waarom is de embedding-model version belangrijk als het model zelf niet is veranderd?

Het is de marker die downstream systemen gebruiken om caches te invalideren, mensen te alerten en re-validatie te triggeren. Als alleen het corpus verandert maar de versie hetzelfde blijft, gaat elke veiligheidscheck ervan uit dat er niets is gebeurd.

Wat is AGS?

Aangifte Goederen Systeem, het Nederlandse douaneportaal waarmee expediteurs douane-aangiftes indienen. Het wordt in het komende jaar uitgefaseerd voor DMS 4.0, wat een extra reden is om je tariefdata van versietags te voorzien.

Hoe lang moet een shadow run duren voordat je een index promoveert?

Lang genoeg om de normale query-verdeling van de index te dekken. Voor de meeste email-agents is tien tot dertig minuten aan live verkeer tijdens de piek genoeg. Voor corpora met laag volume draai je tegen een opgenomen query log.

Heb je alle vier de gate-stappen nodig, of is één genoeg?

Alleen de corpus diff had dit incident al gevangen. De andere drie vangen andere failure modes: prompt-regressies, stille model drift, en toonveranderingen die een douane-lead wel zou zien maar engineering niet.

email automationai agentsragoperationscase studyautomation

Iets bouwen?

Start een project