RAG
RAG achter een ethische muur: playbook voor een octrooibureau
Een Leids octrooibureau. 240.000 documenten. Twee documentsystemen. Eén ethische muur die niet mag lekken. Het RAG-playbook dat elke clausule aan de juiste kant hield.

Het is 22:47 in Leiden. Een partner moet over twee dagen voor een kort geding bij de Rechtbank Den Haag staan. Ze weet dat het kantoor in 2019 precies het argument over gebrek aan inventiviteit heeft gevoerd dat ze nu nodig heeft, in een EPO-oppositie. Het matter-nummer weet ze niet meer. Een zoekopdracht in iManage Work levert 312 hits op. Geen van de top twintig is het. Het document zit ook helemaal niet in iManage Work, want de zaak werd gesloten voordat het kantoor in 2018 van NetDocuments afstapte.
Dit was de opdracht die we kregen. Bouw een retrieval-agent die het argument vindt. Over beide documentsystemen heen. Zonder ooit een chunk te tonen die hoort bij een matter die de partner niet mag zien. Eenentwintig advocaten, 240.000 documenten, één ethische muur die niet mag lekken.
Hieronder staat het playbook dat we gebruikten. We gaan niet doen alsof het netjes ging.
Het twee-corpus-probleem
iManage Work bevat alles vanaf 2018, ruwweg 100.000 documenten met een schoon profielschema: matter-ID, client-ID, document type, auteur, gevoeligheidsvlag. De iManage REST API is rechttoe rechtaan en de profielmetadata is betrouwbaar genoeg om er zonder normalisatieslag op te vertrouwen.
NetDocuments bevat de dertien jaar daarvoor. Ongeveer 140.000 documenten, opgehaald via de NetDocuments REST API, met een metadataschema dat in die periode drie keer is verschoven. 'Client' is soms de viercijferige factureercode, soms de afgekorte klantnaam, soms leeg omdat iemand in 2014 een PDF in een 'scratch'-cabinet heeft geüpload. Ongeveer 8% van het archief is gescand papier, OCR-kwaliteit tussen prima en tragisch.
We hebben beide ingeladen in één corpus-tabel met een discriminator-kolom. De integratiecode bestaat uit twee dunne connectoren, die allebei dezelfde chunker voeden. Het interessante werk gebeurt verderop.
create table chunks (
id bigserial primary key,
source text not null check (source in ('imanage','netdocs')),
external_id text not null,
matter_id text not null,
client_id text not null,
wall_group_id text not null,
doc_type text,
section_path text,
page_from int,
page_to int,
body text not null,
body_tsv tsvector generated always as (to_tsvector('dutch_legal', body)) stored,
embedding vector(1024) not null,
ingested_at timestamptz not null default now()
);
create index chunks_embedding_hnsw on chunks
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
create index chunks_body_tsv on chunks using gin (body_tsv);
create index chunks_wall_grp on chunks (wall_group_id);
De twee indexen voeden bij elke query twee gerangschikte kandidaatlijsten. Die fuseren we met reciprocal rank fusion. Daar komen we zo op.
De ethische muur is een kolom, geen filter
De eerste versie van dit werk behandelde de ethische muur als post-retrieval-filter. Haal de top vijftig chunks op, gooi de chunks weg die de gebruiker niet mag zien, geef de rest terug. Dat is om twee redenen verkeerd.
Het is verkeerd qua veiligheid: een chunk uit een verboden matter zit lang genoeg in de kandidaatset om in onze trace gelogd te worden, in een prompt-buffer terecht te komen, of bekeken te worden door een developer die een query debugt. Zelfs als hij de advocaat nooit bereikt, is hij in elk wezenlijk opzicht over de muur gegaan.
Het is verkeerd qua kwaliteit: als je er vijftig ophaalt en er veertig kwijtraakt aan de muur, hou je tien kandidaten over, en krijgt het model een dunne context. De gebruiker denkt dat het kantoor geen precedent heeft op de vraag. Er is genoeg. Ze mogen het alleen niet zien.
De muur is dus een query-predicaat. Elke chunk draagt een wall_group_id. Elke gebruikerssessie resolveert naar een set toegestane wall groups, berekend uit de matter-toewijzingen van de advocaat en de conflict-matrix van het kantoor. De retrieval-SQL filtert op de index, vóór de ranking.
with allowed as (
select unnest($1::text[]) as wall_group_id
),
dense as (
select c.id, row_number() over (order by c.embedding <=> $2) as rnk
from chunks c
join allowed a using (wall_group_id)
order by c.embedding <=> $2
limit 200
),
sparse as (
select c.id, row_number() over (
order by ts_rank_cd(c.body_tsv, query) desc
) as rnk
from chunks c
join allowed a using (wall_group_id),
plainto_tsquery('dutch_legal', $3) query
where c.body_tsv @@ query
limit 200
)
select id, sum(1.0 / (60 + rnk)) as score
from (
select id, rnk from dense
union all
select id, rnk from sparse
) fused
group by id
order by score desc
limit 30;
De verboden set is de bron van waarheid, niet de toegestane set. Als een matter helemaal niet in de conflict-matrix staat, default-deny. We leerden dit toen de eerste matter van een nieuwe associate nog niet gesynchroniseerd was en de agent vrolijk chunks uit het dossier van een tegenpartij teruggaf.
Hybride retrieval, want octrooirecht is half tekst en half citaat
Dense retrieval is uitstekend in 'vind het argument dat zegt dat de prioriteitsdatum deze claim niet redt omdat de stand van de techniek elk element afdekt.' Het is middelmatig in 'vind alles wat Artikel 56 EOV lid 2 citeert.' Octrooiprocedures draaien op allebei.
De hybride opzet is pgvector voor de dense kant en de native Postgres tsvector full-text search voor de sparse kant. We hebben een dedicated BM25-extensie overwogen (ParadeDB's pg_search is op dit moment de goede optie), maar ts_rank_cd met een custom dictionary was voldoende bij deze corpusgrootte, en we wilden geen tweede extensie in de dependency-tree.
Die custom dictionary doet ertoe. We hebben Postgres geleerd dat 'art. 56 EOV', 'Artikel 56 EOV' en 'EOV art. 56' hetzelfde token zijn. We hebben een thesaurus toegevoegd met veelvoorkomende Nederlandse en Engelse octrooitermen ('uitvinding' / 'invention', 'stand van de techniek' / 'prior art'). We hebben niet geprobeerd slim te zijn over claim-nummers; die lieten we als plain tokens staan omdat advocaten er letterlijk op zoeken.
Voor de dense kant gebruikten we een meertalig embedding-model, fijn afgestemd op zo'n 3.000 query-/relevante-passage-paren die we verzamelden uit een jaar partner-zoekopdrachten van het kantoor. De fine-tune tilde nDCG@10 met 11 punten boven het standaard checkpoint. Dat kostte een lang weekend en één gehuurde GPU.
Chunking, het stuk waar niemand over schrijft
Naïef chunken op een octrooicorpus is een ramp. Een vast venster van 400 woorden splitst een claim chart tussen claim 7 en claim 8, splitst een expertrapport midden in een zin over de inventiviteitsanalyse, en splitst een EPO-oppositieschriftuur op een willekeurige alinea-grens.
We hebben een chunker per document type gebouwd.
- Claim charts chunken per regel. Elk claim-element krijgt één chunk met het claim-nummer, het beweerde corresponderende element in het beschuldigde product, en het aangehaalde bewijs. Section path wordt
Claim 7 / element [b]. - EPO- en EOV-schrifturen chunken per argumentkop. We parsen eerst de kop-hiërarchie en geven één chunk per blad-sectie uit, tot 800 tokens. Lange secties splitsen we op alinea-grenzen.
- Verklaringen en getuigenissen chunken per vraag-antwoord-paar, met een schuivend venster van 200 tokens voor context.
- Expertrapporten chunken per genummerde alinea, omdat experts altijd hun alinea's nummeren en partners ernaar verwijzen op dat nummer.
- Al het andere (correspondentie, memo's, gescande exhibits) valt terug op semantische chunks van 600 tokens met 80 tokens overlap.
De chunker is 340 regels Python. Het is het stukje code met de grootste hefboom in het project. We hebben twee keer opnieuw gechunkt. We gaan nog een keer opnieuw chunken.
Identiteit, sessies en het auditspoor
Elke query is gebonden aan de sessie van een advocaat, en elke sessie resolveert naar een set toegestane wall groups op het moment dat de vraag wordt gesteld, niet op het moment dat de sessie is aangemaakt. Dat doet ertoe omdat matter-toewijzingen dagelijks veranderen en we niet willen dat een partner die om 14:00 van een matter af gaat om 14:05 nog steeds chunks uit die matter ziet.
De conflict-matrix zelf is een graph in Postgres. Knopen zijn clients, matters en partijen. Edges coderen 'vertegenwoordigt', 'is tegenpartij in', en de expliciete muurinstructies van de conflicts-partner. Een wall_group_id is een deterministische hash over het cluster knopen dat het kantoor heeft besloten te isoleren. Wanneer de conflicts-partner een nieuwe muur uitvaardigt, verschijnt er een nieuwe wall_group_id en worden de getroffen chunks binnen minuten opnieuw getagd door een kleine background worker.
Elke retrieval schrijft een rij naar een immutable auditlog: wie vroeg, wat ze vroegen, welke chunks zijn teruggegeven, welke wall groups op dat moment waren toegestaan, welk model en welke prompt-versie. Dat log is het artefact dat een compliance officer leest als er iets misgaat. Het is ook het artefact dat de Nederlandse Orde van Advocaten uiteindelijk zal willen lezen; de Verordening op de advocatuur heeft nog geen schone regel over retrieval-augmented systemen, maar de bestaande regels over geheimhouding en conflict impliceren er al een.
We laten het model de muurhandhaving niet doen. Het model ziet nooit een verboden chunk. Er is geen slimme prompt die zegt 'noem deze documenten niet'. De muur zit in de SQL.
Wat we de volgende keer anders zouden doen
We hebben in week één het hele archief geïndexeerd. Dat had niet gehoeven. Ongeveer 60% van de waarde zit in actieve matters en de laatste drie jaar aan gesloten matters. Met die subset beginnen had het kantoor twee weken eerder van 'geen antwoord' naar 'goed antwoord' gebracht, tegen een vijfde van de embedding-kosten.
We hebben in de eerste ingestion-pass elk gescand exhibit door OCR gehaald. De meeste scans zijn helemaal niet waar advocaten op zoeken. Een tweede OCR-pass, getriggerd door nul-resultaat-queries, had de lange staart tegen een fractie van de kosten opgepakt.
We hebben te lang gewacht met een klein evaluation harness voor het team. Partners hebben sterke meningen over retrieval-kwaliteit en hebben meestal gelijk. De dag dat we een one-screen 'beoordeel deze tien resultaten'-tool live zetten, werd de feedback loop tien keer strakker. Drie weken partner-beoordelingen leverden een relevance set op die we nog steeds gebruiken om elke wijziging aan de retrieval-stack te evalueren.
Het kleinste wat je vandaag kunt doen
Als jij op een multi-system documentenlandschap zit en denkt aan een RAG-agent: begin niet met het embedding-model. Begin met één SQL-query tegen je matter-database die voor elke advocaat de wall groups teruggeeft die hij of zij mag zien. Is die query snel en correct, dan is de rest engineering. Is hij traag of fout, dan heb je een conflictbeheer-probleem, geen AI-probleem, en geen enkele retrieval-slimmigheid gaat dat oplossen.
Toen we deze AI-agent voor het Leidse kantoor bouwden, was niet de hybride index of de chunker het stuk dat het langst duurde. Het was wall-group-resolutie omlaag krijgen tot een query van 12 milliseconden waar de partners genoeg op vertrouwden om hun naam onder te zetten. Toen dat solide was, ging de agent in vijf weken live.
Kern
De ethische muur is een SQL-predicaat op het moment van retrieval, geen filter achteraf. Zodra hij in de prompt of de post-processor leeft, ben je al te laat.
FAQ
Waarom hybride retrieval in plaats van alleen dense?
Dense embeddings zijn zwak in exacte verwijzingen. Octrooiwerk draait op exacte verwijzingen: artikelnummers, claim-nummers, wetscitaten. BM25 vangt die letterlijk. De twee gerangschikte lijsten fuseren met reciprocal rank fusion.
Hoe wordt de ethische muur gehandhaafd?
Elke chunk draagt een wall_group_id. Elke sessie resolveert naar een toegestane set die live wordt berekend uit de conflict-matrix van het kantoor. De retrieval-SQL filtert op de index vóór de ranking, dus verboden chunks bereiken nooit het model of de trace.
Waarom Postgres pgvector en geen dedicated vector database?
Het kantoor draaide al Postgres. Door pgvector toe te voegen bleven het wall-predicaat, de metadata-join en de dense index in één transactionele store. Bij 240k chunks past de HNSW-index ruim in het geheugen op een bescheiden machine.
Hoe hou je de conflict-matrix actueel?
Een kleine background worker kijkt naar het conflicts-systeem. Verandert een muurinstructie, dan worden getroffen chunks binnen minuten opnieuw getagd. De toegestane wall-group-sets worden bij query time bepaald, nooit bij sessiestart.
En hallucinaties in juridische antwoorden?
De agent antwoordt nooit zonder de gebruikte chunks te citeren, en bij elk antwoord staan het bron document, de pagina en het section path naast de uitspraak. Advocaten verifiëren voor ze citeren. We behandelen de agent als research-hulp, niet als auteur.