← Blog

RAG

RAG voor accountants: elke claim citeert RJ of Wta eerst

Een accountantskantoor in Groningen met 22 mensen wilde een RAG-agent voor concept-DGA-brieven. De voorwaarde: elke passage moest RJ of Wta citeren voor er één token werd gegenereerd.

Jacob Molkenboer· Oprichter · A Brand New Company· 20 jun 2026· 10 min
Open houten kaartenbaklade met messing tab, ledgerstapel, groene papieren tab en rode lakzegel op ivoor papier.

Het is een dinsdagavond eind november. Een senior accountant in Groningen werkt aan haar vierde conceptbrief naar een DGA, het type brief waarin uitgelegd wordt waarom de deelnemingsvrijstelling van een holding-BV een bepaalde herstructurering niet overleeft. Ze stuit op een citaat. RJ 216.5. Ze fronst. RJ 216 gaat over langlopende schulden, niet over deelnemingen. Het model heeft een nummer verzonnen dat niet bestaat. Twee maanden later leverden we een RAG-agent voor haar kantoor op die weigert een zin te schrijven voordat er een gevalideerde RJ-bepaling of een Wta-artikel op tafel ligt. Dit is het playbook.

De voorwaarde bepaalt de architectuur

De meeste RAG-demo's optimaliseren voor 'beantwoordde het de vraag'. In de accountancy komt eerst een andere vraag: kan ik hier mijn handtekening onder zetten? Een partner bij een Nederlands accountantskantoor valt onder de Wet toezicht accountantsorganisaties (Wta) en wordt gecontroleerd door de AFM. Als de conceptbrief naar de DGA een niet-bestaande RJ-bepaling citeert, is de prijs niet een verwarde klant. Dat wordt een sanctiedossier.

Dat feit zet elke laag van de stack op zijn kop. Retrieval is niet langer 'pak de top-5 chunks'. Het is 'lever een verifieerbare passage met een afdwingbare bronketen'. Generation is niet langer 'schrijf de brief'. Het is 'schrijf de brief binnen het citatiebudget, en als het budget leeg is, weiger'.

Wij noemen dit cite-before-write. Het model mag geen normatieve uitspraak doen — een 'moet', een 'mag', een 'is verplicht' — zonder een bijbehorend citaat dat aantoonbaar in het live corpus voorkomt. Al het andere is een tooling-probleem.

De twee corpora zijn niet hetzelfde beest

Dit kantoor heeft 22 mensen, 1.240 binnenkomende klantvragen per week en twee referentielichamen:

  • 19.800 handreikingen, RJ-bepalingen en Wta-artikelen uitgegeven door de NBA en de Raad voor de Jaarverslaggeving. Normatief, geversioneerd, goed gestructureerd, citeerbaar.
  • Een 12 jaar oud Visionplanner advies-archief: 86.000 interne memo's geschreven door tien verschillende accountants in drie verschillende huisstijlen, deels OCR'd uit gescande PDF's na een kantoorverhuizing in 2017.

Als je beide indexeert met dezelfde chunker en hetzelfde embedding-model, citeert de agent vrolijk een interne memo uit 2014 alsof het een normatieve passage is. Wij behandelen ze vanaf de eerste byte anders.

De NBA-, RJ- en Wta-kant is het autoriteitscorpus. Alleen passages hieruit komen in aanmerking om de citation gate te passeren. Het Visionplanner-archief is het ervaringscorpus. Het mag framings suggereren, naar vergelijkbare historische casussen wijzen en laten zien wat het kantoor de vorige keer deed, maar zijn passages worden nooit als citaat geaccepteerd. Ze worden bij retrieval gemarkeerd als [INTERNAL — non-citable].

Chunking op bepaling, niet op tokens

Token-based chunking is de verkeerde granulariteit voor normatieve documenten. RJ-bepalingen zijn al opgedeeld, door de mensen die ze geschreven hebben. Een bepaling heeft een stabiel identifier (bijv. RJ 270.103), een versie en een schone scope. Bij 512 tokens afkappen lost juist de enige handle op die citatie mogelijk maakt.

We schreven een parser die elk NBA-document doorloopt en één record per adresseerbare eenheid produceert:

@dataclass
class CitableUnit:
    source: str            # "RJ" | "NBA-handreiking" | "Wta"
    identifier: str        # e.g. "RJ 270.103", "Wta art. 25"
    version_year: int      # the published edition
    superseded_by: str | None
    text: str              # the bepaling, verbatim
    parent_path: list[str] # ["RJ 270", "Opbrengsten", "Dienstverlening"]
    source_url: str        # canonical, deep-linked

Het veld superseded_by doet ertoe. Een advies uit 2014 dat RJ 271 (Personeelsbeloningen) citeert, is niet fout vanwege het citaat. Het is fout omdat die paragraaf in 2019 is herschreven. Wij dragen beide edities mee en flaggen de diff bij retrieval. De agent mag alleen de actuele editie citeren, tenzij de vraag expliciet historisch is.

De citation gate

De gate is een klein, saai filter dat tussen retrieval en generation hangt. Het is de belangrijkste component in het systeem.

def citation_gate(passages: list[CitableUnit], question: str) -> GateResult:
    eligible = [p for p in passages
                if p.source in {"RJ", "NBA-handreiking", "Wta"}
                and p.superseded_by is None]

    if not eligible:
        return GateResult(
            allow_draft=False,
            reason="no_normative_passage",
            fallback="ask_human_partner",
        )

    if not any(score_relevance(p, question) > 0.62 for p in eligible):
        return GateResult(
            allow_draft=False,
            reason="weak_relevance",
            fallback="ask_clarifying_question",
        )

    return GateResult(allow_draft=True, citations=eligible[:4])

De relevance-score is een kleine cross-encoder reranker, fine-tuned op 1.800 historische (vraag, geciteerde bepaling)-paren uit het eigen archief van het kantoor. De drempel van 0.62 is geleerd, niet gekozen. Daarover meer in het evaluatiehoofdstuk.

Takeaway

Als je de agent geen passage kunt laten zien die hij mag citeren, is het juiste gedrag niet 'antwoord toch'. Het is 'weiger en haal er een partner bij'. Een RAG-agent die weet wanneer hij zijn mond moet houden, is meer waard dan eentje die zonder bron vol zelfvertrouwen schrijft.

Hybride retrieval, daarna een echte reranker

Embeddings alleen schieten mis op Nederlandse accountancy-tekst omdat het lexicon zo dicht is. 'Deelneming' betekent het ene in RJ 214 en bijna het tegenovergestelde in spreektaal-zakelijk Nederlands. Wij draaien BM25 over de bepalingstekst en een meertalige embedding-index parallel, en mergen daarna met reciprocal rank fusion.

De reranker is waar het echte werk gebeurt. We fine-tuneden een kleine cross-encoder (rond 110M parameters) op triples uit het archief van het kantoor: (klantvraag, geciteerde bepaling, drie near-miss bepalingen). Acht jaar adviescorrespondentie geeft je genoeg signaal om een model het verschil te leren tussen 'deelneming als bedoeld in RJ 214' en 'deelnemingsvrijstelling als bedoeld in artikel 13 Wet Vpb 1969'.

Die reranker redt ongeveer 11% van de queries waar de embedding-index de juiste bepaling op rank 7 of lager zet. Zonder hem zou de citation gate te veel vragen weigeren en zouden de partners hun geduld verliezen.

Gestructureerde output met citatieslots

Generation produceert geen vrij proza. Het produceert een gestructureerd object met citatieslots dat de renderer uitvouwt tot de conceptbrief.

{
  "salutation": "Geachte heer Van Dijk,",
  "paragraphs": [
    {
      "text": "De voorgenomen herstructurering raakt de waardering van de deelneming in {{client.holding_name}}.",
      "citations": ["RJ 214.302", "Wta art. 25"]
    },
    {
      "text": "Onder de huidige verslaggevingsregels dient de deelneming op nettovermogenswaarde te worden gewaardeerd.",
      "citations": ["RJ 214.305"]
    }
  ],
  "open_questions": [],
  "non_citable_context": ["VP-archief 2019-Q3, advies #4471"]
}

Heeft een paragraaf geen entries in citations, dan laat de renderer hem vallen. De prompt van de agent maakt dat expliciet en het schema dwingt het af. Schema-shaped generation is de goedkoopste manier om een model eerlijk te houden.

Evaluatie: een golden set van 312 historische brieven

We pakten 312 conceptbrieven uit de afgelopen drie jaar die door partners waren gereviewd en ondertekend, en stripten ze terug tot (klantvraag, paragraaf, citaat)-triples. Dat leverde 1.847 ground-truth-uitspraken op.

Voor elke daarvan draaiden we de pipeline en maten drie dingen:

  • Citation recall: haalt de agent ten minste één bepaling naar boven die de partner daadwerkelijk gebruikte?
  • Hallucinated citation rate: geeft de agent ooit een identifier af die niet in het corpus voorkomt? Doel: nul.
  • Decline rate: hoe vaak weigert de gate te schrijven? Dit is geen falingsmetric. Het is een kalibratieknop.

De eerste run kwam uit op 71% citation recall, 2,4% hallucinated citation rate en 18% decline rate. Het hallucinatiegetal was diskwalificerend. De fix was geen groter model. Het was een deterministische post-check die elke geëmitteerde identifier opzocht in de corpusindex en de paragraaf liet vallen als de lookup miste. Na die pass stond de hallucinated citation rate op 0% over de hele evaluatieset. De recall klom naar 84% nadat we de cross-encoder reranker erbij hadden gezet.

Het Visionplanner-archief: archeologie, geen search

Het 12 jaar oude advies-archief was het lastigste deel van het project. Driekwart ervan zit in de document store van Visionplanner. De rest zijn losse PDF's van een al lang verdwenen gedeelde schijf. Ongeveer 14% van de PDF's was gescand, niet born-digital, en de OCR was in 2018 gedaan met een tool die 'ƒ' (het oude guldenteken, nog steeds gestrooid door amendementen van vóór 2002) verwarde met 'f' en diacrieten stilletjes liet vallen.

We bouwden de OCR-pass opnieuw met een actueel model, hingen klantmetadata terug door dossier-ID's te matchen vanuit bestandsnaampatronen, en draaiden een deduplicatie-pass die 86.000 documenten terugbracht tot 41.300 distincte advies-units. Daarna labelden we elke unit met de bepaling(en) die in de body geciteerd werd, met een kleine classifier getraind op de historische taxonomie van de partners zelf.

De output van die labeling is wat het ervaringscorpus bruikbaar maakt. Stelt een klant in 2026 dezelfde vraag als iemand in 2017, dan kan de agent het oude antwoord boven water halen, inclusief de bepalingen die toen gebruikt werden, en daarna checken of die bepalingen zijn opgevolgd. Ongeveer 6% van de historische matches flagt nu een verouderd citaat. Dat zijn precies de matches die de partners willen zien.

Throughput en het partner-in-the-loop-patroon

1.240 binnenkomende vragen per week is geen model-latency-probleem. De meeste vragen worden door een junior beantwoord; het werk van de agent is de eerste versie van de conceptbrief schrijven en de citaten leveren. De partner reviewt en tekent.

We schaalden het systeem op twee SLA's: een concept binnen 35 seconden voor 95% van de gevallen, en een same-day partner-reviewqueue. De agent handelt zo'n 78% van de binnenkomende vragen end-to-end af tot conceptstadium. De overige 22% trekt of de citation gate, of wordt direct naar een partner gerouteerd omdat ze domeinen raken die het systeem expliciet niet bezit.

Dat laatste doet ertoe. Het systeem heeft een deny-list van vraagtypen die het niet aanraakt, zelfs als er citaten zijn: alles wat lijkt op belastingadvies in domeinen die het kantoor niet aangeeft, alles met een controleopdracht waarbij onafhankelijkheidsregels in het spel zijn, en alles waar de klantvraag een datum bevat verder dan 180 dagen in de toekomst. Die gaan rechtstreeks naar een mens.

Wat we de eerste keer fout deden

We voerden de agent in eerste instantie elke NBA-handreiking terug tot 2002. Hij presteerde slechter, niet beter. Oudere handreikingen bevatten taalpatronen die het embedding-model overpakte en hergebruikte op plekken waar de moderne richtlijn duidelijker was. Het autoriteitscorpus beperken tot bepalingen uit de actuele editie plus expliciet historische context, tilde de recall met vier punten op.

We probeerden de agent ook direct in het Nederlands te laten schrijven, vanaf het begin. Hij produceerde vloeiende tekst en subtiel verkeerde terminologie. Naar een gestructureerde tussenvorm schrijven en daarna renderen, gaf ons een plek om te valideren voordat er prozavorm op het scherm verscheen.

Het kleinste ding dat je vandaag kunt doen

Run je een kennisintensieve adviespraktijk en denk je over RAG? Doe vanmiddag één audit: pak tien van je laatste conceptbrieven, trek de citaten eruit en check of elk citaat nog wijst naar de actuele editie van de bepaling. Is er ook maar één verouderd, dan heb je net de hallucinatiebodem gemeten van elk naïef systeem dat je hierbovenop zou bouwen.

Toen we dit voor het Groningse kantoor bouwden, was waar we tegenaan liepen niet de retrievalkwaliteit. Het was het gat tussen 'het model schreef iets plausibels' en 'een partner kan dit tekenen'. We losten het op door het citaat als de eenheid van correctheid te behandelen en te weigeren te genereren zonder. Werk je aan iets vergelijkbaars? Onze aantekeningen bij het bouwen van AI-agents voor gereguleerde praktijken liggen daar het dichtst bij.

Kern

Behandel het citaat als de eenheid van correctheid. Als de agent geen gevalideerde passage kan laten zien, is het juiste gedrag weigeren, niet schrijven.

FAQ

Waarom het autoriteitscorpus en het interne archief in aparte indexen houden?

Zodat de agent een interne memo nooit kan citeren alsof het normatief is. Twee indexen plus een source-filter bij de gate is de goedkoopste manier om die grens af te dwingen.

Kan de agent een concept schrijven zonder een geverifieerd citaat?

Nee. Geeft retrieval geen eligible passage boven de relevance-drempel terug, dan blokkeert de gate de generation en routeert de vraag naar een partner, of stelt een verhelderende vraag.

Wat gebeurt er als een geciteerde bepaling is opgevolgd?

Elke citable unit draagt een superseded_by-veld. De agent is beperkt tot passages uit de actuele editie tenzij de vraag expliciet historisch is, en toont de diff wanneer een historische match weer boven komt.

Hoe garandeer je nul gehallucineerde citatie-identifiers?

Een deterministische post-check resolvet elke geëmitteerde identifier tegen de corpusindex na generation. Elke paragraaf waarvan het citaat niet resolvet, wordt gedropt voordat het concept een mens bereikt.

ragai agentsknowledge basecase studyarchitectureoperations

Iets bouwen?

Start een project