← Blog

Databases

pgvector HNSW reindex: 8 uur storing op Black Friday

Een logistieke SaaS in Rotterdam deed midden in Black Friday een reindex op pgvector en zag de RAG-agent 8 uur uit de lucht gaan. Dit is de tijdlijn en de procedure die we nu hanteren.

Jacob Molkenboer· Oprichter · A Brand New Company· 25 jun 2026· 9 min
Open eiken kaartenbak met crème kaart, groen zijden lint, messing tab, gebroken rode lakzegel op gevouwen briefje.

Zaterdag 28 november 2025. 14:47 CET. Een logistieke SaaS in Rotterdam — zevenentwintig man, vooral koeriers en ops, een platformteam van vier engineers — drukte op REINDEX op een pgvector HNSW-index die de RAG-agent voor de klantenservice voedde. Die agent beantwoordde tijdens de Black Friday-week ruwweg 6.400 tickets per dag. Om 14:48 stopte hij met antwoorden. Pas om 22:51 kwam hij terug.

Dit is de post-mortem van die acht uur, plus de procedure die we daarna hebben neergezet zodat de volgende reindex op 4,2M embeddings nooit meer een ACCESS EXCLUSIVE lock pakt. Ze vroegen ons de tijdlijn pas te delen toen de bonussen waren uitbetaald. De namen van de engineers zijn weggelaten. De cijfers kloppen.

Het cluster op vrijdag

De opzet was zo overzichtelijk dat een team van vier het in z'n hoofd hield. Eén Postgres 16 primary op Hetzner, 64 GB RAM, NVMe, met een streaming replica in Falkenstein. Pgvector op 0.7.4, een HNSW-index op een embeddings-tabel met 4,2M rijen, vectors van 1536 dimensies uit text-embedding-3-small. De index was vier maanden eerder gebouwd met m=16, ef_construction=64. Op disk 11 GB.

De agent daarboven was een vrij standaard retrieval-pipeline: query, embedden, top-k tegen de HNSW-index, herrangschikken met een kleinere cross-encoder, antwoord van Claude Sonnet. In de Black Friday-week piekte het verkeer rond 130 queries per minuut.

Vrijdagmiddag merkte een engineer op dat de recall tegen een apart gehouden evaluatieset langzaam was weggedreven. Er waren vier maanden lang nieuwe embeddings binnengestroomd en de HNSW-graph was aan de randen rommelig geworden. Recall@10 op de evalset stond op 0,81 tegen een baseline van 0,93. Latency was prima. Ze wilden de index nog vóór het weekend opnieuw opbouwen.

De beslissing op zaterdagochtend

De beslissing, om 14:32 op zaterdag in het ticket gezet, was één zin: HNSW nu herbouwen, verkeer is lager (dat was het niet — Black Friday liep door tot zaterdagochtend), verwachte duur veertig minuten op basis van de testcluster, update volgt aan het eind.

REINDEX op een Postgres B-tree-index, met de standaardopties, blokkeert schrijven op de tabel maar staat reads toe. REINDEX op een pgvector HNSW-index pakt ook een lock, en cruciaal: queries die normaal die index gebruiken doen nu een sequential scan over de tabel. Een sequential scan over 4,2M rijen met vectors van 1536 dimensies en cosine-afstand is geen query meer. Het is een timeout van veertig seconden.

De engineer voerde uit:

REINDEX INDEX embeddings_hnsw_idx;

Geen CONCURRENTLY. Geen swap. Eén statement. Om 14:48 liep de eerste RAG-query in een timeout. Om 14:55 stond de upstream ticket-queue met 380 stuks achterop. Om 15:30 was de support-afdeling overgeschakeld op handmatige triage en zat de COO in de platform-Slack.

De reindex deed geen veertig minuten. Hij deed vijf uur en drieënveertig minuten. De engineer had getest op een staging-cluster van 280k rijen. De bouwtijd van HNSW op pgvector schaalt slechter dan lineair met het aantal rijen — de graph-constructie doet voor elke nieuwe rij k-NN-queries tegen de gedeeltelijk gebouwde graph, en die constante groeit mee met de grootte van de graph zelf. De pgvector-documentatie is daar duidelijk over. De engineer had die op zaterdag niet gelezen.

Let op

De bouwtijd van HNSW is grofweg O(N · log N), maar met een constante die meegroeit met de bestaande graph. Een build van veertig minuten op 280k rijen wordt op 4M rijen een build van 6+ uur. Test op een kopie van productie, niet op een staging-snede.

Acht uur ACCESS EXCLUSIVE

Toen het lock eenmaal liep, zou afbreken de halve index terugrollen en het cluster in dezelfde staat achterlaten, alleen met vijf uur build verspild. Het team besloot 'm uit te laten lopen. Terwijl REINDEX liep:

  • Klantenservice-tickets stapelden zich op in Intercom; de SLA-breach-teller liep op.
  • De healthcheck van de RAG-agent bleef 500 retourneren. De frontend toonde een 'tijdelijk niet beschikbaar'-banner.
  • Een tweede engineer schreef een fallback aan de query-kant die via de cross-encoder een willekeurige sample van 50k afliep. De recall was zo slecht dat ze 'm binnen twintig minuten weer uittrokken.
  • De COO schreef een mail aan de top vijftien klanten, en verstuurde 'm uiteindelijk niet.

REINDEX was om 20:31 klaar. Op dat moment ontdekte het team dat de nieuwe index eerst gevacuumd en geanalyseerd moest worden voordat de planner 'm oppakte. Nog eens twee uur en twintig minuten. De agent kwam om 22:51 terug.

Totaal: acht uur en drie minuten geen agent, op een Black Friday-zaterdag.

Herstel en de rekening

Alleen geteld wat een eurobedrag heeft: 412 tickets geëscaleerd naar mensen die normaal vrij waren, twee senior engineers betaald op weekendtarief, één klant dreigde op te zeggen (deed het niet). Het platformteam belde ons die dinsdag.

Het interessante van dit verhaal zijn niet die acht uur. Het is wat we daarna hebben neergezet.

Online build, alias-swap

De procedure die we nu uitrollen vóór elke pgvector-reindex op productie heeft drie eigenschappen. De live HNSW-index wordt nooit gedropt of gelockt voordat de nieuwe volledig is gebouwd en warm staat. De applicatie merkt niets van de swap, want vanuit het perspectief van de planner blijft de naam van de index hetzelfde. En als de nieuwe index slechter is dan de oude, ligt de rollback op één rename afstand.

Het idee: in Postgres bouw je de index onder een andere naam, concurrently, en wissel je vervolgens binnen één transactie atomair de namen om. CREATE INDEX CONCURRENTLY pakt geen ACCESS EXCLUSIVE; het pakt een lichter lock dat reads en writes toelaat. De swap zelf pakt wel ACCESS EXCLUSIVE, maar slechts milliseconden lang.

Zo ziet het eruit.

-- 1. Bouw een verse HNSW-index naast de live versie, zonder lock.
-- Uren werk op 4,2M rijen. De live index blijft ondertussen queries serven.
CREATE INDEX CONCURRENTLY embeddings_hnsw_idx_new
  ON embeddings
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 96);

-- 2. Maak de nieuwe index warm: de planner heeft stats nodig en het OS moet 'm in RAM hebben.
ANALYZE embeddings;
SELECT count(*) FROM embeddings
  WHERE embedding <=> '[...]'::vector < 0.5
  LIMIT 100;  -- herhaal met representatieve query-vectors

-- 3. Atomic swap. Binnen één transactie: eerst de oude renamen, dan de nieuwe.
BEGIN;
  ALTER INDEX embeddings_hnsw_idx     RENAME TO embeddings_hnsw_idx_old;
  ALTER INDEX embeddings_hnsw_idx_new RENAME TO embeddings_hnsw_idx;
COMMIT;

-- 4. Na 24 uur stabiele werking: drop de rollback-kopie.
DROP INDEX CONCURRENTLY embeddings_hnsw_idx_old;

De swap in stap 3 pakt ACCESS EXCLUSIVE, maar alleen op de embeddings-tabel en alleen voor de duur van twee DDL-statements. In de praktijk, op een gezond cluster, is dat onder de vijftig milliseconden. Queries die op dat moment lopen blokkeren kort of ronden af op de oude naam vóór de rename binnenkomt.

Er zit een addertje. Als CREATE INDEX CONCURRENTLY halverwege klapt — disk vol, OOM, een trage vacuum — blijft er een INVALID index achter. De planner gebruikt 'm niet, maar de schijfruimte is wel weg. Drop de ongeldige index voordat je opnieuw probeert:

SELECT indexname FROM pg_indexes
WHERE tablename = 'embeddings' AND indexname LIKE '%_new';

DROP INDEX CONCURRENTLY embeddings_hnsw_idx_new;

De runbook, van begin tot eind

De procedure die we nu uitleveren telt zes stappen. Drie checks voordat de build start. Drie voor de build, swap en rollback.

  1. Capaciteitscheck. Vrije schijfruimte moet minimaal 2× de huidige indexgrootte zijn. Draai pg_size_pretty(pg_relation_size('embeddings_hnsw_idx')) op de primary, en daarna df -h op de host.
  2. Recall-baseline. Draai de apart gehouden evalset tegen de live index. Noteer recall@10 en p95-latency. Dat is de drempel die de nieuwe index moet halen.
  3. Verhoog maintenance_work_mem. Trek 'm voor alleen de build-sessie op naar ~25% van het host-RAM. Een HNSW-build is geheugenhongerig; de default van 64 MB is de traagst denkbare instelling.
  4. Bouw concurrently. Het CREATE INDEX CONCURRENTLY-statement van hierboven. Tag de sessie met application_name zodat 'ie in pg_stat_activity te herkennen is, niet als zomaar 'psql'.
  5. Warm draaien en verifiëren. ANALYZE. Draai de evalset tegen de nieuwe index door 'm expliciet te hinten (EXPLAIN om te bevestigen dat de nieuwe naam wordt gekozen). Is recall@10 slechter dan de baseline, dan afbreken. Gelijk of beter? Door.
  6. Swap. De BEGIN/RENAME/RENAME/COMMIT-transactie. Houd de healthcheck van de agent één hele cyclus in de gaten (meestal 60s). De oude index blijft 24 uur op disk als undo.

Het rollback-pad is dezelfde procedure in omgekeerde volgorde. Als er in het eerste uur iets misgaat, draai je nog een paar renames en zit de planner terug op de oude graph voordat de volgende query binnenkomt.

Wat dit met agentic systems te maken heeft

Een terugkerend draadje op de frontpage deze week gaat over het bouwen van betrouwbare agentic systems. Het meeste van die discussie gaat over prompt-structuur, evals en tool-bedrading. De saaie waarheid is dat de betrouwbaarheid van een agent wordt begrensd door de betrouwbaarheid van het traagste, raarste stukje infrastructuur eronder. Voor een RAG-agent is dat bijna altijd de vector store.

Bouw je agents bovenop pgvector — of pgvector-op-Supabase, of pgvector-op-RDS — dan is de index-rebuild-procedure geen databaseprobleem. Het hoort bij het runtime-contract van je agent. Behandel je het als routinematig database-onderhoud, dan krijg je storingen van acht uur.

Toen wij de support-agent voor de Rotterdamse logistiek-klant hierboven bouwden, liepen we tijdens het herstel tegen dit op: er was geen scheiding tussen 'procedures van het databaseteam' en 'procedures van het agent-team'. We hebben de swap-runbook uiteindelijk in het deployment-manifest van de agent geschreven, in hetzelfde bestand als de system prompt en de evalset. Dat gaat nu mee met elke AI-agent die we bouwen, want het failure mode is hetzelfde ongeacht de stack.

Wat je vandaag in vijf minuten kunt doen

Open de database onder je retrieval-pipeline. Draai \d <embeddings_table> en schrijf de naam van de HNSW- (of IVFFlat-) index op. Zet vervolgens in je runbook het exacte CREATE INDEX CONCURRENTLY-statement waarmee je 'm zou herbouwen, met de juiste opclass en parameters. Kan je team dat statement tijdens een incident niet binnen vijf minuten opdiepen, dan heb je geen rebuild-procedure. Dan heb je een storing die wacht op een recall-drift om interessant te worden.

Kern

Bouw de nieuwe HNSW-index naast de oude, maak 'm warm en wissel de namen in één transactie. Het lock-venster zakt van uren naar milliseconden.

FAQ

Kan ik niet gewoon REINDEX CONCURRENTLY gebruiken in plaats van de alias-swap?

Op recente Postgres-versies werkt dat voor HNSW, maar je geeft je rollback-pad op: de oude index is weg zodra hij klaar is. Met de alias-swap houd je de oude index 24 uur achter de hand, zodat je terug kunt als de recall verslechtert.

Hoe lang duurt CREATE INDEX CONCURRENTLY op een paar miljoen embeddings?

Op 4M+ rijen met vectors van 1536 dimensies en maintenance_work_mem op ~25% van het host-RAM moet je rekenen op 3 tot 6 uur. Test op een kopie van productie, niet op een staging-snede, want de bouwtijd schaalt slechter dan lineair.

Werkt dezelfde procedure ook voor IVFFlat?

Zelfde vorm. IVFFlat bouwt sneller, maar de recall is gevoeliger voor centroid-drift, dus je herbouwt 'm vaker. Het patroon — naast de live versie bouwen, warm draaien, namen wisselen — is identiek.

Wanneer weet je dat de recall ver genoeg is weggedreven voor een rebuild?

Hou een evalset apart van 500 tot 2000 queries met bekende goede antwoorden. Draai 'm wekelijks en houd recall@10 in de gaten. Zakt 'ie meer dan ~10% onder de baseline, plan dan een rebuild in het eerstvolgende rustige venster.

ragai agentscase studyarchitectureoperationsknowledge base

Iets bouwen?

Start een project