Databases
Postgres voor AI-agents: elf patronen die we altijd shippen
Een routine VACUUM FULL liet onze chat-agent een verwijderd beleid citeren. De fix was klein. De les: elf Postgres-patronen die we nu op elke agent-build inzetten.

Het is 16:47 op een vrijdag. Sales heeft 38 GB teruggewonnen op de productiedatabase door VACUUM FULL te draaien op de embeddings-tabel. Vijf minuten later antwoordt de support-agent een betalende klant met een chunk uit een beleidsdocument dat we twee maanden geleden hebben verwijderd. De retrieval is snel. De retrieval is fout. Niemand ziet waarom.
De chunk-tekst renderde netjes omdat de agent geen manier had om te weten dat de onderliggende rij niet meer bestond in ons system of record. De HNSW-index in pgvector wees nog steeds naar tuple-identifiers die VACUUM FULL stilletjes had omgezet tijdens de rewrite. De query planner vertrouwde de index. De agent vertrouwde de planner. De klant vertrouwde de agent.
We hebben het weekend besteed aan opschrijven wat we vrijdagmiddag hadden willen weten. De post-mortem duurde twee uur. We logden de failure mode, het herstel en de rebuild, en daarna deden we het moeilijkere: we maakten een lijst van elke Postgres-gewoonte die we hadden opgepikt op de agent-projecten die we hebben opgeleverd, en rangschikten ze op hoe snel een DBA ze kon uitrollen zonder model-gerelateerde paniek. Dit is die lijst. Elf patronen, gerangschikt op wat een DBA die nog nooit een model-dashboard heeft geopend, op een dinsdagmiddag kan shippen.
Wat VACUUM FULL deed met de vector-index
Een gewone VACUUM in Postgres markeert dode tuples als herbruikbare ruimte binnen hetzelfde fysieke bestand. Een VACUUM FULL is een ander beest: hij neemt een ACCESS EXCLUSIVE lock, herschrijft de hele tabel naar een nieuw bestand en bouwt elke index opnieuw op vanaf nul. Voor een B-tree op een gewone kolom is dat vervelend maar veilig. Voor een HNSW-index bovenop pgvector duurt de rebuild minuten, en de graph-constructie is niet-deterministisch op duplicaten dicht bij de decision boundary.
Die mismatch is genoeg om een agent die de top drie chunks ophaalt een resultaat te laten tonen dat je echt niet meer hebt. Het pgvector-project documenteert de rebuild-semantiek terloops, maar de meeste ops-teams komen er op dezelfde manier achter als wij: via een klant-ticket. Je kunt (terecht) beweren dat dit meer een pgvector-implementatiedetail is dan een Postgres-eigenschap. Klopt. Het is ook irrelevant als een klant in de wacht staat. De patronen in de rest van deze guide gaan ervan uit dat alles je uiteindelijk gaat verrassen, en dat de database de enige laag in je stack is die zijn vorm behoudt onder druk in promptvorm.
Patronen één tot vijf, voor de lunch live
1. Een statement timeout op de agent-rol
Geef de agent zijn eigen Postgres-rol en cap elke statement op een waarde die je in een review kunt verdedigen. Tweeënhalve seconde is onze default. Wat meer tijd nodig heeft, hoort een job te zijn, geen interactieve query.
CREATE ROLE agent_runner LOGIN PASSWORD :'pw';
ALTER ROLE agent_runner SET statement_timeout = '2500ms';
ALTER ROLE agent_runner SET idle_in_transaction_session_timeout = '5s';
ALTER ROLE agent_runner SET lock_timeout = '500ms';
Deze ene wijziging beschermt je tegen de failure mode waarbij een agent een zware query in een loop draait en een rekening opbouwt die niemand in de gaten houdt. Een agent zonder afgedwongen plafond vindt het duurste in je systeem en blijft het herhalen. De database is de juiste plek om die grens te trekken, want de database is het enige in je stack waar de agent niet omheen kan liegen.
2. Idempotency-keys op elke write
Agents retryen. Tool calls retryen. Webhook-afleveringen retryen. De goedkoopste verdediging is een unique index op een client-generated key en een ON CONFLICT DO NOTHING op de insert.
CREATE UNIQUE INDEX agent_runs_idem
ON agent_runs (tenant_id, idempotency_key);
INSERT INTO agent_runs (tenant_id, idempotency_key, payload)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING id;
Als RETURNING leeg is, heeft de agent die rij al weggeschreven. Lees 'm terug en ga door. Je zult verbaasd zijn hoe vaak dit afgaat zodra je gaat tellen, vooral op maandag als de model-provider in het weekend even hapert.
3. SKIP LOCKED voor de work queue
De meeste teams pakken Redis of SQS zodra een agent background werk nodig heeft. Dat hoeft niet. Een queue-tabel met FOR UPDATE SKIP LOCKED handelt duizenden jobs per seconde af op een kleine Postgres-instance, en je houdt transactionele consistentie met de rest van je agent-state.
SELECT id, payload
FROM agent_jobs
WHERE status = 'queued'
ORDER BY priority DESC, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;
Het patroon is ouder dan de meeste agent-stacks en bijna gênant betrouwbaar. Als je eroverheen groeit, merk je dat vanzelf.
4. JSONB met jsonb_path_ops
Tool-call argumenten en agent-traces willen in jsonb wonen. De standaard GIN operator class indexeert elke value en is langzamer dan nodig. Voor de queries die een agent-app daadwerkelijk draait (containment en key existence) is jsonb_path_ops ongeveer de helft kleiner en merkbaar sneller.
CREATE INDEX tool_calls_args_gin
ON tool_calls USING gin (args jsonb_path_ops);
5. LISTEN en NOTIFY voor goedkope fan-out
Als een agent een lange taak afrondt, moet je meestal ergens een websocket wakker maken. NOTIFY is het simpelste gereedschap in de doos. De payload is gecapt op 8000 bytes en aflevering is at-most-once, precies het juiste contract voor 'render de conversatie opnieuw'.
NOTIFY agent_events, 'thread:7d3a:complete';
Heb je exactly-once delivery nodig, gebruik dan geen NOTIFY; schrijf naar een outbox-tabel en laat de worker de outbox pollen. De meeste agent-UI updates hebben geen exactly-once nodig. De meeste teams overengineeren dit.
Patronen zes tot acht, klaar voor de standup van morgen
6. Partitioneer de run log per maand
Agent run-logs groeien sneller dan elke andere tabel in het systeem. Na drie maanden krijg je geen simpele count meer zonder sequential scan en een verpeste middag. Range-partitioneer op created_at en laat de planner snoeien.
CREATE TABLE agent_runs (
id bigserial,
created_at timestamptz NOT NULL,
tenant_id uuid NOT NULL,
payload jsonb NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE agent_runs_2026_06 PARTITION OF agent_runs
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
Combineer dit met een nightly job die partities ouder dan je retentievenster dropt. Eerst detachen, dan droppen; de officiële Postgres-documentatie legt uit waarom de volgorde uitmaakt.
7. pg_stat_statements met een budget per rol
Zodra je pg_stat_statements aanzet, wordt binnen een dag duidelijk wat de zwaarste query van je agent is. Bouw een intern dashboard dat queries rangschikt op total_exec_time, gegroepeerd op de rol die ze draaide. De agent die SELECT * FROM messages WHERE thread_id = ? driehonderd keer per gesprek aanroept, komt vanzelf boven, net als degene die de volledige embedding-kolom ophaalt terwijl alleen de metadata nodig is. De eerste keer dat je sorteert op total_exec_time vind je een query die je tien minuten geleden zelf hebt geschreven. Fix die eerst.
8. HNSW met discipline rond REINDEX
Als je één ding onthoudt van dit stuk, onthoud dan dit. Draai nooit VACUUM FULL op een tabel met een pgvector-index tenzij je direct daarna klaarstaat met een REINDEX, en de rebuild verifieert met een echte query. Beter nog: draai VACUUM FULL helemaal niet. Voor de zeldzame keren dat je echt fysieke ruimte moet terugwinnen, gebruik pg_repack, dat tabellen herbouwt zonder de access-exclusive lock te nemen.
CREATE INDEX CONCURRENTLY chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- After any bulk delete or update:
REINDEX INDEX CONCURRENTLY chunks_embedding_hnsw;
Een pgvector-index die naar verouderde tuple-identifiers wijst, geeft plausibele antwoorden terug. Plausibele antwoorden zijn erger dan errors, omdat ze klanten bereiken voordat ze jou bereiken.
Patronen negen tot elf, een plansessie waard
9. Tenant-scoped row-level security
Multi-tenant agent-apps hebben een terugkerende nachtmerrie: een prompt injection praat de agent zo gek dat hij data van een andere tenant leest. Belt-and-braces betekent tenant-isolatie in de SQL-laag, niet alleen in de applicatie. Zelfs als een model wordt overgehaald om de verkeerde rijen op te vragen, weigert de database.
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY messages_tenant ON messages
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Set de session-variabele aan het begin van elke request. Als de agent ooit een query stuurt onder de verkeerde tenant-context, geeft Postgres niets terug, ongeacht wat het model denkt dat het doet. Row-level security heeft een leercurve. De kosten zitten vooral aan de voorkant, en de winst incasseer je in de meetings waar je na een lek niet bij hoeft te zijn.
10. Logical replication voor de embedding-worker
Elke keer dat iemand een document bewerkt duizend chunks opnieuw embedden is verspilling. Stream wijzigingen van de bron-tabel via een publication, en laat een worker de change stream consumeren en de vectors out of band updaten. De agent leest uit de gematerialiseerde tabel en blokkeert nooit op een rondreisje naar de model-provider.
De eerste keer dat we dit live zetten, daalde de p95 retrieval-latency van 740 ms naar 90 ms tijdens kantooruren, omdat het read-pad van de agent niet meer concurreerde met de embedding-worker om CPU. Gebruik de pgoutput-plugin en één publication per consumer. wal_level moet op logical staan, wat een postgresql.conf-wijziging plus restart vereist, dus plan 'm tijdens het rustige lunchblok.
11. Een canary-chunk in elke index
Dit patroon danken we aan het vrijdagincident. Elke embeddings-tabel bevat nu één bewuste chunk met een bekende zin, bijvoorbeeld 'de canary-zin voor index-gezondheid is de kleur koper'. Een cronjob draait de canary-query elke minuut en checkt of de top hit ook echt de canary is. Zo niet, dan pieper iemand.
De canary werkt omdat het een echte rij is, op dezelfde manier geïndexeerd als de rest van de corpus. Een synthetische health-check die langs de index ging, had onze VACUUM FULL bug niet gepakt. Dit is de goedkoopst denkbare test dat de index teruggeeft wat de tabel bevat. Wij hadden 'm niet. Nu wel.
Wat je vandaag doet
Open psql, connect als de user die je agent gebruikt, en draai SHOW statement_timeout. Als het antwoord 0 is, ben je één weggelopen tool call verwijderd van een rekening waar niemand om vroeg. Zet 'm op twee seconden. Herstart de agent. De rest van de lijst kan wachten tot dinsdagmiddag. Je hoeft de elf patronen niet binnen een kwartaal te shippen. Je moet de eerste vijf deze week shippen. De rest wordt vanzelf duidelijk zodra je stopt met brandblussen en weer gaat slapen.
Toen we vorig kwartaal de retrieval-laag herbouwden voor een Nederlandse e-commerce klant, ving het canary-patroon een misgeconfigureerde HNSW-rebuild op voordat één klant een fout antwoord zag. Als iets hiervan bekend voorkomt: ons team bouwt AI-agents op precies deze stack, en het runbook is al geschreven.
Kern
Ship deze week de vijf snelle Postgres-patronen (timeout, idempotency, SKIP LOCKED, GIN, NOTIFY); de zwaardere zes worden makkelijker zodra je stopt met brandblussen.
FAQ
Waarom is VACUUM FULL gevaarlijk voor pgvector-indexen?
VACUUM FULL herschrijft de tabel en bouwt elke index opnieuw. De HNSW graph-constructie is niet-deterministisch op bijna-duplicaten, dus de herbouwde index kan verouderde of verwijderde chunks boven levende rangschikken totdat je REINDEX draait en het verifieert.
Kan ik IVFFlat gebruiken in plaats van HNSW om dit te vermijden?
IVFFlat is goedkoper om te bouwen maar degradeert bij inserts en moet periodiek opnieuw opgebouwd worden om accuraat te blijven. HNSW is de betere default voor agent-retrieval. De discipline rond REINDEX blijft staan.
Heb ik row-level security nodig als mijn app al filtert op tenant?
Ja. Applicatiefilters beschermen je tegen bugs die jij schreef. Row-level security beschermt je tegen prompt injections die je agent zo ver krijgen dat hij andere SQL schrijft. Ze dekken verschillende dreigingen.
Hoe klein mag de canary-chunk zijn?
Eén rij met een unieke zin en hetzelfde embedding-model als de rest van de corpus. De crux is dat hij hetzelfde codepad volgt als een echte query, zodat elke breuk in het index-pad 'm vangt.