← Blog

RAG

RAG-regressietests: retrieval A/B-testen tegen zichzelf

Een methode om stille regressies te vangen in een Nederlandse HR-RAG-pipeline van 38.000 documenten: shadow traffic, een judge-model, en een regressieset die we elke maandag opnieuw opbouwen.

Jacob Molkenboer· Oprichter · A Brand New Company· 7 jun 2026· 10 min
Open houten kaartenbak op ivoorpapier, één groene kaart omhoog, koperen schot, rode elastiek ernaast.

Maandag, 09:14. Een HR-lead bij een Nederlands logistiek bedrijf plakt een vraag in het chat agent dat we voor haar bouwden: "Mag een uitzendkracht meedoen aan de bonusregeling van 2024?" Het agent antwoordt in twee zinnen en citeert de juiste clausule van de CAO. Zes weken eerder had dezelfde vraag een memo uit 2022 opgeleverd en een zelfverzekerd onjuist 'ja'. Er is in die zes weken niets aan het model veranderd. We hebben de retriever twee keer vervangen, de chunker één keer, en de reranker drie keer. De enige reden dat we weten dat het nieuwe antwoord beter is en niet alleen anders, is de opstelling waar deze post over gaat.

We draaien een RAG-pipeline op 38.000 Nederlandse HR-documenten: CAO's, intern beleid, verlofhandboeken, een decennium aan e-mailmemo's die niemand het hart heeft om te verwijderen. Het corpus groeit met zo'n 200 documenten per week. Elke wijziging aan de retrieval-stack, een nieuw embedding-model, een ander chunk-formaat, een reranker-swap, een metadatafilter, is een gok in productie tenzij je het kunt meten. Het lastige is dat RAG-kwaliteit geen enkel getal is. Een wijziging kan de recall op beleidsvragen verbeteren en stilletjes de precision op loonvragen kapotmaken. Je merkt het een maand lang niet, en tegen die tijd zijn er drie andere dingen ook veranderd.

Dus A/B-testen we de pipeline tegen zichzelf. Hier is de methode, van begin tot eind.

Twee pipelines, één query

Het productie-agent heeft een stabiele versie, noem die champion, en één of meer kandidaatversies, challenger-a, challenger-b. Elke binnenkomende gebruikersvraag wordt naar de champion gerouteerd voor het live antwoord. In dezelfde request gaat de query asynchroon ook naar elke actieve challenger. De antwoorden van de challengers zien gebruikers nooit. Ze worden weggeschreven naar een vergelijkingstabel met het champion-antwoord, de opgehaalde chunks per pipeline, de latency, en de token cost.

Dit is shadow traffic, en het is de enige eerlijke manier om een retriever te evalueren. Synthetische vragen wijken af richting wat de engineer kan bedenken. Echte gebruikers vragen dingen die engineers niet kunnen bedenken, in een register waarin engineers niet schrijven. Op het Nederlandse HR-corpus bevat de helft van de productie-queries minstens één van deze: een typo, een switch naar Engels middenin de zin, of een regionale spelling van een juridische term die niet in de officiële documenten voorkomt. Daar overleeft niets van in een handgeschreven testset.

async def handle_query(q: str, user_id: str):
    champion_task = asyncio.create_task(run_pipeline("champion", q))
    challenger_tasks = [
        asyncio.create_task(run_pipeline(name, q))
        for name in active_challengers()
    ]

    answer = await champion_task

    # Fire and forget. Never block the user on a challenger.
    asyncio.create_task(
        log_shadow_results(q, user_id, answer, challenger_tasks)
    )
    return answer

Het patroon is niet bijzonder. De discipline zit in wat je met de rijen doet.

Een judge-model dat niet weet welke pipeline welke is

Voor elke shadow-rij scoren we het champion-antwoord en elk challenger-antwoord met een tweede model. De judge ziet de vraag, de opgehaalde chunks, en de twee antwoorden, gelabeld A en B in willekeurige volgorde. Hij weet niet welke de live versie is. Hij weet niet welke retriever welke chunks heeft geproduceerd. Hij scoort vier assen op een schaal van 1–5: groundedness (elke claim te herleiden naar een chunk), completeness, directness, en recency (citeerde hij de meest recente toepasselijke versie van het beleid). Daarna kiest hij een winnaar of declareert een gelijkspel.

Twee opmerkingen over de judge. Ten eerste: positiebias is reëel en groot. Als je de A/B-volgorde niet randomiseert, kiest de judge vaker positie A dan toeval verklaart. De originele LLM-as-a-judge paper meet dit direct en is twintig minuten waard. Ten tweede moet de judge uit een andere modelfamilie komen dan het model dat de antwoorden genereert, anders zakken de scores in elkaar tot zelfvleierij. We gebruiken een kleiner model voor generatie en een groter model voor de judging. De judge kost bij onze volumes ongeveer één cent per vergelijking, de goedkoopste regel in het hele systeem.

Waarschuwing

Gebruik nooit hetzelfde model voor generatie en judging. Een model dat zijn eigen huiswerk nakijkt vertelt je dat alles in orde is, tot het moment dat een klant escaleert.

De maandag-regressieset

Shadow traffic vertelt je wat er deze week gebeurt op live queries. Het vertelt je niet of de pipeline nog steeds de vragen beantwoordt die hij vroeger correct beantwoordde. Daarvoor houden we een regressieset van precies 200 vragen, en die bouwen we elke maandagochtend om 06:00 opnieuw op.

De rebuild is geautomatiseerd. Een script samplet uit het shadow log van de vorige week onder drie voorwaarden:

  • 80 vragen waarbij de champion vorige week over de hele linie 4 of 5 scoorde. Dit zijn de overwinningen die we weigeren te verliezen.
  • 60 vragen waarbij champion en challenger het oneens waren en de judge de champion koos. Dit zijn de gevallen die de nieuwe retriever moet blijven evenaren.
  • 60 vragen waarbij de champion 2 of lager scoorde. Dit zijn open wonden. We willen dat de regressieset ze onthoudt.

De 80-60-60-verdeling is niet heilig, maar de vorm telt. Een regressieset die alleen is opgebouwd uit wat vandaag moeilijk is, verliest de gevallen die gisteren makkelijk werden. Een set die alleen uit eerdere overwinningen bestaat, duwt de nieuwe retriever nergens heen. De drie buckets beantwoorden drie verschillende vragen: zijn we nog goed in wat we al wonnen, evenaren we de betwiste calls van vorige week, maken we een deuk in de open wonden. Als je één bucket aanpast, breek je waarschijnlijk de andere, dus we behandelen de verdeling als version-controlled en herzien hem per kwartaal, niet wekelijks.

Daarna besteedt een mens, meestal één van ons, ongeveer een uur op maandag aan het beoordelen van de 60 laag-scorende rijen, het met de hand schrijven van het juiste antwoord, en het taggen van de failure mode (wrong-version, missing-document, hallucinated-clause, wrong-language). Dit uur is niet onderhandelbaar. Het is het enige deel van de loop dat niet geautomatiseerd kan worden, en de hele opstelling verliest betekenis zonder dat. Het judge-model kan twee antwoorden vergelijken, maar het kan je niet vertellen wat het juiste antwoord is op een clausule uit het Nederlands arbeidsrecht die het nog nooit heeft gezien.

Bij elke deploy draait elke challenger de volledige set van 200 vragen voordat hij gepromoveerd kan worden. De drempel: geen regressie op één van de 80 winsten, een judge-preference rate boven 55% op de 60 disagreements, en minstens 20 van de 60 open wonden die nu 4 of hoger scoren. Faalt een kandidaat op één van de drie, dan gaat hij niet live. Met de cijfers onderhandelen we niet.

Wat de opstelling daadwerkelijk heeft gevangen

Een reranker-upgrade die de gemiddelde judge-score met 0,3 punten verbeterde, en stilletjes elke vraag brak die afhing van een tabel die als één chunk werd opgehaald. We zagen het alleen omdat vier van de 80 winsten terugzakten naar 2.

Een chunk-size-wijziging van 800 naar 1200 tokens die completeness op beleidsvragen verbeterde en directness op loonvragen vernietigde, omdat het model antwoorden begon op te vullen met omringende context. Het Lost-in-the-Middle-effect bijt eerder dan de meeste teams verwachten zodra chunks lang worden. De geaggregeerde score bewoog amper. De per-tag-uitsplitsing was ondubbelzinnig.

Een embedding-model swap die mooi scoorde op de regressieset en de volgende dag instortte in shadow traffic. De regressieset was opgebouwd uit queries die de oude embedder goed afhandelde, dus er zat een survivorship bias in tegen vragen waar de oude embedder mee worstelde. We wegen de regressieset nu richting de long tail.

Een metadatafilter dat we toevoegden om queries te scopen op de afdeling van de gebruiker, wat de precision op afdelingsspecifieke vragen verbeterde en zo'n veertig queries brak waarbij het juiste antwoord in een afdelingsoverstijgend beleid stond. Het filter was correct op de head van de verdeling en fout op de long tail. De bucket van 60 open wonden ving het binnen twee weken op, omdat de helft van de nieuwe laagscoorders kwam van één afdeling die vragen stelde over het ouderschapsverlof van een andere afdeling.

Conclusie

Een geaggregeerde kwaliteitsscore op een RAG-pipeline liegt bijna altijd tegen je. Splits hem uit per vraag-tag en per failure mode, of je vliegt blind.

Hoe het dashboard eruitziet

De HR-lead ziet hier niets van. Zij ziet een antwoord in twee zinnen met een citatie. Het ops-team ziet één pagina: een grid van champion tegenover elke challenger, judge-preference rate over zeven dagen, per-tag-uitsplitsingen, het latency-verschil en het cost-verschil. Twee getallen bovenaan: shadow agreement (hoe vaak de twee pipelines überhaupt op hetzelfde antwoord uitkomen) en judge preference (als ze het oneens zijn, wie wint).

De interessante kolom is de derde: vragen die de judge niet kon scoren. Loopt dat getal op, dan is er iets in het corpus veranderd waar geen van beide pipelines nog raad mee weet, meestal een nieuw beleidsdocument dat een ouder document tegenspreekt. Dat is een content-probleem, geen retrieval-probleem, en het gaat naar het HR-team, niet naar ons.

De kalibratieloop tussen judge en mens

De judge is een tool, geen orakel. Om hem niet te laten driften, samplen we per week 20 van zijn beslissingen en scoren we die met de hand opnieuw. We loggen de agreement rate per as (groundedness, completeness, directness, recency) en per failure-mode tag. Zakt de algehele agreement onder de 80%, of valt één tag onder de 70%, dan herschrijven we het relevante deel van de judge-prompt en draaien we het shadow log van de vorige maand opnieuw om te bevestigen dat de scores stabiel blijven op gevallen die we al hadden beslecht.

Twee patronen komen telkens terug. Judges die op Engelse benchmarks zijn getraind, bestraffen de wrong-language tag te weinig, omdat ze probleemloos een Nederlands antwoord op een Engelse vraag accepteren zolang de chunks het ondersteunen. En judges vangen missing-document bijna nooit zelf op, omdat ze alleen de chunks zien die de retriever heeft teruggegeven, niet de chunks die hij heeft gemist. Het eerste lossen we op met prompt-herzieningen. Het tweede met een aparte retrieval-coverage-check die offline draait tegen een index van elk document dat de afgelopen twaalf maanden is ingediend.

Wat het kost en waarom we het toch draaien

Globaal: elke live query wordt 1 + N antwoorden, waarbij N het aantal actieve challengers is, plus één judge-call per challenger. Met één challenger is dat 3x de inference cost op shadow-eligible verkeer. We samplen. Gemiddeld krijgt zo'n 15% van de queries een shadow, maar de sampler is niet vlak over de dag. Tijdens de ochtendpiek van 09:00 tot 11:00, wanneer HR-managers hun inbox triage doen, verlagen we de shadow rate naar 5% zodat challenger-calls niet concurreren met de champion om het rate-limit-budget. Tussen 14:00 en 17:00, als het corpus rustiger is, draaien we shadow op ongeveer elke tweede query. De maandag-regressierun is een vaste kostenpost van zo'n €40 aan inference, plus het mensuur.

Voor een chat agent dat 4.000 HR-vragen per week beantwoordt en ruwweg 12 uur van iemands tijd vervangt, kost de opstelling per maand minder dan het koffiebudget. De reden dat we 'm aan houden is niet het kostenrekensommetje. Het is dat zonder de opstelling een senior engineer per release een halve dag bezig is om zichzelf ervan te overtuigen dat de nieuwe versie minstens zo goed is als de oude, en dat hij ongeveer een derde van de tijd ongelijk heeft. Dat verkeerde derde is wat het support team twee weken later te horen krijgt, in de slechtst mogelijke framing.

Toen we het Nederlandse HR-agent bouwden voor een logistieke klant, liepen we ertegenaan dat de regressieset die we op dag één opleverden binnen vier weken nutteloos werd; het corpus was doorgegroeid, de vragen niet. Uiteindelijk losten we het op door de set elke maandag opnieuw uit live verkeer op te bouwen, en dat is de enige reden dat een van de cijfers hierboven zes maanden later nog iets betekent.

Heb je een RAG-systeem in productie en geen regressie-opstelling, dan is het kleinste nuttige wat je vandaag kunt doen: log de retrieved chunk IDs naast elk antwoord. Zodra je een week aan logs hebt, kun je elke query opnieuw afspelen tegen elke nieuwe retriever en het verschil zien. Alles in deze post is bovenop die ene kolom gebouwd.

Kern

Geaggregeerde RAG-scores liegen. Schaduw elke query tegen een challenger, laat een ander model beoordelen, en bouw de regressieset elke week opnieuw op uit echt verkeer.

FAQ

Waarom niet gewoon een publieke RAG-benchmark gebruiken?

Publieke benchmarks matchen niet met jouw corpus, de formuleringen van jouw gebruikers, of jouw failure modes. Ze zijn nuttig als sanity check, niet als deploy-gate. Het signaal dat je nodig hebt zit in je eigen shadow traffic.

Hoe groot moet de regressieset zijn?

Groot genoeg om je failure modes te dekken, klein genoeg dat een mens de onderste plak in een uur kan beoordelen. Voor ons is dat 200. Onder de 100 is de variantie te hoog; boven de 500 wordt hij niet meer herbouwd.

Moet het judge-model groter zijn dan het antwoord-model?

Niet per se groter, maar wel uit een andere modelfamilie, en bij voorkeur met sterkere reasoning. Een judge uit dezelfde familie stempelt antwoorden af die hij zelf ook had geproduceerd.

Wat als de judge en de mens het oneens zijn?

Houd het bij. We samplen per week 20 judge-beslissingen en scoren ze met de hand opnieuw. Zakt de agreement onder de 80%, dan herschrijven we de judge-prompt. Het is een kalibratieloop, geen fire-and-forget.

Werkt dit ook zonder shadow traffic, op een agent met lage volumes?

Ja, maar trager. Vervang shadow traffic door een handmatig samengestelde set die wekelijks met 10 tot 20 vragen groeit uit echte gebruikerslogs. Het ritme van de maandag-rebuild blijft hetzelfde.

ragai agentsknowledge basetoolingarchitectureoperations

Iets bouwen?

Start een project