pgvector search: a vector column, one model, an RPC with <=>, HNSW

PostgREST has no vector operators, so similarity search is a function returning nearest rows by cosine distance. Embed at write time, one model, index early.

Extensions & Cron

· Chapter

42

·

3

min read

The answer. Semantic search in Supabase is four pieces. The vector extension (installed into extensions) adds a vector(n) column type whose dimension must equal the embedding model's output (1536 for OpenAI's small model, 384 or 768 for common open ones); mixing models in one column produces garbage silently, so the model name is part of the schema. An embedding is produced when the row is written (a trigger-fed queue, a webhook to an Edge Function, or the same request that inserts) rather than backfilled in a page. Search is a SQL function, because PostgREST cannot express <=> (cosine distance, 0 is identical): it takes the query embedding, a threshold and a limit, and returns rows with 1 - distance as similarity; the client calls it through rpc() and RLS on the underlying table still applies when the function is security invoker. An HNSW index makes it fast; without one every search scans every vector.

The pattern.

create extension if not exists vector with schema extensions;
alter table public.tickets add column embedding extensions.vector(1536);   -- "text-embedding-3-small" only
create index tickets_embedding_hnsw on public.tickets using hnsw (embedding extensions.vector_cosine_ops);

create or replace function public.match_tickets(query_embedding extensions.vector(1536), match_threshold float, match_count int)
returns table (id bigint, title text, similarity float) language sql stable set search_path = '' as $$
  select t.id, t.title, 1 - (t.embedding <=> query_embedding) as similarity
  from public.tickets t
  where t.embedding is not null and 1 - (t.embedding <=> query_embedding) > match_threshold
  order by t.embedding <=> query_embedding asc
  limit match_count
$$;
const { data } = await supabase.rpc("match_tickets", { query_embedding, match_threshold: 0.78, match_count: 10 });

Watch out.

  • Thresholds are model-specific; tune on real queries and keep the number next to the model name.
  • Embed the fields users search by (title and body), chunk long bodies, and cache query embeddings: the embedding call is the slow, billed part.
  • The vector column is large; keep it out of select("*") on list queries, or every page transfers thousands of floats per row.

Related: vectorize-index-plus-embeddings-for-semantic-search · pin-search-path-and-keep-extensions-out-of-public · security-definer-rpc-is-a-controlled-hole-in-rls