RAG
pgvector REINDEX sloopte onze RAG: een incident-rapport
Op vrijdag om 14:21 draaide een junior engineer REINDEX op een vector store. Om 15:07 citeerde de RAG-agent een artikel uit NEN-EN-IEC 60601-1 dat niet bestaat.

De melding kwam op vrijdagmiddag om 15:07 binnen in Eindhoven. Een medtech SaaS van 31 personen waar wij voor werken, had een klinische klant aan de lijn. Die las hardop voor uit het antwoord van hun RAG-agent. De agent had NEN-EN-IEC 60601-1, artikel 11.6.7 aangehaald als bron voor een hervalidatie-interval voor sterilisatie. Dat artikel bestaat niet. De compliance lead aan de andere kant van de lijn had de norm open op haar bureau. Hoofdstuk 11.6 stopt bij 11.6.5.
We beginnen daar, omdat de fout niet 'het model liegt' was. Het model haalde de verkeerde chunks op en fantaseerde eromheen. Tegen de tijd dat we de CTO van de klant aan de bridge hadden, had dezelfde agent nog twee fictieve subartikelen aan twee andere tenants geserveerd. De gemene deler was één git-commit, om 14:18 gepusht door een junior engineer, met als bericht reindex pgvector for the weekend warmup.
Dit is het verslag van wat we vonden, en het viertraps recall-rig dat we nu draaien voordat een retrieval-laag live gaat bij een klinische tenant.
Wat we in de logs zagen
De agent zelf zag er op elk dashboard gezond uit. Elke endpoint gaf 200 terug. Tokengebruik was vlak. De retriever leverde de gevraagde top_k = 6 chunks per query, met cosine-afstanden die op het eerste gezicht normaal leken. Geen van de duidelijke alarmen ging af.
Wat opviel, was één regel in postgres.log van 14:21:
LOG: duration: 42087.114 ms statement: CREATE INDEX docs_embedding_hnsw_idx
ON docs USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 16);
Hun productie-rebuild duurt op deze dataset normaal ongeveer achttien minuten. Tweeënveertig seconden was het eerste waar we uitleg over wilden.
HNSW-parameters in het kort
De HNSW-index van pgvector heeft twee build-time knoppen: m, het aantal bidirectionele links per node, en ef_construction, de grootte van de kandidatenlijst die het bouwproces doorzoekt bij het inhaken van elke nieuwe node in de graaf. Een hogere ef_construction betekent een dichtere, accuratere graaf en een veel tragere build. De README van pgvector legt de trade-off uit en geeft de projectdefaults (m = 16, ef_construction = 64).
Dit team had bewuste keuzes gemaakt. Hun productie-index was gebouwd met ef_construction = 200, omdat hun klinische content dicht en technisch is, en op heel specifieke manieren bevraagd wordt. Ze hadden welbewust voor recall boven build-tijd gekozen, na eigen benchmarks. De herbouwde index had ef_construction = 16. Een graaf die een ordegrootte ijler is, in dezelfde tabel, onder dezelfde naam, voor elke tenant.
De REINDEX die geen REINDEX was
De commit van de junior engineer draaide één commando vanuit de root van de repo:
make reindex-vectors
Het Makefile-target was zes maanden eerder toegevoegd door een engineer die inmiddels vertrokken is. Het ziet er zo uit:
reindex-vectors:
psql $(DATABASE_URL) -f db/migrations/reindex_pgvector.sql
En reindex_pgvector.sql is geen REINDEX. Een echte REINDEX zou de bestaande indexdefinitie behouden. Het script is een DROP INDEX gevolgd door een CREATE INDEX, met parameters letterlijk overgenomen uit db/seed/local_pgvector.sql. De auteur van de seed-file had ef_construction = 16 gezet zodat hun docker-compose op een laptop binnen een minuut omhoog kwam. Niemand had dat ooit herzien. De junior engineer vertrouwde, redelijkerwijs, de naam van het target.
Is je 'routine' reindex-script ouder dan je retrieval-team, lees dan de SQL die het daadwerkelijk draait voordat je het op productie loslaat. Het snelle pad is zelden het recall-pad, en de bestandsnaam is geen contract.
Stille instorting, geen errors
Niets crashte. Postgres gaf geen waarschuwing. pgvector gaf geen waarschuwing. De integratie-suite in CI ging groen, want de smoke tests vragen dingen als 'wat is de dosering paracetamol voor volwassenen', en die antwoord-chunks scoren ook in een ijle graaf hoog. De schade zat in de long tail.
Klinische vragen zijn precies. Een query als 'wat is het maximale hertest-interval voor de doorslagspanningstest in artikel 8.8.3' landt in een gebied van de vectorruimte waar dichte graaflinks nodig zijn om bij de juiste chunk te komen. Met ef_construction = 16 stuurde de graaf die queries naar aangrenzende chunks: isolatietests, taalgebruik over elektrische veiligheid, het juiste document en de verkeerde sectie. De opgehaalde context was plausibel, maar bevatte het artikelnummer dat de gebruiker vroeg niet.
Het model deed vervolgens wat modellen doen bij dunne context. Het produceerde een plausibel ogend artikelnummer in hetzelfde nummeringschema als de omliggende chunks. Top-modellen hallucineren minder vaak dan kleinere, maar ze hallucineren ook overtuigender. Tegen de tijd dat iemand het doorheeft, zit je klant aan de telefoon met een ziekenhuis dat het nummer voorleest.
Dit is precies het soort fout dat health checks met een 200-response moeten missen. Latency was zelfs beter, niet slechter: een ijlere HNSW-graaf doorloop je sneller. Throughput was identiek. De retriever gaf elke keer de gevraagde zes chunks terug, met cosine-afstanden binnen de gebruikelijke band. De enige metric die had moeten gillen, was recall at rank, en niets in de stack mat dat.
Het viertraps recall-rig
Nadat we het incident gestabiliseerd hadden, terug waren gerold naar de vorige index vanuit een logical backup, opnieuw hadden gebouwd met de juiste parameters via CREATE INDEX CONCURRENTLY, en de getroffen tenants schriftelijk hadden verteld wat ze gezien hadden en waarom, schreven we een recall-rig op. We draaien het nu voordat een retrieval-laag live gaat voor een klinische tenant, en bij elke wijziging aan een index, embedding-model of chunking-strategie daarna.
1. Een bevroren golden set per tenant, met de hand geschreven
Tweehonderd echte vragen per tenant, elk met het ground-truth document-ID en het ground-truth chunk-ID erbij. Die genereren we niet automatisch. Een domein-expert van de klant gaat twee middagen met een van onze engineers zitten en schrijft ze uit. De set gaat in de config van de tenant, krijgt een checksum, en alleen de domein-eigenaar kan hem wijzigen. Auto-gegenereerde golden sets verbloemen precies het soort long-tail blinde vlek dat deze klant onderuit haalde.
2. Recall@k en MRR meten voor en na elke wijziging
De getallen zijn bot, en dat bevalt ons. Het rig draait elke keer tegen dezelfde golden set, en de diff is wat telt.
def measure_recall(golden_set, retriever, k=5):
hits = 0
mrr_sum = 0.0
for q in golden_set:
ranked = retriever.search(q.text, k=k)
chunk_ids = [r.chunk_id for r in ranked]
if q.gold_chunk_id in chunk_ids:
hits += 1
rank = chunk_ids.index(q.gold_chunk_id) + 1
mrr_sum += 1.0 / rank
return {
"recall_at_k": hits / len(golden_set),
"mrr": mrr_sum / len(golden_set),
}
We laten de deploy falen als recall@5 met meer dan één procentpunt zakt, of als MRR met meer dan 0,03 zakt ten opzichte van de laatste groene run. De build die het incident veroorzaakte, was hier moeiteloos op gesneuveld: op de klinische golden set zakte recall@5 ruwweg veertien punten en stortte MRR in van 0,71 naar 0,49.
3. Synthetische distributie-dekking
De golden set is de bodem, niet het plafond. Daar bovenop genereren we een synthetische queryset uit de brondocumenten zelf: vragen op zes manieren per chunk geformuleerd, waaronder formeel, informeel, afgekort, in het Nederlands waar het corpus Nederlandse bronnen heeft, met een bewuste typfout, en zoals een arts ze op maandagochtend daadwerkelijk zou stellen. De synthetische set vangt regressies op formuleringen waar de menselijke auteur niet aan dacht. Hij vervangt de golden set niet. Hij verbreedt het net, zodat een wijziging die het gemiddelde verbetert zonder het slechtste geval te verbeteren, nog steeds zichtbaar blijft.
4. Drift-alarm op productie
Een nachtelijke job sampelt vijftig echte productiequeries (PII verwijderd), draait ze opnieuw tegen de huidige index én tegen een bevroren snapshot van de index van vorige week, en berekent de overlap van de top-drie chunk-IDs per query. Zakt de gemiddelde overlap onder de 70 procent zonder bijbehorende bewuste wijziging in het deploylog, dan worden we gepiept. Dit is de stap die het incident binnen hetzelfde uur had opgevangen als het rig al had bestaan. Het drift-alarm dicht het gat tussen 'we testen voor de deploy' en 'iemand kan de index buiten de band om wijzigen'.
We houden de index van vorige week in een aparte Postgres-schema, elke zondag tijdens het onderhoudsvenster ververst. De opslagkosten zijn één extra index plus zijn WAL-voetafdruk, wat voor deze klant op zo'n 1,4 GB komt. Goedkope verzekering tegen de volgende out-of-band wijziging, en een nuttig artefact om tegenaan te diffen als een tenant zegt dat 'de antwoorden deze week anders aanvoelen'.
Wat we aan hun proces veranderden, niet alleen aan hun code
De technische fixes waren saai. Hernoem het Makefile-target zodat het de waarheid vertelt. Zet er een bevestigingsprompt voor. Verplaats de losse parameters van de seed-file naar een omgevingsspecifieke variabele, zodat productie en lokaal ze niet per ongeluk delen. Voeg het recall-rig toe aan CI. Geen van die dingen is een verhaal.
De proceswijziging is de wijziging waar we bij andere klanten op terug blijven komen. Dit team had een release-checklist voor de applicatie en een aparte release-checklist voor de database. Geen van beide noemde de retrieval-laag. De vector-index zat in een dode zone tussen 'infra' en 'model', en niemand had de kwaliteitslat in eigendom. De on-call engineer voor de database was niet de on-call engineer voor de agent.
We voegden één eigenaar toe, één dashboard met recall@5 en MRR per tenant, en een regel die we sindsdien naar elk ander RAG-traject hebben gekopieerd: elke wijziging die de retrieval-laag raakt gaat live achter het rig, of hij gaat niet live. Embedding-model upgrades. Wijzigingen aan chunkgrootte. Index-rebuilds. Alles. Het rig vangt het soort stille fout dat health checks met een 200-response niet kunnen zien.
Die ene eigenaar is geen functietitel. Het is een naam in een wiki naast het dashboard, met dezelfde persoon on-call voor retrieval-drift als voor de agent die hem gebruikt. Alleen dat dichtte het gat waar we om 15:07 op een vrijdag in terechtkwamen: de database-engineer die de commit pushte en de agent-engineer die het klantgesprek kreeg, wezen eindelijk naar hetzelfde dashboard.
Toen we de RAG-agents voor de klinische productlijn van deze klant bouwden, was het ding waar we steeds tegenaan liepen precies dit patroon van stille degradatie, vermomd als routinematige database-operatie. We hebben het uiteindelijk opgelost door recall-meting op dezelfde release-rail te zetten als code coverage: niet optioneel, niet adviserend, gegated.
Het kleinste wat je vandaag kunt doen, voordat je dit tabblad sluit: open het script dat jouw team 'reindex' noemt en lees de SQL die het daadwerkelijk draait. Doet het iets anders dan REINDEX, schrijf de parameters op en check ze tegen de indexdefinitie in productie. Vijf minuten, en je weet of dezelfde verborgen migratie ook in jouw repo zit.
Kern
De gezondheid van een vector-index is een release-gate kwestie. Kan een routine REINDEX je recall stilletjes halveren, dan verdient je retrieval-laag dezelfde test-discipline als je applicatiecode.
FAQ
Veranderen mijn HNSW-parameters door een normale pgvector REINDEX?
Nee. Een echte REINDEX behoudt de indexdefinitie. Zijn je parameters na een 'reindex' veranderd, dan was het script dat je draaide vrijwel zeker een DROP INDEX gevolgd door een CREATE INDEX met andere WITH-clauses.
Wat is een redelijke ef_construction voor HNSW in productie met pgvector?
De default van pgvector is 64. Teams met dichte, technische corpora en precieze queries zetten 'm vaak op 200 of hoger en ruilen build-tijd voor recall. Meet altijd op je eigen golden set voordat je 'm vastlegt.
Hoe meten we RAG-recall zonder een domein-expert die data kan labelen?
Je kunt beginnen met synthetische golden sets uit je eigen chunks, maar die onderschatten long-tail fouten. Voor alles wat gereguleerd of veiligheidsrelevant is, betaal je een domein-expert voor twee middagen vraagschrijven per tenant.
Waarom hallucineert een frontier-model toch als retrieval mist?
Bij semi-relevante context vult het model het patroon van de omringende chunks aan. Zijn de chunks subartikelen van een norm, dan is de hallucinatie ook een plausibel subartikelnummer. Betere retrieval is de enige oplossing.
Hoe vaak draaien we het recall-rig opnieuw in productie?
Altijd voor elke deploy van de retrieval-laag. Nachtelijke drift-checks op een sample van productiequeries. Volledige golden-set replays minstens wekelijks, en direct na elke database-operatie die een vector-index raakt.