Symptoms
- Vector search latency jumps from tens of milliseconds to seconds; chat answers time out.
- Database CPU high, many sessions running the similarity query.
- Started after a data load, a query change or a pgvector/PostgreSQL upgrade.
1. Check the plan
Run the real query with a real embedding:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM chunks
WHERE tenant_id = 42
ORDER BY embedding <=> '[0.012, -0.034, ...]'::vector
LIMIT 10;
| Plan shows | Meaning |
|---|---|
Index Scan using chunks_embedding_hnsw_idx | Index used; look at filters and ef_search |
Seq Scan on chunks + Sort | Index not used; exact search over all rows |
| Index scan, then many rows removed by filter, few results | Filtered search problem |
2. Index not used: common causes
Operator doesn't match the index's operator class. An index built with vector_cosine_ops is used only by <=>; vector_l2_ops by <->; vector_ip_ops by <#>.
SELECT indexdef FROM pg_indexes WHERE tablename = 'chunks';
Change the query operator to match, or build the matching index.
No ORDER BY ... LIMIT. The index is only used for nearest-neighbour ordering with a limit. Queries like WHERE embedding <=> x < 0.3 without ordering do a full scan.
Index missing or invalid after a failed CREATE INDEX CONCURRENTLY:
SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE indrelid = 'chunks'::regclass;
Drop and rebuild invalid indexes.
Casting or dimension mismatch: the query vector cast to a different type (halfvec vs vector) or dimension than the indexed expression.
3. Filtered search
With a selective filter (one tenant out of thousands), HNSW finds the nearest 40 (default hnsw.ef_search) vectors, then the filter removes most of them; you get too few results, and the planner may switch to a sequential scan.
Options:
-- pgvector 0.8+: keep scanning the index until enough rows pass the filter
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.ef_search = 100;
- Partial indexes per large tenant or category:
CREATE INDEX CONCURRENTLY chunks_t42_hnsw ON chunks USING hnsw (embedding vector_cosine_ops) WHERE tenant_id = 42;
- Partitioning by tenant (each partition with its own HNSW index) for many-tenant setups.
- A B-tree index on
tenant_idlets the planner choose an exact search inside small tenants, which is fast when the tenant has few rows.
4. Index fits in memory?
HNSW performance drops sharply when the index doesn't fit in RAM.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding_hnsw_idx'));
SHOW shared_buffers;
If the index has grown past available memory, options are more RAM, halfvec (half the size), fewer dimensions, or splitting data.
5. Immediate relief
- Fix the query shape (operator, ORDER BY/LIMIT) and deploy; this is usually the whole fix.
- Lower
hnsw.ef_searchif it was raised to a very high value globally. - Add a statement timeout for retrieval so slow searches don't pile up:
ALTER ROLE rag_app SET statement_timeout = '2s';
Warning
Building a new HNSW index on a large table takes a long time and lots of memory. Use
CREATE INDEX CONCURRENTLY, raisemaintenance_work_memfor that session, and run it off-peak.
Prevent it next time
- Keep a latency test with real filters in CI for retrieval queries; check plans with
EXPLAIN. - Alert on p95 retrieval latency and on sequential scans of the vector table (
pg_stat_user_tables.seq_scan). - Plan memory for index growth.
- Record the operator class next to the query in code.