← Blog

RAG

RAG voor Nederlandse huurcontracten: drie stacks getest

Een Utrechtse woningcorporatie van 21 mensen verwerkt 3.400 huurdervragen per week tegen 280.000 gescande huurcontracten. We testten drie retrieval-stacks op die echte werklast.

Jacob Molkenboer· Oprichter · A Brand New Company· 15 jun 2026· 7 min
Half open eiken kaartenbak met cream kaarten, groen tabblad, manilla map, rode lakstip op ivoor linnen.

Het is dinsdagochtend op het kantoor van een Utrechtse woningcorporatie. Een klachtenbehandelaar, Eva, klapt haar laptop open. Een huurder betwist een regel mutatie-kosten op zijn eindafrekening. De clausule waar hij tegenin gaat is in 2014 getekend. Het contract is een gescande PDF die een stagiair tien jaar geleden door een Konica Minolta heeft gehaald. De OCR denkt op drie plekken dat huurder hourder is.

Eva handelt wekelijks 3.400 van dit soort zoekvragen af, samen met een team van zeven. Het corpus bestaat uit 280.000 huurcontracten, addenda en huurderscorrespondentie die teruggaat tot 2002. We werden gevraagd de RAG-retrieval-laag te bouwen achter de agent die haar eerste antwoord opstelt.

Deze post is het vergelijkingsdocument dat we voor dat project schreven: LlamaIndex, Haystack en een zelfgebouwde combinatie van pgvector plus BM25, getoetst aan de echte werklast.

De werklast, geen benchmark

De verleiding bij een RAG-vergelijking is om MS MARCO te downloaden en het daarbij te laten. Wij deden dat niet. De lat lag specifiek bij de corporatie:

  • 3.400 Nederlandstalige queries per week, piek 90 per uur op maandagochtend
  • 280.000 documenten, gemiddeld 4 pagina's, ruim 11 procent met OCR-foutmarges boven 5 procent op tekenniveau
  • Harde eis: citeer de exacte paragraaf, met paginanummer, voor elk antwoord dat over geld of opzegdata gaat
  • 21 medewerkers, één parttime sysadmin, één Postgres database die ze al op Hetzner draaien

Die laatste voorwaarde woog zwaarder dan de rest. Niemand bij de corporatie had zin om een vector-store DSL te leren bovenop de zeven systemen die ze al onderhouden.

Stack één: LlamaIndex op Pinecone

LlamaIndex is de snelste route van nul naar een werkende agent. We hadden het corpus geïndexeerd en een eerste antwoord stromend na elf uur werk. De ingestion pipeline regelde de PDF's, draaide ze door een hosted OCR, chunkte ze, embeddete met multilingual-e5-large en sloeg de vectors op in een managed Pinecone index. De ingestion pipeline docs zijn eerlijk over de afwegingen, en dat waardeerden we.

Wat ons beviel: de QueryFusionRetriever leverde een hybride van dense en sparse out of the box. Een Nederlands-bewuste splitter respecteerde paragraafgrenzen beter dan naïeve vensters van 512 tokens, en dat telde voor de boilerplate clausules die in duizenden contracten terugkeren.

Wat het in productie de das om deed: kosten per query en lock-in. Pinecone kostte op ons documentaantal zo'n 70 euro per maand voor de index plus nog eens 90 euro per miljoen queries. Tel daar de hosted embedding refresh bij op bij elke documentwijziging en we zaten op ruwweg 380 euro per maand aan vendorrekeningen voordat er één token gegenereerd was. De corporatie draait lean. Dat is meer dan ze voor hun boekhoudsoftware betalen.

Dan was er nog het OCR-probleem. De default settings stuurden chunks rechtstreeks door naar de embedder. Verkeerd ingelezen tokens als hourder zijn out-of-vocabulary voor elke embedder die op schoon Nederlands is getraind. De recall op de slechte-OCR-slice was 0,61 bij k=20. Niet onbruikbaar, maar de missers concentreerden zich precies in de clausules die in disputen opduiken.

Stack twee: Haystack op Elasticsearch

Haystack voelde alsof het geschreven was door mensen die ook echt een zoeksysteem hebben uitgerold. De component graph in versie 2 is netjes. We bedraadden een hybride retrieval pipeline (BM25 vanuit Elasticsearch, dense vanuit een self-hosted embedder, gefuseerd met reciprocal rank fusion) in één YAML, en met de DocumentStore abstraction konden we backends wisselen zonder de pipeline te herschrijven.

Het reranker-verhaal is ook beter. Haystack laat een cross-encoder los op de top-k uit de retrieval. Op de dispute test set (we bouwden er een: 84 echte klachten van de afgelopen 18 maanden, met de ground-truth clausules getagd door de hoofdjurist van de corporatie) tilde de reranker de mean reciprocal rank van 0,58 naar 0,74.

Wat ons uiteindelijk wegduwde bij Haystack was de operationele belasting. Elasticsearch wil zijn eigen JVM, eigen back-up-verhaal, eigen versie-upgrade-kalender. De sysadmin van de corporatie is één persoon, drie dagen per week. Hem vragen Elasticsearch toe te voegen aan een stack waarin al Postgres, Redis, een MTA en een oude SharePoint draaien, voelde onvriendelijk.

We waren bijna toch live gegaan. De retrieval-kwaliteit was de beste van de drie. Maar elk gesprek over uptime eindigde met 'en wie herstart 'm op zondagochtend'.

Stack drie: pgvector plus BM25, zelfgebouwd

De corporatie had al Postgres. Ze draaien hem goed. Dus zetten we het corpus in de database die ze al vertrouwen. De BM25-kant draait op ParadeDB's pg_search; de vectorkant op pgvector met een HNSW-index.

Het schema is niet bijzonder:

create extension vector;
create extension pg_search;  -- BM25 inside Postgres

create table chunks (
  id          bigserial primary key,
  contract_id text not null,
  page        int not null,
  paragraph   int not null,
  body        text not null,
  body_clean  text not null,   -- OCR-corrected variant
  embedding   vector(1024),    -- multilingual-e5-large
  tsv         tsvector generated always as (
                to_tsvector('dutch', body_clean)
              ) stored
) partition by range (contract_year);

create index on chunks using hnsw (embedding vector_cosine_ops);
create index on chunks using bm25 (id, body_clean)
  with (key_field='id');

Hybride retrieval is een CTE die de twee queries draait, de scores normaliseert en fuseert met reciprocal rank fusion. Het geheel zit in één stored function die de agent aanroept.

De body_clean kolom was de truc die het recall-probleem brak. We deden een eenmalige pass over de 31.000 chunks met slechte OCR, en vroegen een goedkoop lokaal model voor de hand liggende typo's te corrigeren tegen een Nederlands woordenboek, met behoud van alles wat eruitzag als een naam, adres of getal. De clean kolom ging naar BM25 en naar de embedder. De ruwe kolom bleef beschikbaar voor weergave, zodat huurders en juristen nog steeds de originele formulering zagen.

Recall op de dispute set: 0,79 bij k=20 voor hybride, 0,86 na een cross-encoder rerank. Dat versloeg het pre-rerank getal van Haystack en evenaarde diens post-rerank getal. De reranker is bge-reranker-v2-m3, draaiend op één oude GPU die de corporatie nog in een la had liggen.

Onthouden

Recall-problemen op OCR-zware corpora zitten meestal upstream van de retriever. Maak de tekst één keer schoon, sla beide versies op, en het embedder-debat verdampt grotendeels.

Kosten per query bij 3.400 wekelijkse zoekopdrachten

Kosten na drie maanden in productie, all-in:

  • LlamaIndex op Pinecone plus hosted embeddings: 0,029 euro per query
  • Haystack op self-hosted Elasticsearch plus self-hosted embedder: 0,011 euro per query, plus zo'n 4 uur sysadmin-tijd per maand
  • pgvector plus BM25 op de bestaande Postgres: 0,003 euro per query, geen nieuwe infrastructuur

Het pgvector-getal is stroom plus de marginale CPU-kost van queries op een database die toch al draaide. Het is niet gratis. Het komt zo dicht bij gratis als je in productie kunt krijgen.

Wie rerankt als een huurder een clausule uit 2014 betwist

Dit was de interessantste vraag die we stelden, en degene die de meeste vergelijkingsposts overslaan.

Als Eva's agent de verkeerde clausule ophaalt bij een mutatie-kosten dispuut, moet iemand het zien. In de LlamaIndex-opstelling was de reranker een black box: een hosted cross-encoder achter een API. We konden scores loggen, maar niet inspecteren waarom een paragraaf hoog uitkwam. Toen de jurist vroeg 'waarom paragraaf 7 boven paragraaf 12', haalden we onze schouders op.

Haystack en pgvector lieten ons allebei een lokale reranker draaien die we konden lezen. We dumpten de top-50 met hun dense scores, BM25 scores, gefuseerde scores en rerank scores in een kleine admin-pagina. De jurist gebruikte hem één keer per week om steekproeven te doen op geëscaleerde disputen. Na zes weken vond ze een patroon: contracten uit een specifiek venster 2013 tot 2015 hadden een templating-eigenaardigheid die de mutatie-clausule naar pagina 4 duwde in plaats van pagina 2. We pasten de chunker aan zodat hij die header respecteerde. De recall op die slice ging van 0,71 naar 0,93.

Die feedback loop krijg je alleen als de mensen de scores kunnen zien. Achteraf pleitte dat voor de saaie stack.

De Postgres delete die ons bijna beet

Halverwege het project namen we een partitioneringsbesluit dat we hadden uitgesteld. Woningcorporaties verwijderen contracten zeven jaar na einde huur volgens het bewaarbeleid. Een DELETE draaien over een HNSW-index van 280.000 chunks zou elk kwartaal een lange, blokkerende, pijnlijke operatie betekenen. De partitioning docs van PostgreSQL benoemen het alternatief: DROP PARTITION is alleen metadata, terwijl DELETE elke index op de tabel moet doorlopen.

Let op

Heeft je RAG-corpus een bewaarbeleid? Partitioneer dan vanaf dag één op de bewaartermijn-key. Een partitie droppen is direct. Verwijderen uit een geïndexeerde tabel niet.

Daarom zijn de chunks gepartitioneerd op contractjaar. Wordt een contract vernietigd, dan droppen we zijn partitie. Index-rebuild op de rest van de tabel: nul. Saaie infrastructuurkeuzes stapelen op.

Wat we uitrolden

De corporatie ging in maart live op de pgvector plus BM25 stack. Eva's team handelt dispuut-correspondentie af in ruwweg 40 procent van de tijd die het vroeger kostte. De agent stelt op; zij redigeert. Citaten zijn exact, met pagina- en paragraafnummers. De sysadmin hoefde geen nieuwe database te leren.

Toen we de RAG-agents voor deze corporatie bouwden, was het terugkerende thema dat het kwaliteitsdebat over retrieval altijd upstream van de OCR zat. We losten het op door een opgeschoonde tekstkolom naast de originele op te slaan, de schone te embedden, en de originele aan de huurder te tonen.

Scope je dit kwartaal een RAG-project? Dan is dit de vijf-minuten-audit: open het slechtst-geOCR'de document in je corpus, plak een paragraaf in je retriever, en zoek op een zin uit die paragraaf. Krijg je niets terug, dan gaat geen enkele vector database dat voor je oplossen.

Kern

Op OCR-zware corpora verslaat het opschonen van de tekst upstream, met origineel ernaast bewaard, elke retriever-keuze. Kies daarna de stack die je sysadmin al kan draaien.

FAQ

Moet ik beginnen met LlamaIndex of pgvector voor een nieuw RAG-project?

Begin met LlamaIndex als je deze week een werkende demo nodig hebt. Stap over op pgvector zodra je het echte queryvolume en de operationele capaciteit van je team kent.

Hoeveel doet OCR-kwaliteit ertoe voor retrieval-recall?

Veel. Op onze testset tilde het opschonen van OCR-fouten upstream van de embedder de recall met ruwweg 18 punten op de slechte-OCR-slice. Dat is meer dan welke retriever-keuze ons ook opleverde.

Waarom geen managed vector database?

Bij 3.400 queries per week was de vendorrekening zo'n 380 euro per maand vóór generatie. De bestaande Postgres handelde dezelfde werklast af voor de stroomkost van een paar HNSW-queries.

Heeft Haystack in 2026 nog zin?

Ja, als je Elasticsearch al comfortabel draait. De retrieval-kwaliteit en het rerank-verhaal zijn uitstekend. De operationele belasting was voor deze klant de dealbreaker.

Welke reranker zetten jullie in productie?

bge-reranker-v2-m3, draaiend op een oude GPU die de corporatie al bezat. Hij tilde recall@20 van 0,79 naar 0,86 op de dispute test set.

ragai agentsarchitectureknowledge basecase studyoperations

Iets bouwen?

Start een project