RAG
Klinische RAG-playbook: citerende agent op verouderd EPD
Een Eindhovense prothesekliniek met 31 mensen beantwoordt 780 pasvragen per week. Hun EPD is 14 jaar oud. Zo bouwden we een RAG-agent die elke passage citeert.

Het telefoontje van 23:00
Een klinieksdirecteur belt. Het is laat. Een orthopeed in Rotterdam zit al veertig minuten te wachten op de pasgeschiedenis van een patiënt die haar chirurg maandag wil opereren. Het dossier bestaat. Het is op papier. Het zit in een ordner. Die ordner staat in een rij ordners die een hele muur van het archief vult. De nachtploeg vindt hem niet.
Dit was de situatie waar we instapten bij een prothese- en orthesekliniek met 31 medewerkers in Eindhoven. Per week komen er zo'n 780 telefoontjes binnen van orthopeden met dezelfde soort vraag. Welke liner heb je in 2019 op patiënt X gepast? Wat was de uitlijning? Heeft de structurele test ooit iets gemeld volgens ISO 10328? De kliniek bewaart 18.000 gescande pasrapporten die teruggaan tot 2004. De gestructureerde data zit in een 14 jaar oud, op PHP gebouwd EPD dat nooit voor export bedoeld was. De antwoorden zitten erin. Ze terugvinden is het knelpunt.
Wat we hebben opgeleverd is een RAG-agent die deze gesprekken in minder dan negen seconden beantwoordt, met elke bewering gekoppeld aan een hyperlink naar de juiste pagina van de scan, en die weigert te spreken als hij niet kan citeren. Hier is de playbook.
Eerst de randvoorwaarden, dan het model
De harde randvoorwaarden kwamen van de kliniek, niet van ons:
- Niets verlaat het gebouw. Patiëntdata blijft op de eigen server.
- Elk antwoord citeert de bronpassage. Geen citaat, geen antwoord.
- De orthopeed verifieert in één klik. Linkt het naar pagina 47 van een scan van 60 pagina's, dan is dat al één klik te veel.
- De agent leest zowel de moderne EPD-records als het tijdperk van de gescande papieren dossiers.
- Het draait op de bestaande server. Geen nieuwe GPU.
Vooral dat laatste bepaalt meer dan mensen verwachten. De bestaande machine is een vier jaar oude Dell rack server met 32 cores en 256 GB RAM. Geen GPU. Er een toevoegen betekent: aanschaffen, beveiligen binnen hun netwerk, en een nieuwe DPIA tekenen met de patiëntenraad. Dat gaat niet snel.
We kwamen uit op een CPU-only stack: Qdrant in single-node modus, BGE-M3 multilingual embeddings, een kleine reranker, en een Nederlandstalig generatormodel dat comfortabel in 24 GB RAM past. End-to-end latency ligt rond zes seconden voor een gewone query, twaalf voor een complexe multi-document vraag. Goed genoeg voor een telefoongesprek.
De OCR-stap is de pipeline
Dit is het onderdeel dat iedereen onderschat. Gescande pasrapporten uit 2004 zijn geen PDF's. Het zijn faxkwaliteit TIFF's met Nederlandse afkortingen, af en toe een Sharpie-aantekening, en een gestempelde header die in ongeveer elf procent van de gevallen over de patiëntnaam heen valt. Draai daar generieke OCR overheen en je krijgt een corpus dat er goed uitziet en op gevaarlijke plekken fout is.
We draaiden een pipeline van drie passes:
- Layoutdetectie per pagina met een vision-model dat blokcoördinaten en bloktypes teruggeeft (getypte tekst, handschrift, formulierveld, handtekening). De kliniek gebruikte tussen 2004 en 2017 vier verschillende rapportsjablonen, plus drie subvarianten voor kinderpassingen. Het vision-model had twee weken labelen nodig om die te leren. Standaard layoutmodellen scoorden onder de 70% bloktype-accuratesse op de oudere scans. Het bijgetrainde model haalde 96%.
- OCR per blok, met aparte modellen voor getypt Nederlands en voor handschrift. Handschrift-OCR draait langzamer en alleen op gedetecteerde handschriftblokken, waardoor de totale doorlooptijd beheersbaar blijft.
- Een confidence-check die velden met lage zekerheid markeert en doorstuurt naar een menselijke reviewer. We hebben de kliniek vooraf gezegd: ongeveer vier procent van de velden vraagt een mens. Ze accepteerden dat. Het alternatief was een zelfverzekerd klinkende agent die af en toe een merk liner verzint.
Heeft je OCR-pipeline geen human-in-the-loop-stap voor velden met lage zekerheid, dan hallucineert je RAG-agent al op de OCR-laag en zie je het nooit terug in je retrieval-logs. De leugen zit stroomopwaarts van het model.
Chunking die het document respecteert
Generieke chunking (500 tokens met 50 overlap) sloopt klinische context. Een pasrapport heeft secties die verschillende dingen betekenen: patiëntgeschiedenis, meettabel, voorgeschreven componenten, structurele testresultaten, vervolgnotities. Hak die in uniforme tegeltjes en je krijgt een passage "structureel testresultaat" terug zonder datum en zonder patiënt-ID, en de agent citeert hem toch.
We chunkten per sectie, met de layoutoutput uit de OCR-pass als grens. Elke chunk draagt:
- het pseudoniem van de patiënt (de kliniek staat geen echte namen in de index toe)
- de rapportdatum
- het sectietype
- paginacoördinaten zodat de citatielink direct naar het juiste gebied linkt
- een hash van het bronbestand voor tamper-detectie
Voor het verouderde EPD exporteerden we de relevante tabellen rechtstreeks uit MySQL en behandelden we elk EPD-record als zijn eigen chunkklasse, gekoppeld aan het scancorpus via patiënt-ID. Het EPD heeft gestructureerde velden. Maak het niet ingewikkelder dan het is.
Data uit het verouderde EPD trekken
Het EPD is een PHP-applicatie uit eind jaren 2000. Er is geen publieke API. Er is een logge CSV-export die timestamps op veldniveau verliest. Onze aanpak was direct: een read-only MySQL-gebruiker, een nightly job die nieuwe rijen naar een parquet-snapshot streamt, en een checksum op de bron-tabellen om schema-drift op te vangen.
-- read-only role for the agent's ingest job
CREATE USER 'rag_ingest'@'10.0.0.%'
IDENTIFIED BY '...';
GRANT SELECT ON epd.patient_fitting TO 'rag_ingest'@'10.0.0.%';
GRANT SELECT ON epd.component TO 'rag_ingest'@'10.0.0.%';
GRANT SELECT ON epd.test_result TO 'rag_ingest'@'10.0.0.%';
GRANT SELECT ON epd.note TO 'rag_ingest'@'10.0.0.%';
FLUSH PRIVILEGES;
We deleten niet in het EPD. We schrijven er niet in. Op de voorpagina van Hacker News stond deze week een stuk dat betoogde dat de enige schaalbare delete in Postgres DROP TABLE is. Datzelfde geldt nog harder in een klinische database waar elke rij een gereguleerd record is onder de Nederlandse AVG. De ingest-job is read-only per beleid en per grant. Schema-drift checksums draaien voor elke snapshot; bij een mismatch stopt de job en wordt een mens gepiept, in plaats van stilletjes door te indexeren tegen een veranderde vorm.
De autoriteitslaag: ISO en NEN als grondwaarheid
Hier is het deel dat de agent nuttig maakt in plaats van alleen snel.
Een typische vraag van een orthopeed is niet wat heb je gedaan. Het is wat heb je gedaan en viel het binnen de specificatie. Specificatie betekent ISO 10328 voor structurele tests op onderbeenprotheses, of NEN-EN 12183 voor handbewogen rolstoelen, of een van een tiental verwante normen. Antwoordt de agent alleen vanuit het patiëntdossier, dan is dat het halve antwoord.
We hebben het normencorpus als een aparte, geverifieerde index ingelezen. Elke klinische passage die een testbare eigenschap raakt (axiale belasting, vermoeiingscycli, voortbewegingskracht) wordt tijdens retrieval gekoppeld aan de bijbehorende clausule uit de relevante norm. De generator moet beide citeren: één bron uit het patiëntdossier, één bron uit het normencorpus. Ontbreekt er één, dan zegt de agent dat en stopt.
In gereguleerde domeinen is retrieval twee indexen aan elkaar geplakt: het rommelige patiëntcorpus en het schone normencorpus. De taak van het model is weigeren als er maar één van beide opduikt.
De generatiestap doet heel weinig
We hebben de generator bewust klein en dom gehouden. Zijn taak: een samenvatting van één alinea, een citatieblok, en een confidence-label. Meer niet. De orthopeed wil geen proza. Hij wil het antwoord, de paginalink, en een gevoel voor hoe zeker het systeem ervan is.
Een typisch antwoord ziet er zo uit:
Patient PSEUD-8842, fitting dated 2019-04-11.
Liner: Ottobock 6Y75, size 28.
Alignment offset: 5 mm posterior.
Structural test: passed per ISO 10328 P5 loading.
Sources:
[1] Fitting report 2019-04-11, page 3
/scans/2019/04/PSEUD-8842_fit_001.pdf#page=3
[2] EPD entry 2019-04-11T14:22, component table
epd://patient_fitting/884201942
[3] ISO 10328:2016, clause 6.3, loading level P5
Confidence: HIGH (4/4 retrieval matches above threshold)
Let op wat er niet staat. Geen slag om de arm. Geen "op basis van de beschikbare informatie." Geen inleiding. De orthopeed klikt op bron [1], ziet pagina 3 van de scan, en het gesprek is binnen 90 seconden klaar.
Evaluatie waar de kliniek op kon vertrouwen
De medisch directeur van de kliniek is een voorzichtig mens. Ze wilde geen leveranciersdemo. Ze wilde de agent zien falen.
We bouwden samen met drie van haar medewerkers een evaluatieset van 400 vragen. Bij elke vraag hoorde een handmatig opgezocht juist antwoord uit het archief. De set was gewogen: 60% routinevragen over passingen, 25% vragen waarvoor een normverwijzing nodig was, 10% bewust dubbelzinnige gevallen waarbij het juiste antwoord "weigeren en een mens bellen" was, en 5% vragen met een opzettelijk foute premisse (vragen over een patiënt die nooit gezien is, vragen over een component die nooit gepast is). Die laatste 15% bestond alleen om de weigerspier eerlijk te houden.
We draaiden de agent wekelijks tegen de volledige set tijdens de bouw, en bewaarden elke fout. Een paar patronen kwamen naar boven:
- OCR-fouten op handgeschreven uitlijnwaarden gaven de gevaarlijkste foute antwoorden. Mitigatie: handschriftblokken gaan voor elk numeriek veld altijd via menselijke review.
- Oudere rapporten (van vóór 2010) gebruikten soms componentnamen die de moderne catalogus niet kent. Mitigatie: een kleine lookup-tabel, bijgehouden door de senior passer, in git geversiond.
- De agent citeerde af en toe een normclausule die inmiddels was vervangen. Mitigatie: een versietag op elke normchunk en een hard filter op de retrieval-call.
De releasedrempel was: 98% correct-of-weigeren op de evaluatieset, met nul zelfverzekerd-foute antwoorden in drie opeenvolgende runs. We hadden zes weken nodig om die te halen. De eerste drie van die weken waren OCR-werk.
Herkomst als ontwerpprincipe
Het meest besproken Hacker News-verhaal van deze week gaat over een Braziliaanse "zelfgekweekte" LLM die een merge bleek van een bestaand open model. De les is niet dat merges slecht zijn. De les is dat herkomst het hele spel is. Een model dat niet kan laten zien waar het vandaan komt, zet geen serieus iemand in productie.
Diezelfde logica geldt voor klinische RAG. Een clinicus accepteert geen antwoord dat hij niet kan terugleiden. Wij schreven het citatiecontract in de retrieval-laag, niet in de prompt: geeft de retrieval-call minder dan twee bronnen boven de drempel terug, dan stuurt de agentroute een harde weigering vóór de generator überhaupt draait. Het model krijgt nooit een vraag die het met een hallucinatie zou kunnen beantwoorden, omdat het er de kans niet voor krijgt.
In de lucht houden
Een RAG-agent in een kliniek is geen demo. Hij draait om 02:00 op een zaterdag, als een spoedeisende hulp moet weten of de patiënt op de brancard ooit een specifieke liner heeft gehad. We hebben drie dingen geïnstrumenteerd en al het andere genegeerd.
Eén: retrieval-scores. Elke query logt de top-vijf retrievalscores en de drempel. Een wekelijkse check vlagt drift: zakt de mediaan van de topscore, dan rot er iets in de index, meestal omdat er een nieuw rapportformat langs de layout-detector is geglipt. Twee: weigerratio per categorie. Een piek in weigeringen op een specifiek rapportjaar betekent meestal dat de OCR-confidence-drempel voor die batch opnieuw moet worden bijgesteld. Drie: replay van de evaluatieset bij elke model- of indexwijziging. De 400-vragenset draait in negentien minuten op de bestaande server, en het team promoveert geen wijziging met ook maar één zelfverzekerd-foute regressie.
Back-ups zijn saai en belangrijk. De indexen zelf bouwen we opnieuw vanuit de bron-scans en de EPD-snapshot, dus zijn ze reproduceerbaar in plaats van geback-upt. De evaluatieset, de lookup-tabel, de gewichten van de layout-detector en de normversiekaart staan allemaal in een git repo met off-site mirrors. Brandt de server af, dan bouwen we de agent in een dag opnieuw op uit schone input.
Wat het kost en wat het oplevert
Na acht weken in productie heeft de kliniek twee dingen gemeten. De gemiddelde tijd om een telefonische vraag van een orthopeed te beantwoorden, daalde van ongeveer zeven minuten naar minder dan twee. De weigerratio, het aandeel vragen dat de agent niet beantwoordt, kwam uit op ongeveer zes procent. De kliniek heeft die ratio liever dan het alternatief. Weigeringen gaan door naar een mens, net als vroeger, alleen ligt de ordner nu al klaar omdat de agent de mens al heeft verteld om welke patiënt en welk jaar het gaat.
Wat je vanmiddag kunt doen
Heb je een corpus aan gescande dossiers en een verouderd EPD liggen, en wil je weten of een gereguleerde RAG-agent haalbaar is, doe dan vandaag één klein ding. Pak vijftig vragen die je medewerkers nu met de hand beantwoorden, schrijf het juiste antwoord ernaast, en bewaar ze als je evaluatieset. Die evaluatieset is het project. De rest is leidingwerk.
Toen we dit bouwden voor de Eindhovense kliniek, zat het lastige niet in de RAG-agent zelf. Het zat in de OCR-pipeline en de discipline om geen antwoord te geven als de retrieval te dun was. We leverden uiteindelijk een systeem op dat ongeveer zes procent van de vragen weigert, en de clinici hebben dat liever dan een zelfverzekerde agent die af en toe ongelijk heeft.
Kern
In gereguleerde RAG is retrieval twee indexen aan elkaar geplakt: het rommelige patiëntcorpus en het schone normencorpus. De taak van het model is weigeren als er maar één opduikt.
FAQ
Waarom de data niet naar een gehoste LLM sturen?
Patiëntdossiers mogen onder hun compliance niet de kliniek verlaten. Alles draait on-prem: vector store, embeddings, reranker, generator. Trager dan de cloud, maar het alternatief was helemaal geen project.
Hoe gaan jullie om met OCR-fouten op handschrift?
Handschriftblokken worden los van getypte tekst gedetecteerd. Elke numerieke waarde die uit een handschriftblok komt, wordt gemarkeerd voor menselijke review voordat hij in de index belandt. Zo'n vier procent van de velden wordt door een reviewer aangeraakt.
Waarom twee indexen in plaats van één?
Patiëntdossiers en wettelijke normen hebben verschillende vertrouwensprofielen en verschillende updatecadansen. Door ze gescheiden te houden kan de agent een citatieregel afdwingen: één bron uit elk, of hij weigert te antwoorden.
Wat gebeurt er als de agent geen antwoord vindt?
Hij weigert, benoemt wat er ontbrak (patiëntdossier, normclausule, of allebei), en stuurt het gesprek door naar een mens. De weigerratio ligt rond zes procent. De kliniek beschouwt dat als het juiste gedrag.
Hoe lang duurde het project?
Ongeveer vier maanden van begin tot eind. De eerste zes weken waren de OCR-pipeline en de evaluatieset. Retrieval, generatie en het citatiecontract kostten de rest. De kliniek draaide vier weken parallel voordat ze omschakelden.