RAG
Image RAG voor plattegronden: schetsen door 40k PDF's
Een architectenbureau met veertien jaar werk in 40.000 plattegrond-PDF's stelde ons een kleine vraag: konden ze het archief doorzoeken door te tekenen?

Op een dinsdagochtend in februari opende de directeur van een Rotterdams architectenbureau een briefing van een klant die een uur eerder haar kantoor had verlaten. De klant wilde een wigvormig huis met een binnenplaats uitgesneden in de zuidhoek. Ze wist nog dat ze in 2018 iets vergelijkbaars had getekend. De projectnaam wist ze niet meer, de klant niet, het jaar niet. Het zoekvak op haar Mac gaf haar bestandsnamen. Hun DMS gaf haar tags. Geen van beide wist hoe een wig met een binnenplaats eruitziet. Ze had 40.000 plattegrond-PDF's op een NAS staan, veertien jaar opgebouwd werk, en geen enkele manier om het archief te vragen: 'laat me alles zien wat hierop lijkt.'
Dit is het verhaal van wat we voor haar bouwden, en de drie of vier keer dat het op interessante manieren faalde voordat het ophield met falen.
Wat het bureau eigenlijk vroeg
De briefing was drie zinnen. 'We hebben 40.000 PDF's. We willen ze doorzoeken door op een canvas te tekenen. Kun je dat bouwen.' Het eerste gesprek besteedden we aan de voor de hand liggende vervolgvraag: wilden ze vergelijkbare vormen, vergelijkbare programma's, of vergelijkbare visuele stijlen. Het antwoord was ja op alle drie, en ze wisten niet zeker welke ervan zwaarder woog. Dat antwoord bleek het belangrijkste datapunt van het hele project.
Het archief zelf was ongelijk. Ongeveer 12.000 PDF's waren vector-exports uit Vectorworks en ArchiCAD met intacte laag-metadata. Nog eens 18.000 waren platte rasterscans van oudere tekeningen, een deel daarvan foto's van papieren plannen die aan een wand hingen. De resterende 10.000 waren een rommeltje: prijsvraaginzendingen, schetsen in PDF-vorm, en een handvol bestanden die pikepdf eerst moest repareren voordat iets anders ze kon openen.
Waarom CLIP onderuitging bij de eerste test
Het instinct bij elk image-RAG-project in 2026 is om naar CLIP of een CLIP-variant te grijpen, alles te embedden, de vectors op te slaan en het project af te ronden. Er is een reden waarom dit werkt voor productfoto's en stockbeelden, en een reden waarom het uit elkaar valt op plattegronden. CLIP is getraind op image-text paren die van het open web zijn geschraapt. Het visuele vocabulaire dat het leert, is fotografisch. Een lijntekening van een gebouwvoetafdruk zit vrijwel nergens in die trainingsdistributie.
We hebben het toch getest. We embedden 500 plattegronden met OpenCLIP ViT-L/14, embedden een paar schetsen die de directeur op een iPad had getekend, en vroegen om nearest neighbors. De beste resultaten voor een wigvormige schets waren twee pizzapunten, een stuk taart, en een topdownfoto van een zwembad. De directeur moest lachen, en vroeg toen, beleefd, of we iets anders konden proberen.
Het patroon duikt op in elk vakgebied waar het visuele vocabulaire afwijkt van webfotografie: medische scans, satellietbeelden, CAD-output. CLIP is een startpunt, geen antwoord, als jouw domein niet fotografisch is.
40.000 plattegronden uit PDF's halen zonder de goede te verliezen
Voordat er ook maar een model bij de data kwam, moesten we de juiste pagina van elke PDF als een schoon raster renderen. Architectuur-PDF's zijn pathologisch. De 'plattegrond' kan op pagina 1 staan, op pagina 7, of op pagina 23. Dezelfde PDF bevat vaak situatietekeningen, plattegronden, doorsneden, gevels en een titelblok, en wij wilden alleen plattegronden. Vector-PDF's renderen scherp op elke DPI; gescande PDF's zijn ruizig en scheef.
De pipeline werd uiteindelijk dit:
#!/usr/bin/env bash
# render-plans.sh - render every page of every PDF at 300dpi, then classify
for pdf in /archive/**/*.pdf; do
base=$(basename "$pdf" .pdf)
out="/render/$base"
mkdir -p "$out"
pdftoppm -r 300 -png "$pdf" "$out/page" 2>/dev/null \
|| (pikepdf --replace "$pdf" /tmp/fixed.pdf \
&& pdftoppm -r 300 -png /tmp/fixed.pdf "$out/page")
done
Classificeren welke gerenderde pagina de plattegrond is, was zelf een klein model. We trainden een lichte ResNet-18 op 1.200 handmatig gelabelde voorbeelden over zes klassen: plattegrond, situatietekening, doorsnede, gevel, titelblok, overig. De accuracy op de validatieset kwam uit op 94%, en dat was genoeg. De 6% die misclassificeerde, haalde meestal situatietekeningen en plattegronden door elkaar, en die bleken allebei doorzoekbaar te zijn, dus we hielden ze allebei.
Sla de pagina-classifier-stap niet over. We probeerden eerst 'embed gewoon elke pagina'. De vector store ballonneerde van 40.000 entries naar ongeveer 380.000, de retrieval-kwaliteit zakte, en de architecten kregen voortdurend titelblok-matches terug als ze een vorm schetsten.
De embedding-stack waar we op uitkwamen
We testten zeven embedding-strategieën. De twee die werkten, in volgorde van bijdrage:
Eerst, DINOv2 base (ViT-B/14) als backbone. DINOv2 is getraind met self-supervised objectives op natuurlijke beelden, maar in tegenstelling tot CLIP is het niet afhankelijk van tekstcaptions, en zijn features blijken goed te reageren op structuur en topologie. Lijntekeningen zitten nog steeds buiten zijn trainingsdistributie, maar een kleine adapter head dicht het gat.
Ten tweede, een MLP-adapter van drie lagen die we bovenop de DINOv2-features trainden met triplet loss. We labelden in twee middagen 600 triples samen met de junior architecten van het bureau (anker-plattegrond, structureel vergelijkbare positive, structureel afwijkende negative). Nog eens driehonderd kwamen uit synthetische augmentatie: rotaties, spiegelingen, en perturbaties van de lijndikte van elke anker.
Het derde stuk, dat later kwam en het project veranderde, staat hieronder.
import torch, torch.nn as nn
from transformers import AutoModel, AutoImageProcessor
backbone = AutoModel.from_pretrained("facebook/dinov2-base")
proc = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
class PlanHead(nn.Module):
def __init__(self, d_in=768, d_out=256):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_in, 512), nn.GELU(),
nn.Linear(512, 384), nn.GELU(),
nn.Linear(384, d_out),
)
def forward(self, x):
return nn.functional.normalize(self.net(x), dim=-1)
head = PlanHead()
@torch.no_grad()
def embed(pil_image):
px = proc(images=pil_image, return_tensors="pt").pixel_values
feats = backbone(px).last_hidden_state[:, 0] # CLS token
return head(feats).cpu().numpy()
Vectors gingen naar Postgres met pgvector. Het bureau draaide al Postgres voor hun projecttracker; de extensie toevoegen was een aanpassing van één regel. We hebben Qdrant en Weaviate overwogen en besloten om er geen tweede database bij te slepen die de IT-contractor van het bureau zou moeten leren.
Van schets-canvas naar query-vector
De interface is een canvas van 1200x900 in de browser. De architect tekent met een drukgevoelige stylus of een muis. Elke streek gaat naar een queue; als de gebruiker 400ms pauzeert, rasteriseren we het canvas naar een grayscale PNG van 518x518, dilateren we de streken met 2 pixels om de visuele zwaarte van gearchiveerde plattegronden te matchen, draaien we hem door dezelfde embedder, en queryen we pgvector voor de top 24 nearest neighbors.
SELECT plan_id, file_path, page,
1 - (embedding <=> $1::vector) AS similarity
FROM plan_embeddings
ORDER BY embedding <=> $1::vector
LIMIT 24;
De mediane query-tijd op het kleine Hetzner-bakje van het bureau, met een HNSW-index over 40.000 vectors, is 38ms. Een GPU hebben ze niet nodig voor retrieval. De schets embedden op CPU kost nog eens 120ms, en dat is de dominante kost. We hebben overwogen om de inference naar ONNX runtime te verplaatsen om dat verder te knijpen, en besloten dat 160ms totaal aanvoelt als instant op een stylus.
Het probleem van week drie
Aan het einde van week drie zag onze interne benchmark er prima uit. We hadden 92% top-5 retrieval op een testset die het bureau had gelabeld. We demoden het. De architecten haatten het.
Het shape-similarity-model deed precies waar we hem voor getraind hadden, en dat bleek het verkeerde te zijn. De directeur schetste een wig met een binnenplaats. Het systeem gaf zes andere wiggen terug. Geen daarvan had een binnenplaats. Het model had geleerd dat het dominante signaal het silhouet was, en de uitsnede voor de binnenplaats, waar het hele punt van de schets om draaide, was een kleine inkeping die de embedding als ruis behandelde.
Wat de architecten eigenlijk wilden, toen ze voorbij de manier dachten waarop ze de briefing hadden geformuleerd, waren programma-vergelijkbare plattegronden. Twee slaapkamers, één badkamer, een keuken open op de woonkamer, een kleine entree, een installatiekern. Vorm was een proxy voor programma, niet het doel.
De 12.000 vector-PDF's met laag-metadata gaven ons een ingang. We parseerden die lagen (grotendeels een XML-walk door een DXF-tussenformaat), bouwden voor elke plattegrond een room-adjacency-graph, embedden de graph met een klein graph neural network, en concateneerden het resultaat met de shape-vector. De twee helften wogen we met een slider die de architect kon verschuiven, met het label 'vorm naar ruimtes'. Default 50/50. De meeste gebruikers zetten hem binnen hun eerste sessie op ongeveer 30/70.
De briefing zei 'zoeken op schets'. De eerlijke briefing was 'zoeken op programma, maar ik schets omdat ik zo denk'. Vind het gat tussen die twee voordat je oplevert.
Wat het kost om te blijven draaien
Het indexeren van alle 40.000 plattegronden duurde 9 uur op één L4-GPU die we voor de eerste build huurden. Re-indexeren van incrementele updates draait 's nachts op de eigen machine van het bureau en is in ongeveer vier minuten klaar voor de 30 tot 60 plattegronden die ze per dag toevoegen.
Storage is klein. Elke plattegrond is een float-vector van 256 dimensies plus metadata: ongeveer 1,5KB per plattegrond, dus onder de 60MB voor het hele archief. De rasterpreviews zijn groter, rond de 180GB, op goedkope object-storage.
Inference op querytijd draait op het bestaande Ryzen-werkstation van het bureau. Ze hebben geen GPU gekocht. Voor nog een ordegrootte aan groei gaan ze er ook geen nodig hebben.
Wat we anders zouden doen
Als we vandaag opnieuw zouden beginnen, sloegen we de OpenCLIP-test over. We wisten dat het zou falen; we deden het toch omdat het bureau erom vroeg. Veertig minuten werk, maar we hadden ze beter kunnen besteden aan de pagina-classifier, want daar kwam het project tekort.
We zouden ook de DINOv2-backbone fine-tunen, niet alleen de adapter head, op een paar duizend plattegronden. De adapter was de snelle weg naar resultaat; volledige fine-tuning had het top-5-cijfer waarschijnlijk boven de 95% op programma's geduwd zonder dat we het graph-stuk nodig hadden. We hebben het niet gedaan, omdat het bureau in zes weken wilde opleveren.
En we zouden de programma-similarity-laag als eerste bouwen, niet als derde. Bijna elke architect met wie we sinds dit project hebben gesproken, bevestigt het: ze denken in ruimtes, niet in silhouetten.
Als je dit op je eigen archief wil proberen
Het kleinste wat je vandaag kunt doen, als je een map met PDF's hebt en het vermoeden dat een image-RAG-laag zou helpen: draai pdftoppm -r 200 -png yourfile.pdf out op een steekproef van vijftig bestanden en kijk hoeveel pagina's per PDF je echt nodig hebt. Als het antwoord 'één of twee van de twaalf' is, heb je hetzelfde pagina-classifier-probleem dat wij hadden, en daar moet je dan eerst budget voor reserveren.
Toen we de plattegrond-zoekfunctie voor dat Rotterdamse bureau bouwden, was het ding waar we tegenaan liepen het gat tussen wat de briefing vroeg en wat het werk eigenlijk nodig had. We losten het op door twee embedding-ruimtes te bouwen en de gebruiker daartussen te laten schuiven. Dat soort probleem, waarbij de specificatie een proxy is voor de echte specificatie, vormt het meeste van wat we tegenkomen als we AI-agents en RAG-systemen bouwen voor bureaus die op een decennium aan opgebouwd werk zitten.
Kern
De briefing zei zoeken op schets. De eerlijke briefing was zoeken op programma. Vind dat gat tussen de specificatie en het werk voordat je oplevert.
FAQ
Waarom faalde CLIP op plattegronden?
CLIP is getraind op image-text paren van het open web, dus zijn visuele vocabulaire is fotografisch. Lijntekeningen van gebouwvoetafdrukken vallen vrijwel volledig buiten die trainingsdistributie, en de nearest neighbors komen terug als pizzapunten en foto's van zwembaden.
Waarom DINOv2 in plaats van CLIP voor lijntekeningen?
DINOv2 is self-supervised getraind op beelden zonder afhankelijk te zijn van captions, dus zijn features reageren meer op structuur en topologie dan op semantische labels. Een kleine adapter head bovenop dicht het resterende gat naar line art.
Heb je een GPU nodig om image RAG op querytijd te draaien?
Niet voor 40.000 vectors. We draaien pgvector met een HNSW-index op een klein Hetzner-bakje; de mediane query is 38ms en de schets-embedding kost nog 120ms op CPU. Een GPU is alleen nodig voor de initiële bulk-index.
Wat is het lastigste onderdeel van image RAG voor PDF's bouwen?
De juiste pagina kiezen. Architectuur-PDF's mixen plattegronden, doorsneden, gevels en titelblokken door elkaar. Zonder pagina-classifier ballonneert de vector store en zakt de retrieval-kwaliteit hard in.
Kun je tegelijk zoeken op vorm en op programma?
Ja. We embedden vorm met DINOv2 plus een adapter, embedden room-adjacency-graphs met een kleine GNN, concateneren de twee, en laten de gebruiker ze wegen met een slider. De meeste architecten komen uit rond 30% vorm, 70% programma.