# Vector indexes (HNSW and IVFFlat)

> Speed up pgvector similarity search on SupaHost with HNSW or IVFFlat indexes: choosing one, operator classes, build settings for shared instance memory, query tuning and filtered search.

Source: https://supahost.app/docs/ai/vector-indexes · Updated 2026-11-11

Without an index, a similarity search compares the query vector with every row. That's exact, and fine for a few thousand rows. Past that, each search gets slower in proportion to the table size, and on SupaHost it also uses CPU that other projects on the instance share.

A vector index makes search approximate: it finds the nearest rows very quickly but may occasionally miss one. The share of true nearest neighbours it finds is called **recall**, and every setting below trades recall against speed and memory.

## HNSW or IVFFlat?

| | HNSW | IVFFlat |
|---|---|---|
| Query speed at good recall | Faster | Slower |
| Build time | Slower | Faster |
| Memory and size | Larger | Smaller |
| Can be built on an empty table | Yes | No, needs representative data first |
| Copes with inserts after build | Yes | Recall drops as data drifts; rebuild periodically |

Use HNSW unless index build time or size is a real problem. It doesn't need retuning as the table grows.

## Match the operator class to your query

An index only helps queries that use the same distance operator it was built for.

| Query operator | `vector` operator class | `halfvec` operator class |
|---|---|---|
| `<=>` cosine | `vector_cosine_ops` | `halfvec_cosine_ops` |
| `<->` Euclidean | `vector_l2_ops` | `halfvec_l2_ops` |
| `<#>` inner product | `vector_ip_ops` | `halfvec_ip_ops` |
| `<+>` L1 | `vector_l1_ops` (HNSW only) | `halfvec_l1_ops` (HNSW only) |

Indexes support up to 2,000 dimensions for `vector` and 4,000 for `halfvec`.

## Create an HNSW index

For the `recipes` table used throughout this section, searched by cosine distance:

```sql
create index recipes_embedding_hnsw_idx
  on public.recipes
  using hnsw (embedding extensions.vector_cosine_ops);
```

Two build options control quality and cost:

| Option | Default | Effect of raising it |
|---|---|---|
| `m` | 16 | More links per node: better recall, bigger index, slower build |
| `ef_construction` | 64 | More candidates considered while building: better recall, slower build |

```sql
create index recipes_embedding_hnsw_idx
  on public.recipes
  using hnsw (embedding extensions.vector_cosine_ops)
  with (m = 24, ef_construction = 100);
```

The defaults are a sensible start. Change them only after measuring recall on your own data.

### Building on a shared instance

Index builds are fastest when the graph fits in `maintenance_work_mem`. Remember that your instance's RAM (4 GB on Builder, 8 GB on Pro, 16 GB on Business) is shared by Postgres and every project's services, so raise it modestly and only for the build:

```sql
begin;
set local maintenance_work_mem = '512MB';
set local statement_timeout = '30min';
create index recipes_embedding_hnsw_idx
  on public.recipes using hnsw (embedding extensions.vector_cosine_ops);
commit;
```

This runs in one go in Studio's SQL editor. It blocks writes to `recipes` until it finishes. On a busy table, use `create index concurrently` instead, which can't run inside a transaction: run it as the only statement in the SQL editor, or over psql through the SSH tunnel on Business. More in [Indexes](/docs/database/indexes) and [Connections and timeouts](/docs/database/connections-and-timeouts).

If Postgres logs a notice that the graph no longer fits in `maintenance_work_mem`, the build continues but slows down considerably.

## Tune HNSW at query time

`hnsw.ef_search` sets how many candidates a search considers. The default is 40. Higher values improve recall and cost time; it must be at least your `limit` to return that many rows.

Set it for one function rather than the whole database:

```sql
create or replace function public.match_recipes_precise(
  query_embedding extensions.vector(1536),
  match_count int default 20
)
returns table (id bigint, title text, distance float8)
language sql
stable
security invoker
set search_path = ''
set hnsw.ef_search = 100
as $$
  select r.id, r.title, r.embedding operator(extensions.<=>) query_embedding
  from public.recipes r
  order by r.embedding operator(extensions.<=>) query_embedding
  limit least(match_count, 100);
$$;
```

## Filtered search and iterative scans

An HNSW scan returns the `ef_search` nearest candidates, and only then does Postgres apply your `where` clause. With a selective filter, such as one user's recipes out of a million, most candidates get thrown away and you can get fewer rows than you asked for.

From pgvector 0.8.0, iterative scans keep reading the index until enough rows pass the filter. Check your version with `select extversion from pg_extension where extname = 'vector';`, then enable them on the function:

```sql
create or replace function public.match_my_recipes(
  query_embedding extensions.vector(1536),
  match_count int default 10
)
returns table (id bigint, title text)
language sql
stable
security invoker
set search_path = ''
set hnsw.iterative_scan = relaxed_order
as $$
  select r.id, r.title
  from public.recipes r
  where r.user_id = (select auth.uid())
  order by r.embedding operator(extensions.<=>) query_embedding
  limit least(match_count, 50);
$$;
```

`relaxed_order` may return rows slightly out of distance order; use `strict_order` if exact ordering matters. `hnsw.max_scan_tuples` caps how far a scan goes. Other options for very selective filters:

- A **partial index** per common filter value: `create index ... where cuisine = 'thai'`.
- **Partitioning** by tenant or category, with an index per partition. See [Table partitioning](/docs/database/partitioning).
- **No vector index** for the filtered query: if the filter leaves only a few thousand rows, an exact scan over them using a B-tree index on `user_id` is fast and has perfect recall.

## Create an IVFFlat index

IVFFlat clusters vectors into `lists` and searches only the closest clusters. Load your data first, because the clusters are learned from the rows present at build time.

```sql
create index recipes_embedding_ivf_idx
  on public.recipes
  using ivfflat (embedding extensions.vector_cosine_ops)
  with (lists = 100);
```

A common starting point is `lists = rows / 1000` up to a million rows, and `sqrt(rows)` beyond that. At query time, `ivfflat.probes` (default 1) sets how many lists to search; try `sqrt(lists)`:

```sql
set ivfflat.probes = 10;
```

Rebuild the index when the table has grown or changed a lot, for example with `reindex index concurrently recipes_embedding_ivf_idx;` run on its own.

## Check the index is used

```sql
explain analyze
select id from public.recipes
order by embedding operator(extensions.<=>) (select embedding from public.recipes where id = 7)
limit 10;
```

You should see `Index Scan using recipes_embedding_hnsw_idx`. If you see `Seq Scan` and `Sort`, check that the operator matches the operator class and that the query has `order by ... limit`. On small tables Postgres may choose an exact scan anyway, because it's cheaper.

Index size counts toward your plan's database storage:

```sql
select pg_size_pretty(pg_relation_size('public.recipes_embedding_hnsw_idx'));
```

## Related

- [Faster vector search with an HNSW index](/kb/hnsw-index-speed)
- [Semantic search](/docs/ai/semantic-search)
- [Query performance and EXPLAIN](/docs/database/query-performance)
- pgvector reference: [github.com/pgvector/pgvector](https://github.com/pgvector/pgvector)
