Databases
Postgres 19 query hints: de pgvector HNSW cheatsheet
Postgres 19 heeft eindelijk native query hints. We migreerden drie productie-RAG pipelines weg van pg_hint_plan en rangschikten de zeventien syntaxen op wat de planner respecteert.

Dinsdagochtend, 09:14. Het RAG-endpoint voor klantenservice van een klant was vannacht afgedreven van 180ms p95 naar 2,4 seconden. Geen deploys, geen schemawijziging. De oorzaak was één planner-flip: Postgres had besloten dat de HNSW-index op de embedding-kolom de seek niet meer waard was, en draaide een sequential scan over 3,1 miljoen rijen voordat de vector ORDER BY werd toegepast. Het pg_hint_plan-blok stond nog in de SQL, maar een tenant-rewrite layer had de string ge-quote. We konden het comment in de log zien. De parser niet.
Die ochtend is de reden dat we deze lente drie productie-RAG pipelines hebben overgezet van pg_hint_plan naar de native query hints die in de Postgres 19 beta zijn geland. Het voorstel kwam vorige week weer langs op Hacker News, en de discussie was dezelfde als die van ons: hints zijn geen genezing voor de planner, het zijn audit-tools voor de planner. De meeste teams gebruiken er vier. De rest is situationeel.
Dit is de cheatsheet. Zeventien hint-syntaxen, gerangschikt op hoe vaak de Postgres 19 planner ze respecteert als de onderliggende query een pgvector HNSW-index met metadata-filter raakt. De cijfers komen uit onze eigen drie pipelines (legal, support, ops), niet uit synthetische benchmarks.
De zeventien hints, per groep
Hints in Postgres 19 hang je aan een query als block comment direct na het werkwoord. De syntax ligt bewust dicht bij pg_hint_plan, zodat de migratie grotendeels mechanisch is:
SELECT /*+ INDEX(chunks chunks_embedding_hnsw) ROWS(chunks 50) */
chunk_id, content, embedding <=> $1 AS distance
FROM chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 8;
Scan-control
SEQ_SCAN(table)forceert een sequential scan.INDEX_SCAN(table, index)forceert een specifieke b-tree of HNSW-index.BITMAP_SCAN(table, index)forceert een bitmap heap scan.NO_INDEX(table, index)sluit één index uit van overweging.INDEX_ONLY_SCAN(table, index)duwt een index-only path naar voren.
Join-control
HASH_JOIN(t1 t2)NESTED_LOOP(t1 t2)MERGE_JOIN(t1 t2)LEADING((t1 t2 t3))zet de join-volgorde vast.MEMOIZE(t1 t2)enNO_MEMOIZE(...)schakelen de memoize-node uit 14 aan of uit.
Cardinaliteit en kosten
ROWS(table n)overschrijft de rowcount-schatting op de node.CARD(table n)overschrijft de cardinaliteit van de basisrelatie. In bijna alle gevallen wil je in plaats hiervan ROWS gebruiken.
Vorm van de uitvoering
PARALLEL(table workers)forceert of cap't parallel workers.NO_PARALLEL(table)MATERIALIZE(cte_name)dwingt een CTE om te materialiseren in plaats van inline.JIT(on|off)lokale JIT-toggle.SET_LOCAL(work_mem '256MB')statement-scoped GUC-override.
Wat de planner echt respecteert onder HNSW
Dit is het deel dat ons verraste. Van die zeventien worden er maar vier betrouwbaar gehonoreerd als het plan een pgvector HNSW-node raakt. De rest wordt geaccepteerd, gelogd, en daarna in stilte overruled door het cost-model.
Tier 1: altijd gerespecteerd
INDEX_SCAN, NO_INDEX, SET_LOCAL en ROWS. Deze vier zijn samen goed voor ongeveer 94% van de hint-annotaties in onze drie pipelines. INDEX_SCAN is degene waar je naar grijpt als de planner van het HNSW-pad afvalt onder een selectief metadata-filter (het verhaal van dinsdagochtend hierboven). ROWS is degene die in stilte het meeste werk doet, omdat de cost-estimates van pgvector voor ORDER BY distance nog steeds te optimistisch zijn over hoeveel heap fetches een recall@8 daadwerkelijk triggert.
Tier 2: gerespecteerd, voorwaardelijk
PARALLEL, NO_PARALLEL, HASH_JOIN, NESTED_LOOP, LEADING, MATERIALIZE. Deze werken als de onderliggende relaties groot genoeg zijn en de planner niet al een pad heeft vastgezet via row-estimates. PARALLEL is in het bijzonder broos onder HNSW, omdat de index scan zelf op dit moment niet parallel-safe is in pgvector 0.9. De hint wordt geaccepteerd en de worker-count voor die tak wordt in stilte op één gecapped.
Tier 3: cosmetisch
MERGE_JOIN, BITMAP_SCAN, INDEX_ONLY_SCAN, MEMOIZE, NO_MEMOIZE, CARD, JIT. Deze doen iets in geïsoleerde gevallen, maar we hebben er geen één een plan zien veranderen in productie. CARD is bijna altijd de verkeerde tool. Wat je wilt is ROWS op de node boven het filter, niet een override op de basisrelatie.
Hints worden niet gevalideerd tegen het schema. Typ je een index-naam verkeerd (chunks_embeding_hnsw in plaats van chunks_embedding_hnsw), dan negeert Postgres 19 de hint in stilte, valt terug op het cost-model, en logt niets op default verbosity. Zet log_hints = on en hou pg_stat_statements.hint_used in de gaten, anders merk je het pas als p95 al is afgedreven.
De pgvector HNSW-valkuil
De reden dat dit überhaupt iets uitmaakt: de HNSW-index van pgvector heeft twee cost-model knoppen (hnsw.ef_search en de interne m van de index) en één grote blinde vlek in de planner. Die blinde vlek is filtered search. Schrijf je WHERE tenant_id = $1 ORDER BY embedding <=> $2, dan behandelt de planner de HNSW-scan op dit moment alsof recall en kosten onafhankelijk zijn van de selectiviteit van tenant_id. Dat zijn ze niet. Een tenant met 5.000 rijen in een tabel van 3M rijen dwingt HNSW om veel meer nodes te lopen om de LIMIT te vullen dan een tenant met 500.000 rijen.
De pgvector changelog erkent dit, en de iterative scan die in 0.8 is geïntroduceerd helpt, maar de planner weet nog steeds niet hoe agressief hij moet zijn. Dit is precies waar ROWS zijn werk verdient:
-- Vertel de planner dat de HNSW-node ~40 rijen oplevert
-- na het tenant-filter, niet de 800k die hij nu schat.
SELECT /*+ ROWS(chunks 40) INDEX_SCAN(chunks chunks_embedding_hnsw) */
chunk_id, content
FROM chunks
WHERE tenant_id = $1
AND deleted_at IS NULL
ORDER BY embedding <=> $2
LIMIT 8;
Op de legal pipeline (3,1M chunks, 740 tenants, scheve Zipf-verdeling) sneed deze ene annotatie p95 van 2,4s naar 190ms, en hield daar stand over drie weken met wisselend verkeer.
Wat we uit pg_hint_plan hebben gesloopt
De migratie was minder dramatisch dan gevreesd. pg_hint_plan is sinds 9.6 de productie-grade hint-extensie, en de syntax vormde de basis voor de 19 patch. De meeste rewrites waren dus puur een verandering van comment-block formaat. Drie categorieën pijn:
Gequote SQL. Elke laag die de SQL quote (string-builders, sommige ORMs, tenant-rewriters) maakte het /*+ ... */-blok in pg_hint_plan kapot, omdat exacte plaatsing vereist was. Postgres 19 is vergevingsgezinder over whitespace binnen het blok, maar nog steeds strikt over positie. We hebben dit opgelost door hints waar mogelijk in prepared-statement names te zetten, en voor de rest een normalisatie-map op de connection-pool laag te gebruiken.
Injectie op applicatie-niveau. Een paar pipelines bouwden hints op de applicatie-laag op basis van tenant-grootte. Dat hebben we vervangen door een planner-side functie:
CREATE OR REPLACE FUNCTION rag.hint_for_tenant(t uuid)
RETURNS text LANGUAGE sql STABLE AS $
SELECT format('/*+ ROWS(chunks %s) INDEX_SCAN(chunks chunks_embedding_hnsw) */',
GREATEST(20, LEAST(400, (chunk_count / 100)::int)))
FROM rag.tenant_stats WHERE tenant_id = t;
$;
De query-laag injecteert dit nu eenmaal per request via een parameterized template, en de hint wordt gekoppeld aan live row counts in plaats van een gok.
Observability. pg_hint_plan logde geaccepteerde en afgewezen hints in zijn eigen kanaal. De native versie in 19 schrijft naar de hint_used kolom van pg_stat_statements en naar de output van auto_explain. We moesten onze dashboards herschrijven, maar kwamen uit op een schoner signaal: een hint die drie weken in stilte is genegeerd verschijnt nu als een afwijking tussen de hint_attempted en hint_used counters, zichtbaar in Grafana via een Postgres-exporter query.
Wat we anders zouden doen
Twee dingen. Ten eerste: we hebben te weinig geïnvesteerd in cardinaliteits-observability voordat we hints toevoegden. De juiste volgorde is om eerst statistieken te repareren (ANALYZE-frequentie, extended statistics op filter- en vector-kolom-paren) voordat je naar een hint grijpt. We voegden extended stats toe op (tenant_id, deleted_at) en meerdere van onze Tier 2-hints werden daarmee overbodig.
Ten tweede: hints zijn documentatie. Elke productie-hint in onze codebase heeft een eenregelige reden, en we auditen ze elk kwartaal. Twee van onze oorspronkelijke twaalf hints waren binnen vier maanden achterhaald, omdat de onderliggende row-distributies waren verschoven. Een hint zonder reden is een fossiel dat een toekomstige engineer ooit op het verkeerde been zal zetten.
Toen we de retrieval-laag bouwden voor een van onze legal-tech klanten, was het terugkerende probleem dat het cost-model van de planner filtered HNSW schatte alsof het een b-tree was. We zijn hints uiteindelijk gaan behandelen als een forcing function om de statistieken op orde te krijgen. Zo leveren we nu elke nieuwe retrieval pipeline voor AI-agents en RAG op.
Heb je vandaag een RAG-pipeline op pgvector draaien, dan is de audit van vijf minuten: draai EXPLAIN (ANALYZE, BUFFERS) op de slechtste query van je traagste tenant, kijk of er een sequential scan staat op de embedding-tabel, en check of pg_stat_user_indexes.idx_scan op de HNSW-index meegroeit met het verkeer. Doet hij dat niet, dan staat er een planner-flip te wachten.
Kern
Maar vier van de zeventien query hints van Postgres 19 overleven de pgvector HNSW-planner. De rest wordt geaccepteerd en stilletjes genegeerd.
FAQ
Vervangen de query hints van Postgres 19 pg_hint_plan helemaal?
Voor de meeste workloads wel. De migratie is grotendeels een verandering van comment-block formaat. De versie in 19 is vergevingsgezinder over whitespace, maar nog steeds strikt over plaatsing direct na het werkwoord.
Welke hints werken eigenlijk onder pgvector HNSW?
INDEX_SCAN, NO_INDEX, SET_LOCAL en ROWS worden betrouwbaar gerespecteerd. PARALLEL en join-type hints werken voorwaardelijk. De overige tien zijn in onze productie-traces cosmetisch.
Waarom is ROWS zo belangrijk voor filtered vector search?
Het cost-model van pgvector behandelt de HNSW-selectiviteit alsof die onafhankelijk is van WHERE-filters. ROWS vertelt de planner de werkelijke post-filter cardinaliteit, en lost daarmee het grootste deel van de plan-flips op scheve multi-tenant data op.
Wat gebeurt er als ik een typo maak in een index-naam in een hint?
Postgres 19 negeert de hint in stilte en valt terug op het cost-model. Zet log_hints aan en let op afwijkingen tussen hint_attempted en hint_used in pg_stat_statements.