Databases
Postgres 16 planner-regressie: een RAG-uitval van 11 uur
Dinsdag, 22:47 Amsterdam. PagerDuty gaat af voor een Nederlandse verzekeraar. De RAG-retrieval van hun claims-triage-agent is 50x trager dan een uur geleden.

Dinsdag, 22:47 Amsterdam. PagerDuty gaat af voor de claims-triage-agent van een Nederlandse verzekeraar. De chat draait op een RAG-retrievallaag over twaalf jaar aan dossiers en polis-PDF's. P95-retrievallatentie, normaal 80ms, staat nu op 4,2 seconden. De klantgerichte agent timet uit na 8 seconden, dus elke vijfde conversatie sneuvelt halverwege een zin. Het callcenter in Eindhoven moet handmatig overlopen naar voicemail. We hebben tot de ochtendshift om 09:00 om dit weer onder de seconde te krijgen.
Het symptoom paste in geen enkel patroon uit het runbook
De latentie schoot omhoog om 21:33. Geen deploy. Geen verkeerspiek, dinsdagavond is het rustige venster. Geen GPU-problemen, embedding-generatie draaide nog steeds in 11ms op de inference-box. Geen load balancer-drama. Het enige waar het dashboard het over eens was: het leespad door Postgres was traag.
We begonnen met het voor de hand liggende. Locked iets de tabel? pg_stat_activity toonde geen geblokkeerde queries, geen lange transacties, geen autovacuum die vast zat op de embedding-tabel. CPU stond verhoogd maar niet vastgepind. Disk IO was verdrievoudigd. Een vacuum die zes uur eerder klaar was liet niets vreemds zien in pg_stat_user_tables.
De retrieval van de agent is één query. Het ziet er ongeveer zo uit:
SELECT chunk_id, content
FROM rag_chunks
WHERE tenant_id = $1
AND embedding <=> $2 < 0.3
ORDER BY embedding <=> $2
LIMIT 12;
tenant_id heeft een btree-index. De embedding-kolom heeft een HNSW-index uit pgvector 0.7.4. De query draaide al zes maanden rustig op 80ms. pg_stat_statements wees naar deze query, en alleen deze query, als oorzaak van de load.
De planner was van gedachten veranderd
EXPLAIN (ANALYZE, BUFFERS) liet het binnen dertig seconden zien. De query gebruikte de HNSW-index niet meer. Hij deed een sequential scan over rag_chunks, om daarna te sorteren op afstand. Voor een tabel van veertien miljoen rijen, op een tenant met 280.000 rijen, betekende dat 4,2 seconden pure CPU per call, en de connection pool liep vol.
Dezelfde query. Dezelfde datavorm. Dezelfde tenant. Nieuw plan.
De cost estimate van de planner voor het HNSW-indexpad was verschoven. Niet veel. Net genoeg om hem voorbij de sequential-scan-schatting te tippen. Toen de planner besloot dat seq scan goedkoper was, kwam elke retrieval voor elke tenant uit op hetzelfde verliezende plan. De geschatte row count voor het indexpad was van 12 (logisch, past bij LIMIT) naar 41.000 gegaan (duidelijk fout voor een HNSW-operator), waardoor de cost van 0,42 naar 9.847 sprong.
De kwetsbare combinatie is HNSW plus ORDER BY plus een strakke LIMIT. De cost-functie van de HNSW-operator geeft een heuristiek terug, geen gemeten row estimate, en zodra het vertrouwen van de planner in die heuristiek een fractie zakt, gaat de sequential scan eruitzien als de veiligere keuze. Bij een klein genoeg LIMIT heeft de planner bijna gelijk: een seq scan met een early stop kan goedkoop zijn. Met ORDER BY embedding komt die early stop nooit, omdat de planner elke kandidaat-rij moet materialiseren voor hij kan ranken. Het cost-model weet dat in theorie. In de praktijk bewogen de constanten net genoeg om de gok te verliezen.
Voor achtergrond bij het lezen van een Postgres-plan zijn de officiële EXPLAIN-docs de canonieke referentie. We hadden ze al vaak gelezen. We staarden alsnog twintig minuten naar de verkeerde kolom voordat iemand de BUFFERS-regel zag en vroeg waarom de seq scan 11GB aan het lezen was.
De apt-log vertelde het verhaal
Om 21:31, precies twee minuten voor de latentiepiek, had de database-host unattended-upgrades gedraaid. Postgres 16 was een minor-release omhoog gestapt. pgvector bleef staan.
$ grep postgresql /var/log/apt/history.log
Start-Date: 2026-06-08 21:31:04
Upgrade: postgresql-16:amd64 (16.4-1.pgdg22.04+1, 16.5-1.pgdg22.04+1),
postgresql-client-16:amd64 (16.4-1.pgdg22.04+1, 16.5-1.pgdg22.04+1)
End-Date: 2026-06-08 21:32:11
De minor-versie release notes noemden een aanpassing aan cost estimation voor index scans met ORDER BY plus een strakke LIMIT. De wijziging klopte voor de meeste workloads. Voor de pgvector-operatorfamilie, waar de cost-functie stilletjes leunde op het oudere gedrag, duwde het de geschatte cost boven het sequential-scan-pad. De HNSW-index stond er nog. De planner pakte hem alleen niet meer.
Laat unattended-upgrades nooit zomaar je database-host aanraken zonder een vastgepinde minor-versie. Een planner-wijziging één decimaal diep in een routinematige security release kan zonder waarschuwing je productie-query-plan herschrijven.
Drie opties om 23:30
Tegen middernacht hadden we drie werkbare paden en een deadline die om 09:00 afliep.
Terugrollen naar de vorige minor. De vorige .deb stond nog op de apt-mirror. Een apt-downgrade op een draaiend cluster is niet sierlijk. Het pakket was door de upgrader gemarkeerd als held-forward. In het beste geval 90 minuten plus een restart, met een reëel risico op een gedeeltelijke staat als er om 01:00 iets mis ging.
De planner platwalsen met GUCs. enable_seqscan = off op sessieniveau zou elk ander plan in de database dwingen om seq scans óók te vermijden. De nachtelijke rapportage-endpoints zouden dat niet overleven. Acceptabel als tourniquet, lelijk als oplossing.
Eén query hinten met pg_hint_plan. De extensie pg_hint_plan laat je een comment aan een query hangen die de planner precies vertelt welke scan-, join- en parallelisme-strategie te gebruiken. Het is sinds 9.1 de dragende workaround voor productie-Postgres-teams die tegen planner-regressies aanlopen. Het is ook het dichtste dat Postgres bij Oracle-stijl hints komt, iets dat de community in de core altijd heeft geweigerd.
We kozen de derde optie. Kleinste blast radius, schoonst om later te verwijderen, en het runbook bevatte al twee pg_hint_plan-voorbeelden uit een incident in 2025 op de facturatie-query van een andere klant.
De patch van 3 uur 's nachts
We installeerden pg_hint_plan uit de PGDG-repository, voegden 'm toe aan shared_preload_libraries en herlaadden het cluster. De Python-retrievalcall in de agent-service werd aangepast om een gehinte versie van dezelfde SQL te sturen:
/*+ IndexScan(rag_chunks rag_chunks_embedding_hnsw_idx) */
SELECT chunk_id, content
FROM rag_chunks
WHERE tenant_id = $1
AND embedding <=> $2 < 0.3
ORDER BY embedding <=> $2
LIMIT 12;
De hint zit in het commentaarblok. pg_hint_plan parseert het voor de planner draait en pint de scan-methode voor de genoemde tabel vast op de genoemde index. Al het andere laat hij aan Postgres over.
We rolden eerst naar één read replica uit, draaiden er 200 synthetische retrievals tegenaan en zagen p95 zich vastzetten op 84ms, vier milliseconden boven de lange-termijn-baseline. Om 03:11 promoveerden we de wijziging naar de primary. Om 03:24 stonden de dashboards op groen en zag het callcenter geen time-outs meer. Twee achterlopende read replicas hadden de volgende ochtend dezelfde patch nodig, en daarom liep de incident-timer op tot 11 uur en 9 minuten van eerste alert tot fleet-breed alles veilig.
Wat we de volgende dag aanpasten
De post-incident review was kort. Het meeste werk zat in preventie.
De Postgres-minorversie staat nu vastgepind op elke database-host in de fleet. De allowlist van unattended-upgrades sluit postgresql-* en pgvector expliciet uit. Security-patches voor de database draaien in een doelbewust gepland vrijdagochtendvenster, met een synthetische retrieval-probe die p95 in realtime volgt en een rollback-commando klaar.
De synthetische probe zelf is nieuw. Hij draait elke 60 seconden tegen een representatieve tenant, vuurt dezelfde retrieval af die de agent ook gebruikt, en piept bij p95 boven 500ms gedurende drie aaneengesloten minuten. De probe had de regressie binnen twee minuten na release gespot, in plaats van een uur en veertien minuten later toen het callcenter het door had. We zetten ook een dagelijkse ANALYZE op de embedding-tabel, omdat verouderde statistieken stilletjes meededen aan de slechte row estimate.
De probe is vijftien regels Python, op een cron van één minuut:
import os, time, psycopg
from monitoring import push_metric, pagerduty
QUERY = open('rag_retrieval.sql').read()
EMBEDDING = load_canonical_embedding() # cached, regenerated weekly
TENANT = os.environ['PROBE_TENANT_ID']
t0 = time.monotonic()
with psycopg.connect(os.environ['DSN']) as conn:
conn.execute(QUERY, (TENANT, EMBEDDING)).fetchall()
elapsed_ms = (time.monotonic() - t0) * 1000
push_metric('rag.probe.latency_ms', elapsed_ms)
if elapsed_ms > 500:
pagerduty.trigger('rag-probe-slow', value=elapsed_ms)
Daarna testten we het rollback-pad, voor we het opnieuw nodig hadden. Het team draaide de apt-downgrade op een kopie van het productiecluster, zag de planner binnen één minuut na restart terugschakelen naar het HNSW-pad en klokte de volledige restore op zeven minuten. Het runbook vermeldt nu het exacte apt-commando, de exacte restart-volgorde en de exacte probe-waarde die signaleert dat de rollback gelukt is.
pg_hint_plan staat nu voorgeïnstalleerd op elke Postgres-host die de studio aanraakt. De volgende keer dat we om 03:00 een hint nodig hebben, staat de extensie al geladen in shared_preload_libraries. Het runbook zegt het expliciet: als er een retrieval-latentie-alert afgaat en de apt-log toont een Postgres- of pgvector-wijziging in het laatste uur, draai dan eerst EXPLAIN voor je iets anders doet.
Het gesprek over hints komt eindelijk in core op gang
Postgres weigert al twee decennia uit principe query-hints in core. De officiële wikipagina over het onderwerp stelt, niet zonder grond, dat hints een planner-probleem in een code-onderhoudsprobleem veranderen, en dat de juiste oplossing betere statistieken of een beter schema is. Dat klopt als je tijd hebt. Het is geen antwoord om 03:00 als een Nederlandse verzekeraar voicemails staat te sturen naar mensen die bellen over een ondergelopen kelder.
Die discussie komt eindelijk in beweging. Een Postgres 19 hints-voorstel stond deze week op de voorpagina van Hacker News, wat een nuttig signaal is dat het geduld voor "verbeter je statistieken" ook buiten de hackers-lijst dun aan het worden is. De maintainers van pg_hint_plan dragen dit dossier al sinds 9.1. Hints in core opnemen zou teams laten stoppen met kiezen tussen een shared-preload-extensie en een platwals-GUC, en zou incident-reviewers een gesanctioneerde escape hatch geven in plaats van een afslag via een third-party repository.
Als Postgres 19 eersteklas hints meebrengt, wordt ons runbook één regel korter. Tot die tijd blijft pg_hint_plan in productie, en blijft het hint-commentaar in onze retrieval-laag staan waar het staat.
Het kleinste wat je kunt opleveren voor de volgende apt-log-verrassing
Toen we die retrieval-laag voor de claims van de verzekeraar bouwden, ging de les die we in de postmortem schreven minder over de specifieke regressie en meer over de failure mode. Een werkende query die stilletjes niet meer geïndexeerd wordt is een van de moeilijkste dingen om op te monitoren, want niets gaat kapot. Het wordt alleen traag. Als je een Postgres-gebaseerd RAG-systeem draait, schrijf vandaag die synthetische latentieprobe, richt 'm op één representatieve tenant en piep bij p95 boven 500ms. Vijf minuten werk, één query, één alert. Dat is de goedkoopste verzekering tegen de volgende minor release die besluit dat je HNSW-index het niet meer waard is om te gebruiken.
Kern
Een RAG-query die stilletjes zijn HNSW-index niet meer gebruikt is de failure mode die je niet ziet. Lever een synthetische latency-probe op vóór de volgende minor release.
FAQ
Wat is pg_hint_plan en is het veilig om in productie te draaien?
Het is een Postgres-extensie waarmee je scan-, join- en parallelisme-methoden per query vastpint via SQL-commentaar. Hij draait in productie sinds Postgres 9.1 en zit in de PGDG-repos. Veilig om te gebruiken, maar de hints zijn schuld: review ze bij elke major upgrade.
Waarom zou een minor Postgres-upgrade een queryplan veranderen?
Minor releases passen af en toe cost estimation, statistiek-afhandeling of operator-class-interne zaken aan. De meeste queries merken er niets van. Edge cases zoals pgvector HNSW-scans met ORDER BY en een strakke LIMIT kunnen omklappen naar een slechter plan als de cost-balans een klein beetje beweegt.
Hoe voorkom je dat unattended-upgrades je database sloopt?
Pin de Postgres- en pgvector-pakketten expliciet, zet ze op de blocklist van unattended-upgrades, en patch de databaselaag in een gepland venster met een synthetische latency-probe en een geteste rollback klaar voor je begint.
Had een synthetische probe dit eerder kunnen vangen?
Ja. Een probe van 60 seconden die de echte retrieval-query afvuurt en piept bij p95 boven 500ms had binnen twee minuten na de apt-upgrade gealarmeerd, in plaats van ruim een uur later toen het callcenter de time-outs door had.