← Blog

Databases

Postgres deletes onder AI: 17 manieren waarop tabellen sterven

Een delete-zware embeddings-tabel, een AI-agent die stilletjes trager wordt en één rij in pg_stat_user_tables die alles verklaart. Zeventien faalmodi, gerangschikt.

Jacob Molkenboer· Oprichter · A Brand New Company· 25 aug 2025· 8 min
Open eiken kaartenlade met vergeeld grootboekpapier, messing klem, groen lint en rood waszegel op ivoor papier.

Het is 02:00 in Amsterdam. Het on-call dashboard zegt dat de p95-latency van je retrieval-agent in één nacht van 280ms naar 4,1 seconden gewandeld is. Recall op de offline eval-set zakte vier punten. De Postgres-machine draait op 18% CPU, dus het is niet "de database staat onder druk". Je SSH't in, draait één query tegen pg_stat_user_tables, en de agent_memories-rij staart je aan met 240 miljoen dode tuples bovenop 38 miljoen levende.

Dit is de vorm van de bug. Een AI-agent die retrieval-augmented werk doet, is een delete-zwaar systeem. Je schrijft embeddings weg, je TTL't ze eruit, je her-embed wanneer het model verandert, je dedupliceert bijna-identieke chunks, je garbage-collect verouderde gesprekscontext. Postgres handelt deletes af, maar op zijn eigen voorwaarden, en een vector-tabel maakt die voorwaarden harder dan de documentatie laat doorschemeren.

Vector-tabellen belasten Postgres anders

Drie dingen maken een vector-search-tabel anders dan een normale CRUD-tabel onder zware deletes.

Ten eerste de vector-kolom zelf. Een 1536-dimensionaal float-array is ongeveer 6 KB. Dat zit ruim boven de 2 KB-drempel waarop Postgres de kolom uitduwt naar een TOAST-tabel. Elke embedding-rij is in werkelijkheid twee rijen in twee tabellen, en elke delete laat dode tuples achter in beide.

Ten tweede de indexen. HOT updates, de goedkope in-place truc die normale tabellen tegen bloat beschermt, vuren alleen als er geen geïndexeerde kolom verandert. Een vector-index bedekt elke rij, dus HOT staat effectief uit. Erger nog: de HNSW- en IVFFlat-indexen van pgvector geven ruimte niet terug zoals B-tree dat doet. HNSW markeert verwijderde nodes als tombstones en laat ze in de graph staan tot een volledige rebuild.

Ten derde het workload-patroon. Agents schrijven meestal in bursts (een her-embedding-job, een nachtelijke dedupe-pass) en verwijderen in bursts. Autovacuum is afgestemd op een rustige druppel. Een delete van een miljoen rijen die in 90 seconden klaar is, blijft uren op de tabel staan voordat autovacuum erbij komt met default-instellingen.

Zeven fouten die je vanavond ziet in pg_stat_user_tables

Dit zijn de goedkope. Eén query, één screenshot, en je weet of je een probleem hebt. De monitoring views staan al aan; je hoeft ze alleen te lezen.

SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
  n_mod_since_analyze,
  last_autovacuum,
  autovacuum_count,
  n_tup_hot_upd,
  seq_scan,
  idx_scan
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC
LIMIT 10;

De zeven dingen die je uit die kolommen leest:

  1. Dead-tuple ratio boven 20%. Een vector-tabel die voor 20% dood is, verliest al planner-accuratesse. Boven 50% betaal je voor een index die niet meer in cache past.
  2. last_autovacuum uren achter op n_mod_since_analyze. Autovacuum is wakker, maar verliest de race. De default autovacuum_vacuum_cost_limit van 200 is geschreven voor draaiende schijven. Op NVMe hoort die op 2000 of hoger.
  3. autovacuum_count rond nul op een hete tabel. De cost limit wordt elke pass geraakt en de worker breekt af voordat hij klaar is. pg_stat_progress_vacuum laat er één zien die uren loopt.
  4. n_tup_hot_upd op nul. Bevestigt dat de vector-index HOT blokkeert. Niet op te lossen zonder de index te droppen, maar nuttig om te weten: elke UPDATE op deze tabel betaalt volle bloat-belasting.
  5. seq_scan loopt op bij een geïndexeerde tabel. Planner-cardinaliteit is verouderd. reltuples in pg_class liegt nu over het aantal rijen, en de planner denkt dat een sequential scan goedkoper is dan de HNSW-lookup.
  6. n_ins_since_vacuum loopt parallel aan n_dead_tup. Klassiek insert-dan-delete-patroon. Vaak een dedupe-job die schrijft en direct daarna bijna-duplicaten verwijdert. De moeite waard om eerst in de agent-code op te lossen, voordat je de database gaat tunen.
  7. idx_scan blijft vlak terwijl seq_scan stijgt. De vector-index is door de planner gedegradeerd. Recall is nu wat je LIMIT toevallig oppikt uit een sequential scan, en dat is niet wat in je eval-doc staat.

Zes fouten die één query dieper verstopt zitten

Deze zie je niet in pg_stat_user_tables. Je hebt pg_stat_activity, pgstattuple of de WAL-directory zelf nodig.

  1. TOAST-bloat. De vector-kolom is ge-TOAST'd. Draai SELECT pg_size_pretty(pg_relation_size(reltoastrelid)) FROM pg_class WHERE relname = 'agent_memories';. Als de TOAST-tabel groter is dan de main heap, zit je bloat dáár, niet waar je keek.
  2. Een vastzittende xmin-horizon. Eén langlopende transactie, een verlaten replication slot of een vergeten prepared transaction pint de xmin vast en houdt VACUUM tegen om dode tuples te verwijderen die nieuwer zijn dan die snapshot. SELECT pid, backend_xmin, state, query_start FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY backend_xmin LIMIT 5; vindt de schuldige.
  3. WAL-volume piekt. Elke dode tuple is een WAL-record. Een delete-zware workload kan je WAL-throughput verdubbelen en replicatie laten haperen. Check pg_stat_wal en de grootte van de pg_wal/-directory.
  4. Visibility map churn. Index-only scans hangen af van de visibility map. Zware deletes draaien de all-visible-bit uit op pagina's die continu herschreven worden, en de planner zet het snelle pad zonder waarschuwing uit.
  5. FSM-tracking klopt niet meer. De free space map denkt dat pagina's ruimte hebben die ze niet hebben. Nieuwe inserts gaan de relatie uitbreiden in plaats van vrijgekomen ruimte hergebruiken, en de tabel groeit op disk terwijl het bloat-percentage gelijk blijft.
  6. Checkpoint-storms. pg_stat_bgwriter laat checkpoints_req sneller stijgen dan checkpoints_timed. Delete-batches duwen de WAL voorbij max_wal_size en forceren checkpoints die met de queries van de agent vechten om I/O.

Vier fouten die alleen DROP TABLE oplost

Er bestaat een community-spreuk onder Postgres-operators: op een bepaalde schaal is de enige DELETE die in redelijke tijd klaar is, een DROP TABLE. De framing is met opzet scherp, maar voor de vier fouten hieronder klopt het ook letterlijk.

  1. HNSW-graph-degradatie. Verwijderde vectoren worden getombstoned, niet ontkoppeld. De graph loopt er bij elke search nog steeds doorheen. Na een paar miljoen tombstones daalt recall stilletjes en stijgt latency. REINDEX CONCURRENTLY op een tabel van een miljard rijen is een operatie van meerdere uren die de agent al die tijd uithongert.
  2. IVFFlat centroid drift. Je lists zijn berekend tegen de data-distributie van zes maanden geleden. Deletes en re-embeds hebben de centroids verschoven. Kwaliteit zakt eerst geleidelijk en valt dan van de rand af zodra een list leegloopt. Geen VACUUM-instelling repareert een centroid.
  3. Heap-bloat voorbij 70%. VACUUM FULL heeft een ACCESS EXCLUSIVE-lock nodig voor de hele rewrite. Op een tabel van honderd gigabyte is dat uren downtime. pg_repack vermijdt de lock, maar heeft vrije diskruimte nodig ter grootte van de tabel zelf en concurreert de hele tijd met de live workload.
  4. Pagina-fragmentatie over TOAST'd rijen. Een vector-rij van 6 KB laat bij delete een gat van 6 KB achter. De FSM kan die gaten niet samenvoegen tot bruikbare vrije ruimte. Zelfs met een schone autovacuum groeit de heap. Niets minder dan een rewrite herstelt dat.

Het partition-swap-patroon, concreet

Kom je aan de onderste vier toe, dan is de eerlijke fix niet tunen. Het is een onderhoudsvenster. De goedkoopste versie van dat venster is een partition-swap. Ontwerp daar vanaf dag één voor: een gepartitioneerde tabel waarvan je een week per keer kunt droppen, overleeft een monolithische die je twee keer per jaar volledig moet VACUUM'en.

-- Original table, range-partitioned by week
CREATE TABLE agent_memories (
  id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL,
  embedding vector(1536) NOT NULL,
  content text NOT NULL,
  created_at timestamptz NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE agent_memories_2026w24
  PARTITION OF agent_memories
  FOR VALUES FROM ('2026-06-08') TO ('2026-06-15');

-- Sunday 03:00 maintenance
BEGIN;
ALTER TABLE agent_memories DETACH PARTITION agent_memories_2026w24;
COMMIT;

DROP TABLE agent_memories_2026w24;

De DETACH gaat snel en neemt kortstondig een lock. De DROP is onmiddellijk. Geen VACUUM, geen REINDEX, geen wachten tot autovacuum bijtrekt. Je geeft de disk, de bloat, de TOAST-entries en het HNSW-graph-fragment in één transactie terug.

De prijs is dat je retentie vooraf moet vastleggen. Een week aan agent-geheugen moet genoeg zijn, of twee weken, of wat je product nodig heeft. Zodra je je aan een venster hebt gecommitteerd, wordt het operationele model dramatisch eenvoudiger: elke zondag wordt de oudste partitie kandidaat voor een swap, en die swap is een script, geen project.

Een diagnostische volgorde van handelen

Triage je nu een trage agent? Draai de queries in deze volgorde. Stop bij de eerste die verklaart wat je ziet.

  1. De pg_stat_user_tables-query hierboven. Dead-tuple ratio en last_autovacuum. Twee minuten.
  2. pg_stat_activity voor een vastzittende xmin. Eén minuut.
  3. TOAST-grootte voor de verdachte tabel. Eén minuut.
  4. pgstattuple voor échte heap-bloat, niet alleen dode tuples. Vijf minuten, en hij leest de hele tabel.
  5. Index-grootte en de laatste REINDEX. Eén minuut.
  6. Zijn stap 1 tot en met 5 schoon en is de agent nog steeds traag? Dan is het centroid drift of HNSW-tombstones, en kijk je tegen een rebuild of een swap aan.
Waarschuwing

Draai geen VACUUM FULL op een vector-geïndexeerde tabel tijdens kantooruren. Hij neemt ACCESS EXCLUSIVE, blokkeert elke read waar de agent op leunt, en de operatie is niet te onderbreken zonder het al verrichte werk te verliezen.

Het kleinste nuttige ding dat je vandaag kunt doen

Draai de pg_stat_user_tables-query één keer. Bewaar de output. Voeg een cron toe die hem elke zes uur draait en die alert geeft zodra de dead-tuple ratio op een agent-tabel boven de 25% komt. De eerste elf fouten van deze lijst zie je weken voordat ze de agent pijn doen. De onderste vier vragen nog steeds een zondagavond, maar een partition-swap-zondagavond is twee uur rustig werk, geen 06:00-incident.

Toen we de retrieval-laag bouwden voor een SaaS-klant met elf miljoen embeddings verdeeld over drie tenants, liepen we in maand drie tegen fout 14 aan: HNSW-tombstones drukten recall stilletjes omlaag zonder dat er een alert afging. We hebben het opgelost door agent_memories per week te partitioneren en een swap-script van 40 regels te schrijven dat elke zondag om 03:00 Amsterdamse tijd draait. Het werk achter die fix is wat we bedoelen als we het hebben over AI-agents als productlaag in plaats van demo.

Kern

Vector-geïndexeerde Postgres-tabellen met delete-zware workloads vragen vanaf dag één om partition-swap-retentie, niet om VACUUM-tuning.

FAQ

Hoe vaak moet ik pg_stat_user_tables checken op een vector-tabel?

Elke zes uur is voor de meeste workloads voldoende. Alert zodra de dead-tuple ratio op een agent-tabel boven de 25% komt. Dagelijks is te traag; een delete-zware job knalt op één middag voorbij 50% bloat.

Is REINDEX CONCURRENTLY genoeg om een HNSW-index te herstellen?

Voor de index zelf wel, maar het kan op een grote tabel uren duren en concurreert met live queries om I/O. Boven de tien miljoen rijen is partition-swap sneller en voorspelbaarder.

Heeft IVFFlat van pgvector hetzelfde tombstone-probleem als HNSW?

Iets anders. IVFFlat draagt tombstones niet op dezelfde manier mee, maar list-toewijzingen verschuiven naarmate de onderliggende distributie verandert. Beide hebben uiteindelijk een rebuild nodig.

Wat is het kleinste partitievenster waar het swap-patroon nog werkt?

Een dag werkt als je heel hoge turnover hebt. Een week is de meest gangbare keuze. Onder een dag eten de swap-overhead en de kosten van partition pruning de winst op.

ai agentsragarchitectureoperationstooling

Iets bouwen?

Start een project