# 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.

Source: https://supahost.app/kb/hnsw-index-speed · Updated 2026-11-11

## 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

1. **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` |

```sql
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.

2. **Write the query so the index can be used:** `order by <column> <operator> <value>` with a `limit`.

```sql
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;
$$;
```

```ts
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.

3. **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 most `ef_search` rows from the index, so keep it at least as high as your `limit`.

```sql
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 nearest `ef_search` rows 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 with `select 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) and `ef_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:

```sql
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_mem` well 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 `vector` columns support up to 2,000 dimensions. For larger embeddings, index a `halfvec` cast.
- The index takes database space, which counts toward your plan's database quota.

## Related

- [Vector indexes (HNSW and IVFFlat)](/docs/ai/vector-indexes)
- [Semantic search](/docs/ai/semantic-search)
- [Vector columns with pgvector](/docs/ai/pgvector)
- [High memory or swap usage](/kb/high-memory)
