RAG
RAG voor medische IFU's: het citation-first playbook
Een Eindhovense distributeur kreeg 1.940 productvragen per week op 28.000 IFU-PDF's. We bouwden een RAG-agent die eerst een CE-certificaat noemt voor hij spreekt.

Maandag, 9:14 in Eindhoven. De sales coordinator van een distributeur van medische hulpmiddelen — 29 mensen — opent de gedeelde inbox en ziet drieënveertig nieuwe vragen over katheter-compatibiliteiten, IFU-revisies en contra-indicaties. Vrijdag staan er 1.940. Op haar tweede monitor draait Notum, het maatwerk MDR-registratiesysteem dat iemand in 2012 in PHP heeft gebouwd, dat in stilte bijhoudt welk van hun 4.100 productreferenties op dit moment geautoriseerd zijn, welke onder een overgangsregeling vallen en welke vorige week een CE-certificaat zagen verlopen op het portaal van een notified body.
Het team beheert 28.000 instructions-for-use-PDF's verspreid over een SharePoint waar niemand blij van wordt. Er is een regulatory affairs lead die al op zaterdag werkt. Er is een salespipeline waarin elke offerte mee moet met een correct, actueel en traceerbaar antwoord, want artikel 10 van de MDR verplicht hen daartoe. En er is een bestuur dat — niet onredelijk — vroeg of een RAG-agent de last kon verlichten.
Dat kon. Maar niet op de manier waarop de meeste RAG-agents gebouwd worden. Dit is het playbook dat we voor hen schreven.
Waar standaard-RAG vastloopt op gereguleerde documenten
De standaard RAG-stack — alles embedden, opdelen in chunks, top-k ophalen, doorgeven aan een model — produceert een antwoord dat correct klinkt. In de context van medische hulpmiddelen is correct klinken juist het faalmodel.
Dezelfde IFU bestaat over zijn levensduur in vijf revisies. Revisie 3.1 waarschuwt misschien tegen gebruik bij patiënten op antistolling. Revisie 3.2 verzacht die waarschuwing wellicht tot 'overleg met de voorschrijver'. Als je embedding-index die twee passages niet uit elkaar kan houden — en een cosinus-similariteitsindex kan dat categorisch niet — kiest het model gewoon de chunk met de hoogste score. Die chunk kan uit een revisie komen die veertien maanden geleden is ingetrokken.
Erger: het hulpmiddel dat de chunk beschrijft draait wellicht onder een CE-certificaat dat de notified body in maart heeft geschorst. De IFU is echt. De tekst klopt voor zijn tijd. Het antwoord is alsnog fout, want de autorisatie eronder is weg.
Artikel 10 vraagt niet om plausibel te zijn. Het vraagt om traceerbaar te zijn. We moesten een retrieval-laag bouwen waarin elke passage, vóór retrieval, gekoppeld was aan een geldig certificaat en een actueel-gezaghebbende revisie. Niets in de model-laag lost dat op. De structuur moet in de index zitten.
De documentgraph komt vóór de chunks
We zijn begonnen door de 28.000 PDF's twee weken volledig te negeren en de wereld te modelleren die ze beschrijven.
Elk hulpmiddel heeft een UDI-DI. Elke UDI-DI hangt aan één of meer CE-certificaten, elk met een nummer van de notified body, een uitgiftedatum, een vervaldatum en een scope. Elk hulpmiddel kent een reeks IFU-revisies, elk gekoppeld aan een datumvenster waarin het de gezaghebbende versie was. Elk post-market surveillance-rapport hangt aan een UDI-DI en een rapportageperiode. Elk hulpmiddel zit in een klasse (IIa, IIb, III) die bepaalt welke reviewstappen een antwoord moet doorlopen.
Dat is een graph, geen verzameling documenten. We hebben het in Postgres gemodelleerd voordat er één PDF in chunks was opgedeeld:
create table device (
udi_di text primary key,
product_ref text not null,
device_class text not null, -- IIa, IIb, III
status text not null -- active, grandfathered, withdrawn
);
create table ce_certificate (
cert_id text primary key,
notified_body text not null, -- e.g. 0123, 0344
issued_on date not null,
valid_until date not null,
scope_summary text not null
);
create table device_certificate (
udi_di text references device,
cert_id text references ce_certificate,
primary key (udi_di, cert_id)
);
create table ifu_revision (
ifu_id uuid primary key,
udi_di text references device,
revision text not null, -- "3.2"
effective_from date not null,
effective_until date, -- null while current
source_pdf text not null
);
create table pms_report (
report_id uuid primary key,
udi_di text references device,
period_start date not null,
period_end date not null,
source_pdf text not null
);De PDF's worden pas in chunks gehakt en geëmbed als ze aan een knoop in deze graph hangen. Een wees-PDF — eentje die niet bij een hulpmiddel hoort dat de distributeur daadwerkelijk voert — komt nooit in de index. We hebben er in week één 3.200 van geschrapt. De meeste waren marketingmateriaal. Vier waren echt verontrustend: IFU's voor hulpmiddelen die de distributeur sinds 2019 niet meer vertegenwoordigde, maar die nooit van de gedeelde schijf waren gehaald. Als een retrieval-laag die passages in 2026 aan een sales-agent geeft, heb je een artikel 10-probleem dat geen achteraf-log meer voor je oplost.
Het certificaat citeren, niet de chunk
Zodra je een graph hebt, is het retrieval-resultaat geen passage meer. Het is een tuple:
{
"passage": "When the device is used in patients receiving anticoagulant therapy, ...",
"ifu_revision": "3.2",
"udi_di": "0419901234567890",
"product_ref": "CAT-IIB-4471",
"cert_id": "CE-0344-MDR-21887",
"notified_body": "0344",
"valid_until": "2027-03-12",
"device_status": "active"
}Het model ziet nooit een passage zonder die envelope. De system prompt is kort en ondubbelzinnig: elke bewering in je antwoord moet gevolgd worden door een citaat dat verwijst naar de IFU-revisie en het certificaat dat het hulpmiddel autoriseert. Als twee opgehaalde passages elkaar tegenspreken, kies dan de passage waarvan de IFU-revisie op dit moment van kracht is. Als de huidige revisie zwijgt over de vraag, zeg dat dan. Niet synthetiseren.
We hebben een truc geleend uit Anthropic's werk rond contextual retrieval: bij het embedden zetten we voor elke chunk een korte gegenereerde contextregel ('Deze passage komt uit IFU-revisie 3.2 voor katheter CAT-IIB-4471, momenteel geautoriseerd onder CE-0344-MDR-21887'). Alleen al dat duwde de recall op revisie-specifieke vragen van 71% naar 92% op onze interne evaluatieset van 600 handmatig gelabelde Q&A-paren. Die evaluatieset is in drie middagen opgebouwd door de regulatory lead en de senior sales coordinator. We leveren geen gereguleerde RAG-agent zonder.
De freshness gate zit tussen retrieval en generatie
Embedding-indexen lopen weg van de werkelijkheid. Notified bodies schorsen certificaten zonder waarschuwing. PMS-rapporten komen middenin een kwartaal binnen. De freshness gate is de deterministische check die we draaien na retrieval en vóór generatie:
def freshness_gate(hits: list[Hit], today: date) -> list[Hit]:
kept = []
for h in hits:
cert = notum.current_cert(h.cert_id)
if cert is None or cert.valid_until < today:
continue # cert lapsed
if cert.status != "active":
continue # suspended / withdrawn
rev = notum.current_revision(h.udi_di)
if h.ifu_revision != rev:
continue # superseded text
if notum.device_status(h.udi_di) != "active":
continue # device pulled
kept.append(h)
return keptAls kept leeg is, roept de agent het LLM helemaal niet aan. Hij retourneert een gestructureerde weigering: 'Geen actueel-gezaghebbende bron ondersteunt een antwoord op deze vraag. Doorgestuurd naar regulatory affairs.' Die weigering komt met een payload — de originele vraag, de kandidaat-hits die zijn gefilterd en de reden waarom elk werd afgewezen — rechtstreeks in een Teams-kanaal dat de compliance lead daadwerkelijk leest.
Eén regel telt hier. De freshness gate draait op de inputs, nooit op de outputs. Als je eerst genereert en dan het antwoord filtert op verouderde citaten, heeft het model zijn standpunt al ingenomen. Filteren achteraf voelt dan als censuur van een antwoord dat 'bijna goed' was, en het team gaat zich vastbijten in randgevallen. Filter de inputs en het model krijgt nooit de kans om met overtuiging fout te zijn.
Notum aansluiten zonder Notum te herschrijven
Notum is veertien jaar oud, geschreven in PHP 5.6 met een MySQL 5.5-backend, en de oorspronkelijke auteur is in 2017 vertrokken. Niemand in het team heeft trek om het te herschrijven, en we waren het eens dat dat het juiste instinct is. Het werkt. Zijn datamodel — UDI-DI, certificaat, revisie, status — heeft drie regulatoire regimes overleefd, inclusief de overgang van MDD naar MDR. Het juiste was om eruit te lezen, niet erdoorheen.
We hebben één read-only MySQL-view toegevoegd die de drie tabellen die de freshness gate nodig heeft platslaat. Een kleine Python-syncjob trekt die view elke vijftien minuten naar een Postgres-mirror, met een row-level checksum zodat we niet herindexen wat niet veranderd is. De agent praat alleen met de mirror. Notum weet niet dat we bestaan, en dat is precies het contract dat je wilt met een systeem dat niemand opnieuw wil deployen.
Eén reconciliatie-verrassing: 137 SKU's hadden in Notum een 'huidige revisie' die niet overeenkwam met het publieke register op EUDAMED. Vooral verouderde Notum-records uit een productlijn-update van 2023 die nooit was bijgewerkt. We exporteerden de diff, de regulatory lead werkte hem in vier middagen door, en Notum was sluitend voordat de agent live ging. De agent legde het probleem bloot. De agent loste het niet op. Dat is de juiste arbeidsverdeling.
Twaalf weken in productie
De agent draait nu drie maanden live. We samplen wekelijks 5% van de antwoorden voor audit, en de regulatory lead leest ze vrijdagochtend na.
- Ongeveer 78% van de 1.940 wekelijkse vragen wordt afgehandeld zonder menselijke escalatie. Het gros zijn compatibiliteits- en contra-indicatie-lookups, en 'welke IFU hoort bij dit lotnummer'-vragen.
- 22% geeft de gestructureerde weigering en routet naar compliance. Ongeveer een derde daarvan blijkt op basis van de huidige documentatie echt niet te beantwoorden — meestal een CE-certificaatovergang of een ontbrekend PMS-rapport. De agent vindt de gaten in de documentset, hij dekt ze niet toe.
- Nul gehallucineerde citaten over twee auditrondes. Elk citaat dat het model afgeeft, verwijst naar een echte cert-ID, een echte IFU-revisie en een echte passage. De citation-first envelope is wat dat oplevert.
- De maandagochtend-inbox van de sales coordinator is om 10:30 leeg in plaats van om 16:00. Dat viel het bestuur op nog voor de rest.
Bij gereguleerde RAG is de index geen platte corpus. Het is een graph die hangt aan dezelfde autoriteitsrecords waarmee je bedrijf al producten levert. Als retrieval niet kan zeggen 'deze passage, deze revisie, onder dit certificaat', kan het model dat ook niet.
Volgende stappen op de roadmap
Drie dingen staan op de roadmap voor het volgende kwartaal. Eén: de publieke registers van notified bodies bewaken op statuswijzigingen van certificaten en binnen het uur een reindex triggeren, in plaats van te wachten op de Notum-sync van vijftien minuten. Twee: de agent koppelen aan de offerte-engine, zodat een uitgaande offerte een inline citaatblok meedraagt dat per regel de regulatoire basis toont. Drie — en dit is degene die de regulatory lead vroeg — de freshness gate uitbreiden naar de cadans van PMS-rapporten, zodat een hulpmiddel waarvan het laatste rapport te laat is, geen positieve antwoorden meer kan geven zonder een expliciete compliance-flag.
Toen we deze IFU-agent voor de Eindhovense distributeur bouwden, was het probleem waar we tegenaan liepen dat Notums beeld van 'huidige revisie' op 137 SKU's was afgeweken van EUDAMED, en daar gaat geen slimme retrieval bovenop helpen. We hebben uiteindelijk eenmalig een reconciliatie met menselijke ogen gedraaid voordat de agent één vraag beantwoordde — het soort onglamoureuze stap dat we nu bij elke RAG-agent inbouwen die we naar gereguleerde workflows brengen.
Open de drie meest gestelde productvragen van vorige week. Kun je voor elke vraag, met de hand, binnen vijf minuten het exacte document aanwijzen, de exacte revisie, en het certificaat dat het beschreven hulpmiddel nu autoriseert? Is het antwoord nee, dan gaat geen RAG-laag dat oplossen. Repareer eerst de graph.
Kern
Bij gereguleerde RAG is de index een graph die aan je autoriteitsrecords hangt. Als retrieval niet kan zeggen 'deze passage, deze revisie, onder dit certificaat', kan het model dat ook niet.
FAQ
Kun je een RAG-agent überhaupt inzetten voor MDR-gereguleerde antwoorden?
Ja, mits elke opgehaalde passage zijn autoriserende CE-certificaat-ID en IFU-revisie meedraagt, en een deterministische gate weigert het model aan te roepen als die niet actueel zijn.
Waarom het veertien jaar oude MDR-registratiesysteem niet herschrijven?
Omdat het werkt. Lezen uit een platgeslagen view van zijn tabellen is sneller, goedkoper en minder risicovol dan code aanraken die al drie regulatoire regimes heeft overleefd.
Hoe voorkom je dat het model citaten hallucineert?
Citeer op de retrieval-laag, niet op de generatielaag. Het model kan alleen citaten geven die teruggekomen zijn in een hit-envelope. Geen envelope, geen antwoord — by design.
Hoe vers moet de freshness gate zijn?
Sneller dan de traagste regulatoire gebeurtenis die je belangrijk vindt. 's Nachts is te traag voor een certificaatschorsing. Wij draaien een Notum-sync van vijftien minuten en houden registers van notified bodies in de gaten op pieken.