RAG
Legal RAG voor een notariskantoor: drie aanpakken vergeleken
Een klerk had 41 minuten nodig om een huwelijkse-voorwaardenclausule uit 2003 te vinden. Wij bouwden drie RAG-retrievers voor vragen als de hare. De simpelste won bijna.

Een senior klerk bij een notariskantoor in de Randstad had op een dinsdagmiddag een vraag. Ze had de exacte clausuletekst nodig die in 2003 in de huwelijkse-voorwaardentemplate had gestaan, de template die het kantoor in 2008 stilletjes uit gebruik had genomen. Het archief telde ongeveer 180.000 documenten, terug tot 1971. Ze had een naam, een geschat jaar, en de herinnering aan één zinsnede.
Ze vond het document met de hand in 41 minuten.
Die klerk was de reden dat wij gebeld werden. Het kantoor, een praktijk met 40 medewerkers in Utrecht, wilde weten of een RAG-systeem vragen als die van haar binnen seconden kon beantwoorden in plaats van minuten. Het archief stond al geïndexeerd in een eenvoudige zoekappliance. Ze wilden er iets slimmers bovenop.
We bouwden drie kandidaat-retrievers tegen dezelfde evalset. Degene waarvan wij verwachtten dat hij zou winnen, werd tweede. Dit is wat we leerden.
Het corpus en de evalset
Het archief was een mix. Notariële akten, executeursverslagen, huwelijkse voorwaarden, statuten, hypotheken, en decennia aan correspondentie. Ingescande PDF's uit de jaren zeventig met OCR-fouten. Native Word-exports uit de jaren 2000. Templates waarin 90% van de tekst standaardtekst was en 10% het deel dat iemand daadwerkelijk wilde vinden.
De klerken gaven ons 84 echte vragen die ze de afgelopen zes maanden aan het archief hadden gesteld, elk gekoppeld aan het document dat de zoekopdracht had moeten teruggeven. Echte vragen, geen synthetische. Ongeveer een derde waren letterlijke zoekopdrachten ("vind de akte met de clausule over het tuingrensgeschil uit 1999"). Een derde waren entiteitsvragen ("welke akten noemen de familie Janssen tussen 1985 en 1995"). Een derde waren conceptueel ("laat zien hoe wij de plichten van de executeur formuleerden vóór 2010").
Die evalset is de enige reden dat we de juiste beslissing namen. Zonder die set hadden we verscheept wat slim oogde in een demo.
De BM25-baseline die we bijna verscheepten
Het eerste systeem was het simpelste. We hakten elk document in chunks van 400 tokens met een overlap van 50 tokens, indexeerden ze in Elasticsearch met een Nederlandse analyzer (Snowball-stemmer, custom stoplist die juridische termen als "ten behoeve van" intact liet), en gooiden de vraag erin.
BM25 is het algoritme dat Robertson en collega's in de jaren negentig beschreven, en veertig jaar later heeft het nog steeds geen recht van bestaan om zo goed te zijn als het is. De Okapi BM25-referentie is een herlezing waard als je het al een tijd niet hebt gezien. Eén gewogen formule. Geen GPU. Geen vector database. Geen embedding model.
Op onze evalset scoorde het 71% top-5 recall.
Dat is geen typefout. De simpelst denkbare retriever, met een zorgvuldige analyzer en goede chunking, vond het juiste document zeven van de tien keer in de top vijf. De letterlijke zoekopdrachten (een derde van de set) liepen op 96% recall, omdat BM25 precies voor die vorm van query is gemaakt. Waar het instortte was het conceptuele derde, met 38%. De entiteitsvragen kwamen rond de 78% uit.
We hadden dit kunnen verschepen. De klerken waren tevreden geweest. We hebben het bijna gedaan.
Hybride zoeken en de reranker-belasting
De hybride aanpak was de aanpak waarvan wij dachten dat hij zou winnen. Embed elke chunk met een meertalig model, sla de vectoren op in pgvector naast de BM25-index, haal resultaten op uit beide, fuseer ze met reciprocal rank fusion (RRF), en herrank eventueel de top 50 met een cross-encoder.
Voor embeddings testten we drie modellen. Een Nederlandse fine-tune van een sentence transformer, een meertalig groot model, en een generiek Engels model als controle. De Nederlandse fine-tune won met comfortabele marge op de conceptuele queries. Het Engelse model presteerde verrassend dicht erbij op entiteitsvragen (eigennamen reizen makkelijk tussen talen), en slecht op de conceptuele.
De fusiestap was het interessante deel. RRF is de saaie truc die de meeste slimme combinaties verslaat. Je neemt de rang van elk document in elke lijst, berekent 1 / (60 + rang) voor elk, telt op over de lijsten, en sorteert. Zestig is het magische getal uit de oorspronkelijke paper. We probeerden gewogen varianten. De vanille-versie won.
def rrf(rankings, k=60):
scores = {}
for ranked_list in rankings:
for rank, doc_id in enumerate(ranked_list, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
Met BM25 plus dense retrieval plus RRF ging de recall naar 84%. Met een cross-encoder reranker bovenop de gefuseerde lijst ging hij naar 91%.
De reranker-belasting was reëel. Elke query had nu een embedding-call, een vector-search, een BM25-search, het fusierekenwerk, en 50 cross-encoder-scores nodig. Cold-path latency ging van 80ms naar 1,4 seconde. De klerken waren daar niet blij mee. Wij waren ook niet blij met de operationele kosten, want het kantoor wilde alles self-hosted vanwege vertrouwelijkheid.
Als je evalset klein is (onder de 200 queries), laat dan geen 4% verschil in recall je architectuur kiezen. Bootstrap een 95% betrouwbaarheidsinterval voordat je gaat juichen.
Parent-child chunking en het contextprobleem
De derde kandidaat pakte een ander probleem aan. Notariële akten hebben structuur. Een typische akte heeft een aanhef, de partijen, de overwegingen, de operatieve clausules, en de handtekeningen. Als een klerk een conceptuele vraag stelt, kan het juiste antwoord zitten in een specifieke clausule twee pagina's diep in een document van 14 pagina's, maar dat antwoord slaat pas ergens op als je het leest naast de omringende clausules.
Kleine chunks retrieven beter. Grote chunks redeneren beter. Parent-child chunking is de techniek die die spanning oplost. Je indexeert kleine chunks (zeg 200 tokens) voor retrieval, en bewaart een verwijzing naar een grotere parent chunk (zeg 1.500 tokens). Bij query-time haal je de kleine chunk op en geef je de parent terug.
LangChain noemt dit de parent document retriever, en de documentatie is een redelijk startpunt als je het patroon nog niet kent. In productie gebruiken we LangChain niet. We schreven de koppeling in ongeveer 80 regels Python rond dezelfde Elasticsearch- en pgvector-backends.
De implementatie die ertoe deed was de parent-grens. Naïeve parent-child gebruikt vaste parent-vensters. Dat werkt voor proza. Het breekt voor juridische documenten waar de betekenisvolle eenheid de clausule of het artikel is, en waar clausules van één alinea tot twee pagina's kunnen lopen.
We schreven een chunker die door de documentboom liep (gebouwd op basis van layouthints uit de PDF plus regex voor clausulenummering: "Artikel 1.", "1.1", "Onder a."), en maakte van elke parent een volledige clausule. De kleine kinderen binnen elke parent waren vensters van 200 tokens uitgelijnd op zinsgrenzen. De retriever gaf de parent-clausule terug, niet het kindvenster.
CLAUSE_RE = re.compile(
r"^\s*(Artikel\s+\d+|\d+\.\d+|Onder\s+[a-z]\))",
re.MULTILINE,
)
def split_into_clauses(text):
boundaries = [m.start() for m in CLAUSE_RE.finditer(text)]
boundaries.append(len(text))
return [text[a:b].strip() for a, b in zip(boundaries, boundaries[1:])]
Op de evalset scoorde parent-child met hybride retrieval eronder 89% top-5 recall. Net onder de reranker-opzet. De klerken scoorden hem hoger op bruikbaarheid, omdat de teruggegeven context een volledige clausule was die ze konden lezen en begrijpen, in plaats van een fragment dat nog gemonteerd moest worden.
De latency bleef op 180ms.
De beslissing en wat live ging
We lieten de drie kandidaten twee weken blind tegen elkaar lopen bij de klerken. Dezelfde UI, willekeurig welk back-end welke query bediende. De klerken beoordeelden elk antwoord op een vierpuntsschaal. Het hybride-plus-reranker systeem won op recall met 2 procentpunt. Het parent-child systeem won op klerkentevredenheid met 18 punten.
We verscheepten parent-child.
De cijfers uit productie, zes maanden later:
- Mediane query-latency: 210ms.
- Top-5 recall op een vernieuwde evalset van 200 vragen: 88%.
- Gemiddelde tijd tot antwoord voor het soort vraag waarmee deze post opende: 14 seconden, omlaag van een zelfgerapporteerd gemiddelde van 8 minuten.
- Klerken die het systeem afgelopen week minstens één vraag stelden: 34 van de 38.
De saaie BM25-baseline draait overigens nog steeds in productie. Twee redenen. Ten eerste, voor exacte juridische zoekopdrachten (wetsverwijzingen, clausuletitels, met name genoemde contracten) is hij sneller en nauwkeuriger dan welke neurale retriever we ook getest hebben. Ten tweede, hij is de kanarie. Als de embedding-pijplijn zich misdraagt, houdt BM25 het kantoor draaiend.
Een kleine evaluatieset met echte queries, opgebouwd voordat je een architectuur kiest, is meer waard dan welke benchmark dan ook.
Drie dingen die niet uitmaakten
De moeite waard om de experimenten te noemen die mislukten, want die nemen het grootste deel van het werk in beslag.
Multi-vector embeddings (ColBERT-achtig) gaven kleine recall-winst op Nederlandse juridische tekst en een grote opslagoverhead. ColBERT schittert op academische IR-benchmarks. Op ons corpus was het een sisser.
Query-herschrijving met een kleine LLM vóór de retriever. De hoop was dat een LLM "de executeursclausules van voor 2010" kon uitbreiden tot een rijkere query. In de praktijk introduceerden de herschrijvingen evenveel valse sporen als ze oplosten. We trokken hem er na een week weer uit.
Chunken per zin. Klinkt netjes. Vernietigde de conceptuele recall omdat clausules hun context verloren. Het 200-token kindvenster uitgelijnd op zinsgrenzen was de juiste eenheid.
Hoe dit eruitziet voor jouw archief
Als je een documentcorpus hebt en je RAG-architecturen tegen elkaar afweegt, doet de volgorde van handelen er meer toe dan de algoritmekeuze.
Bouw eerst de evalset. Tachtig echte vragen van de mensen die het systeem gaan gebruiken. Koppel elke vraag aan het document dat zou moeten winnen. Zonder dit zit je te gokken.
Draai daarna BM25 met een zorgvuldige analyzer. Dat is je vloer. De meeste systemen beginnen hier en hoeven nooit verder. Als BM25 op jouw evalset 80% haalt, is de vraag of de resterende 20% de operationele complexiteit waard is van iets meer geavanceerds.
Heb je meer nodig, dan is parent-child met hybride retrieval de volgende stap. De cross-encoder reranker is de stap daarna, en alleen als je gebruikers de extra seconde kunnen afwachten.
Toen we de retriever voor het notariële archief bouwden, was wat we niet zagen aankomen hoeveel van het werk in de chunker zat, niet in het model. De logica voor de parent-grenzen was vier keer zoveel code als de retrieval-pijplijn. Dat is het werk dat goed oudert, omdat het geworteld is in de structuur van de documenten zelf en niet in het embedding-model van de maand. Als we tegenwoordig AI-agents en RAG-systemen bouwen voor klanten, is de chunker het eerste dat we ontwerpen, niet het laatste.
Als je vandaag begint, schrijf dan 30 van je echte vragen op papier voordat je één regel code schrijft. Alleen dat al verandert het systeem dat je bouwt.
Kern
Een kleine evaluatieset met echte queries, opgebouwd voordat je een architectuur kiest, is meer waard dan welke benchmark dan ook.
FAQ
Moet ik beginnen met BM25 of meteen door naar hybride zoeken?
Begin met BM25 en een zorgvuldige taalanalyzer. Dat is de vloer die elke andere aanpak moet halen. Scoort hij goed op jouw evalset, dan zijn de operationele kosten van dense retrieval het misschien niet waard.
Hoe groot moet mijn evaluatieset zijn?
Tachtig echte vragen van de mensen die het systeem gaan gebruiken is genoeg om een richtinggevende beslissing te nemen. Tweehonderd is genoeg om een klein verschil in procentpunten te vertrouwen. Onder de vijftig is theater.
Wanneer is een cross-encoder reranker de latency-kosten waard?
Als je gebruikers de extra seconde willen afwachten en je al alles uit chunking hebt gehaald. Reranking kan de recall vijf tot tien punten omhoog tillen, maar het verdubbelt of verdrievoudigt de cold-path latency en kosten per query.
Heeft parent-child chunking een eigen chunker nodig?
Voor proza niet. Voor gestructureerde documenten als juridische akten, wetten of contracten wel. De parent-grens moet de eigen structuur van het document volgen (clausules, artikelen, secties), niet een vast tokenvenster.