RAG
RAG bij een private bank: het draaiboek achter elk citaat
09:47 op de Meir: een relatiebeheerder moet een klant antwoorden over een autocallable uit 2019. De KID is vorige maand opnieuw uitgegeven. De FSMA leest logs. Schaal dat eens naar 25 mensen.

Het is dinsdag, 09:47, op de Meir in Antwerpen. Een relatiebeheerder opent een mail van een vaste cliënt die wil weten of een autocallable uit 2019 nog bij haar risicoprofiel past, en of de laatste KID de recente issuer downgrade weergeeft. De fiche staat in Olympic Banking, voor het laatst aangeraakt in 2019. Het geschiktheidsrapport is een PDF op een gedeelde schijf. De KID is dit jaar twee keer opnieuw uitgegeven. Achter de relatiebeheerder staan 34.000 productfiches, in zijn inbox veertien andere cliënten, en één regel van de toezichthouder: geen enkele aanbeveling verlaat dit kantoor zonder een bronvermelding die een compliance officer kan openen.
Die ene mail is er één van zo'n 1.180 die we in één week telden toen we het project startten. De bank heeft 25 mensen. Zonder hulp klopt het rekensommetje niet. Een RAG-systeem dat een derde van dat mailvolume opvangt, met bronvermeldingen die een compliance officer in één lezing vertrouwt, is geen productiviteitswinst. Het is de reden waarom dit project überhaupt budget kreeg.
De compliance-eis die elke architectuurkeuze bepaalde
Voor we één regel code schreven, schreven we één zin op het whiteboard: geen token bereikt het concept van een cliëntenmail zonder een controleerbare verwijzing naar een gevalideerd document. De rest vloeide daaruit voort.
Onder MiFID II moet de adviesketen maanden later nog te reconstrueren zijn. De FSMA leest logs. Een zelfverzekerd antwoord zonder bron is erger dan geen antwoord — dat is een compliance-incident. Het systeem dat we bouwden is dan ook geen 'zoeken plus genereren'. Het is een bronvermeldings-engine die af en toe proza schrijft.
De harde regel, ingebakken in de runtime:
def gate(draft: Draft) -> Draft:
if not draft.citations:
raise NoCitationError("blocked before draft path")
for c in draft.citations:
if c.doc.status != "vetted":
raise UnvettedSourceError(c.doc.id)
if not draft.text_supports(c.span):
raise UngroundedClaimError(c.span)
return draft
Als de gate een fout gooit, bereikt het concept de relatiebeheerder niet. Het belandt in een review-wachtrij met één regel toelichting. We willen nooit dat een zelfverzekerde hallucinatie in een Outlook-conceptmap belandt waar een vermoeide hand op verzenden klikt.
Inventarisatie vóór vectors
De eerste zes weken gingen niet over embeddings, niet over prompts, zelfs niet over Python. We liepen het archief door.
De bank draaide op Olympic Banking, een maatwerk-core uit het Sybase-tijdperk dat sinds 2012 was uitgebreid. Veertien jaar XML-exports, twee Access-frontends die niemand toegaf te gebruiken, en een SharePoint waarin hetzelfde geschiktheidsrapport in vier versies over drie mappen verspreid stond. Van de 34.000 productfiches waren er maar 11.200 nog actief. De resterende 22.800 waren ofwel afgelopen, ingetrokken, of duplicaten uit een migratie van 2017 die nooit was afgemaakt.
We deelden alles in vijf buckets in:
- Gevalideerd & actief — KIDs, geschiktheidsrapporten, actuele fiches, notulen van de productcommissie. Indexeerbaar.
- Gevalideerd & gearchiveerd — laatst-bekende-goede versies van ingetrokken producten. Indexeerbaar, gemarkeerd met intrekkingsdatum.
- Concept — alles wat de productcommissie niet had getekend. In quarantaine.
- Duplicaat — oudere kopieën van een nog actief document. Uit de index gehaald, bewaard voor audit.
- Onbekende herkomst — Word-bestanden die niemand aan een commissie kon toewijzen. Volledig uitgesloten.
Ongeveer 19% van wat de bank als referentiemateriaal beschouwde, overleefde de audit niet. Dat percentage is het belangrijkste getal in deze post. Sla je het over, dan meet elke recall- en precision-verbetering die je later doet iets verkeerds.
Chunken op KID-sectie, niet op tokens
De meeste RAG-tutorials chunken op vensters van 500 tokens met 50 tokens overlap. Wij niet. Een KID heeft vaste secties onder PRIIPs — 'Wat is dit product?', 'Wat zijn de risico's en wat kan ik ervoor terugkrijgen?', 'Wat zijn de kosten?' — en een bronvermelding telt alleen als die in de juiste sectie valt. We chunkten op de sectie, nooit erdoorheen.
SECTIONS = [
"what_is_this_product",
"risks_and_return",
"costs",
"how_long_should_i_hold_it",
"complaints",
]
def chunk(doc: Document) -> list[Chunk]:
sections = parse_kid_sections(doc.pdf)
return [
Chunk(
id=f"{doc.id}#{name}",
doc_id=doc.id,
section=name,
text=text,
vetted_at=doc.vetted_at,
isin=doc.isin,
)
for name, text in sections.items()
if name in SECTIONS and text
]
Eén bijeffect dat we niet hadden voorzien: hierdoor werd de index een derde kleiner dan bij naïef chunken, en recall ging omhoog. Een risicosectie wordt nooit verward met een kostensectie omdat het verschillende rijen in de database zijn.
Er is nog een tweede reden om te noemen. KID-secties veranderen in verschillend tempo. Het risicoblok wijzigt als de productcommissie volatiliteitsaannames herziet, vaak per kwartaal. Het kostenblok beweegt alleen als de issuer een nieuwe prijs zet, en dat is zeldzaam. Token-chunking dwingt een volledige herindexering bij elke wijziging. Section-chunking herindexeert alleen de rijen die echt zijn verschoven, en het auditlog laat zien welke sectie is aangeraakt, door wie, op welke datum. Die eigenschap verdiende zichzelf terug toen compliance voor het eerst vroeg wie de risicobewoording op een specifieke ISIN had aangepast.
Hybride retrieval met een reranker
Dense vectors alleen presteerden slecht op ISIN-codes, tickervermeldingen en de afkortingen die een relatiebeheerder in een queryveld typt. We voegden BM25 over dezelfde chunks toe, fuseerden de top 50 van elk, en herrangschikten de unie met een cross-encoder.
def retrieve(query: str, k: int = 8) -> list[Chunk]:
bm25_hits = bm25.search(query, top_k=50)
dense_hits = vectors.search(embed(query), top_k=50)
fused = rrf([bm25_hits, dense_hits])
reranked = reranker.score(query, fused[:80])
return [c for c in reranked if c.score > 0.42][:k]
De ondergrens van 0,42 was geen gok. We hielden 220 historische relatiebeheerdersvragen met bekende correcte bronnen apart en liepen de drempel door tot F1 stabiliseerde. Onder 0,42 trokken we aanliggende producten erbij. Boven 0,55 misten we geldige antwoorden zodra de relatiebeheerder een instrumentnaam verkeerd spelde.
Twee talen, één index
Een Belgische private bank bedient cliënten in het Nederlands, het Frans, en soms in het Duits. De productcommissie schrijft KIDs eerst in het Nederlands; een gereguleerde Franse vertaling volgt binnen tien werkdagen. Twee talen betekent twee embedding-ruimtes als je niet oplet, en een relatiebeheerder die de vraag van een Waalse cliënt in het Frans stelt, moet alsnog de Nederlandse bron ophalen als dat deze week de enige goedgekeurde versie is.
We gebruiken een meertalig embedding-model met een gedeelde ruimte en taggen elke chunk met zijn taal. De retrieval-call draagt de querytaal; de generatie-call draagt de antwoordtaal. Is de enige gevalideerde chunk in het Nederlands en de query in het Frans, dan haalt het systeem de Nederlandse chunk op, genereert het antwoord in het Frans, en citeert de Nederlandse span. Een voetnoot bij de bronvermelding vertelt de compliance officer wat er is gebeurd, en welk vertaalpaar is gebruikt.
Cross-language retrieval was de feature die in de eerste maand het meeste goodwill opleverde. Het was ook degene die het meest geruisloos kon falen wanneer een modelrelease de embedding-ruimte verschoof zonder dat iemand het merkte. We pinden de embedder vast en voegden een regressietest toe die een bekende Franse query tegen een Nederlandse KID stelt en de build laat falen als de top-1 geciteerde chunk wijzigt.
Bronvermelding eerst, generatie tweede
De generatiestap is begrensd. Het model krijgt de opgehaalde spans en een systeeminstructie die, vereenvoudigd, zegt: noem alleen feiten die woordelijk of bijna woordelijk in de spans hieronder staan; citeer elke zin met de chunk-id; kun je niet uit de spans antwoorden, zeg dat dan en stop.
Daarna valideren we achteraf. Elke zin in het concept wordt met een textual-entailment-check teruggekoppeld aan de geclaimde span. Zinnen die zakken worden eruit gehaald. Faalt het hele concept, dan geeft het systeem 'concept niet beschikbaar, escaleer naar de productdesk' — nooit een verzonnen paragraaf.
Draait je guardrail alleen op de retrieval-laag, dan stuur je hallucinaties de deur uit. De check die telt is die tussen de gegenereerde zin en de geciteerde span, ná generatie, vóór het conceptpad.
Toegangscontrole vóór retrieval
Niet elke relatiebeheerder mag elke fiche zien. Sommige producten zijn voorbehouden aan private-banking-cliënten; andere aan specifieke geschiktheidsniveaus; weer andere aan het team dat ze heeft opgezet. De naïeve aanpak is resultaten na retrieval filteren. Doe dat niet. Wordt een beperkt document eerst opgehaald en daarna uitgefilterd, dan heb je het bestaan ervan al gelekt — in latency en in af en toe een ranking-artefact dat opduikt zodra dezelfde query zichtbaar verschillende aantallen oplevert voor twee rollen.
We duwen het toegangspredicate in de retrieval-call zelf. De vector store kent de rol van de aanroeper en het machtigingsniveau van de chunk, en de index geeft nooit een chunk terug die de aanroeper niet mag zien. Het weigeringspad is identiek of het document nu niet bestaat of de aanroeper er geen toegang toe heeft. Na twee maanden vroeg een compliance officer hoe we dat zouden bewijzen; we draaiden de evalset met drie verschillende rolpersona's en lieten de diff zien. Het gesprek was binnen vijftien minuten klaar.
Evaluatie, geen gevoel
We bouwden een gouden set van 412 vragen, met de hand geannoteerd door twee senior relatiebeheerders van de bank, verspreid over vier middagen. Elke vraag draagt het juiste brondocument, de juiste sectie en een samenvatting in één regel van het juiste antwoord. We draaien de volledige evaluatie opnieuw bij elke modelwijziging, drempelwijziging of aanpassing van een chunking-regel.
Het getal waar compliance om geeft is niet accuracy. Het is citation correctness: van de beantwoorde vragen, hoe vaak is de geciteerde span ook echt de span die het antwoord ondersteunt. Bij de livegang maten we 96,1% citation correctness, 88% beantwoord, 11,4% geweigerd, en effectief nul niet-onderbouwde zinnen over de hele evalset. Weigeringen gaan door naar de productdesk.
Uitrol
De eerste twee weken rolden we uit naar vijf relatiebeheerders. Hun feedback was weinig glamoureus: het antwoord klopt maar de toon is te formeel; het antwoord klopt maar het citeert de verkeerde KID-versie als er twee even vers zijn; het antwoord klopt maar de relatiebeheerder heeft het antwoord in het Frans nodig omdat de cliënt Waals is. Elk daarvan is een apart ticket. Geen ervan is een modelkeuze.
Tegen week zes zaten alle 25 op het systeem. De gemiddelde reactietijd op een relatiebeheerdersvraag zakte van 'ik kom er vanmiddag op terug' naar onder de vijftien seconden. De compliance officer van de bank controleert wekelijks een willekeurige 2% van de concepten en tekent de keten af.
Wat als eerste brak
Drie dingen, achteraf allemaal voorspelbaar.
Eén: de tekencodering van het Olympic Banking-archief was inconsistent. Oudere fiches mengden Windows-1252 en UTF-8, en stilzwijgend corrupte Nederlandse en Franse accenten zorgden dat retrieval exact-match-queries op woorden als 'rémunération' miste. We schreven een normaliser en herindexeerden.
Twee: als een KID midden in de week opnieuw werd uitgegeven, bleef de index tot de volgende nachtelijke job de oude chunk serveren. Een relatiebeheerder kreeg een perfect onderbouwd antwoord bij een verouderd document. We gingen over op event-driven indexering via de webhook van het documentmanagementsysteem, en voegden bij retrieval een versheidscheck toe die elke chunk ouder dan de laatste gevalideerde versie voor dezelfde ISIN naar beneden weegt.
Drie: de vector store had een stil IO-plafond dat we pas voelden toen de concurrency steeg. Bij vijf relatiebeheerders bleef de latency onder de 1,2 seconden. Bij vijfentwintig duwden twee gelijktijdige queries op dezelfde shard de p95 richting de vijf. De fix was weinig glamoureus: een read replica, connection pooling, en een 50 ms in-memory cache op identieke queries binnen een schuivend venster van tien seconden. p95 stabiliseerde op 1,6 seconden. We hadden geen mooiere vector store nodig. We hadden de reflex van een sysadmin nodig.
Het kleinste wat je deze week kunt doen
Vóór dit alles — vóór een vector store, vóór een reranker, vóór een prompt — open de map waaruit je een RAG-systeem zou laten putten en beantwoord één vraag: kan een compliance officer vandaag aanwijzen welk document de canonieke versie is? Is het antwoord 'meestal wel', dan heb je geen RAG-project. Dan heb je een documentmanagementproject dat toevallig eerst aan de beurt is.
Toen we dit bouwden voor de bank aan de Meir, was het stuk dat we hielden en hergebruikten de citation gate aan het begin van deze post: het kleine functietje dat weigert een niet-onderbouwde zin in een conceptmap te laten landen. Het is het type artefact dat een modelwissel, een leverancierswissel en een toezichthouderwissel overleeft. Sta jij voor een vergelijkbaar archief met een toezichthouder over je schouder, dan is dit de vorm waarin we bij ABN aan AI-agents blijven werken.
Kern
In een gereguleerd RAG-systeem is de bronvermeldingsketen het product. Het model is vervangbaar; de gate die weigert een niet-onderbouwde zin in een conceptmap te laten landen, niet.
FAQ
Waarom chunken op KID-sectie in plaats van vaste-tokenvensters?
Omdat een bronvermelding compliance alleen helpt als die in de juiste gereguleerde sectie valt. Sectiebewuste chunks maken van 'risico's' en 'kosten' aparte rijen en voorkomen dat het model ze mengt, en ze laten je alleen de sectie herindexeren die echt is veranderd.
Wat meet 'citation correctness' eigenlijk?
Van de vragen die het systeem beantwoordt, het percentage waarin de geciteerde span de gegenereerde zin daadwerkelijk ondersteunt. Accuracy kan hoog zijn terwijl citation correctness laag is, en alleen het tweede is auditbaar.
Hoe ga je om met een KID die midden in de week opnieuw wordt uitgegeven?
Event-driven herindexering via de DMS-webhook, plus een versheidsregel bij retrieval die elke chunk ouder dan de laatste gevalideerde versie voor dezelfde ISIN naar beneden weegt.
Hoe voorkom je dat een Franse query een afgeschermd Nederlands document lekt?
Duw het toegangspredicate in de retrieval-call zelf, gebruik geen post-filter. De vector store geeft nooit een chunk terug die de aanroeper niet mag zien, en het weigeringspad is identiek of het document nu niet bestaat of de aanroeper er geen toegang toe heeft.
Vereist de FSMA dat elk AI-gegenereerd antwoord door een mens wordt nagekeken?
De toezichthouder schrijft geen specifieke workflow voor, maar eist wel een reconstrueerbare adviesketen. In de praktijk betekent dat: elk concept moet bronvermeldingen meedragen die een reviewer maanden later nog kan openen.