Faster vector search with an HNSW index
Vector similarity queries that scan every row slow down as data grows. Add an HNSW index with pgvector, make sure queries use it, tune recall, and build it within your memory.
What you see
Semantic search was quick with a few thousand rows and is now slow. explain on the query shows a sequential scan and a sort over the whole table:
Limit (actual time=1912.4..1912.5 rows=10 loops=1)
-> Sort (actual time=1912.4..1912.4 rows=10 loops=1)
Sort Key: ((embedding <=> '[0.012,-0.044,...]'::vector))
-> Seq Scan on recipes (actual time=0.03..1630.8 rows=480000 loops=1)
Why it happens
Without an index, pgvector has to compare the query vector with every stored vector and sort the results. Time grows with the number of rows. An HNSW index builds a graph of neighbours, so a query visits a small part of the table instead. It's approximate: it can occasionally miss a true nearest neighbour, which you trade for a large speed-up.
How to fix it
- Create the index with the operator class that matches your distance operator. Mismatched pairs are the most common reason an index is ignored.
| Query operator | Distance | Operator class |
|---|---|---|
<=> |
cosine | vector_cosine_ops |
<-> |
Euclidean (L2) | vector_l2_ops |
<#> |
negative inner product | vector_ip_ops |
create index recipes_embedding_hnsw_idx
on public.recipes
using hnsw (embedding extensions.vector_cosine_ops);
On a table that's in use, add concurrently (outside a transaction) so writes aren't blocked during the build.
- Write the query so the index can be used:
order by <column> <operator> <value>with alimit.
create or replace function public.match_recipes(query_embedding extensions.vector(1536), match_count int default 10)
returns table (id bigint, title text, distance float)
language sql
stable
set search_path = ''
as $$
select r.id, r.title, r.embedding operator(extensions.<=>) query_embedding as distance
from public.recipes r
order by r.embedding operator(extensions.<=>) query_embedding
limit match_count;
$$;
const { data } = await supabase.rpc('match_recipes', { query_embedding: embedding, match_count: 10 })
Filtering with where distance < 0.3 instead of order by ... limit stops the index being used.
- Check the plan shows
Index Scan using recipes_embedding_hnsw_idx. If Postgres still picks a sequential scan on a small table, that can be the right choice.
Tune recall and speed
hnsw.ef_search(default 40) is how many candidates a query looks at. Raise it for better recall, at some cost in speed. A query returns at mostef_searchrows from the index, so keep it at least as high as yourlimit.
set hnsw.ef_search = 100;
To apply it inside the function, add set hnsw.ef_search = 100 next to set search_path.
Filters plus vector search: with a selective
where(say, one user's recipes), the index returns the nearestef_searchrows first and the filter removes most of them, so you can get fewer results than asked for. On pgvector 0.8 and later,set hnsw.iterative_scan = relaxed_order;makes the scan keep going until it has enough rows. Check your version withselect extversion from pg_extension where extname = 'vector';. A plain B-tree index on the filter column also helps when the filter is very selective.Build options
m(default 16) andef_construction(default 64) trade build time and size for recall:with (m = 24, ef_construction = 100). The defaults suit most apps.
Building without running out of memory
HNSW builds are fastest when the graph fits in maintenance_work_mem. If it doesn't, you'll see a notice like hnsw graph no longer fits into maintenance_work_mem and the build slows down a lot. Raise it for your session only:
set maintenance_work_mem = '1GB';
set max_parallel_maintenance_workers = 2;
create index concurrently recipes_embedding_hnsw_idx on public.recipes
using hnsw (embedding extensions.vector_cosine_ops);
On SupaHost
- Your instance's RAM is shared by every project and service on it: 4 GB on Builder, 8 GB on Pro, 16 GB on Business. Keep
maintenance_work_memwell below that; something like 512 MB on Builder and 1-2 GB on Pro or Business is a reasonable start. Build large indexes at a quiet time. - HNSW indexes on
vectorcolumns support up to 2,000 dimensions. For larger embeddings, index ahalfveccast. - The index takes database space, which counts toward your plan's database quota.