# Supabase pgvector Standard

> **Scope:** nextjs-supabase
> **Layer:** 2 (on keyword)
> **Keywords:** supabase, pgvector, embedding, similarity, vector search
> **Load When:** supabase pgvector keywords detected

Stack: Next.js 15 + Supabase + .NET Backend

## Core Rules

- ALWAYS use HNSW indexes for production (faster queries, no training required)
- ALWAYS match dimensions to embedding model (e.g., 1536 for text-embedding-3-small)
- NEVER store embeddings without an index -- full table scan at query time
- Use `halfvec` for large datasets to halve storage (16-bit vs 32-bit per dimension)
- ALWAYS use RLS on tables containing embeddings

## Setup and Table Design

```sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id),
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  metadata JSONB DEFAULT '{}',
  embedding vector(1536),
  created_at TIMESTAMPTZ DEFAULT now()
);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "owner_access" ON documents FOR ALL
  USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
CREATE INDEX idx_documents_user_id ON documents (user_id);
```

### halfvec Optimization

| Type | Storage/dim | 1536-dim | Best for |
|------|------------|----------|----------|
| `vector` | 4 bytes | 6 KB | High precision, small datasets |
| `halfvec` | 2 bytes | 3 KB | Large datasets, cost optimization |

## Index Types

```sql
-- HNSW (recommended)
CREATE INDEX idx_docs_embedding ON documents
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

-- IVFFlat (legacy, requires existing data)
CREATE INDEX idx_docs_ivf ON documents
  USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
```

| Feature | HNSW | IVFFlat |
|---------|------|---------|
| Query speed | Faster | Slower |
| Requires training | No | Yes |
| Recall quality | Higher | Lower |
| Recommended | Yes | Only for very large datasets |

## HNSW Parameters

| Parameter | Default | Tuning |
|-----------|---------|--------|
| `m` | 16 | Higher = better recall, more memory |
| `ef_construction` | 64 | Higher = better index, slower build |
| `ef_search` | 40 | `SET hnsw.ef_search = 100;` per session |

## Distance Functions

| Operator | Function | Index Ops | Use Case |
|----------|----------|-----------|----------|
| `<=>` | Cosine distance | `vector_cosine_ops` | Normalized embeddings (most common) |
| `<->` | L2 (Euclidean) | `vector_l2_ops` | Spatial/positional data |
| `<#>` | Inner product (neg) | `vector_ip_ops` | Pre-normalized, max similarity |

## Similarity Search

```sql
CREATE OR REPLACE FUNCTION match_documents(
  query_embedding vector(1536),
  match_threshold float DEFAULT 0.78,
  match_count int DEFAULT 10,
  p_user_id uuid DEFAULT auth.uid()
) RETURNS TABLE (id uuid, title text, content text, similarity float)
LANGUAGE sql STABLE AS $$
  SELECT d.id, d.title, d.content,
    1 - (d.embedding <=> query_embedding) AS similarity
  FROM documents d
  WHERE d.user_id = p_user_id
    AND 1 - (d.embedding <=> query_embedding) > match_threshold
  ORDER BY d.embedding <=> query_embedding
  LIMIT match_count;
$$;
```

## Hybrid Search (Vector + Full-Text)

```sql
CREATE OR REPLACE FUNCTION hybrid_search(
  query_text text, query_embedding vector(1536),
  match_count int DEFAULT 10,
  text_weight float DEFAULT 0.3, vector_weight float DEFAULT 0.7
) RETURNS TABLE (id uuid, title text, content text, score float)
LANGUAGE sql STABLE AS $$
  WITH vector_results AS (
    SELECT id, title, content,
      1 - (embedding <=> query_embedding) AS vector_score
    FROM documents WHERE user_id = auth.uid()
    ORDER BY embedding <=> query_embedding LIMIT match_count * 2
  ),
  text_results AS (
    SELECT id, title, content,
      ts_rank(to_tsvector('english', content), plainto_tsquery('english', query_text)) AS text_score
    FROM documents WHERE user_id = auth.uid()
      AND to_tsvector('english', content) @@ plainto_tsquery('english', query_text)
    LIMIT match_count * 2
  )
  SELECT COALESCE(v.id, t.id), COALESCE(v.title, t.title), COALESCE(v.content, t.content),
    (COALESCE(v.vector_score, 0) * vector_weight + COALESCE(t.text_score, 0) * text_weight)
  FROM vector_results v FULL OUTER JOIN text_results t ON v.id = t.id
  ORDER BY score DESC LIMIT match_count;
$$;
```

## .NET Integration (Npgsql)

```csharp
public sealed class DocumentRepository(AppDbContext db)
{
    public async Task StoreEmbeddingAsync(
        Guid documentId, float[] embedding, CancellationToken ct = default)
    {
        await db.Database.ExecuteSqlInterpolatedAsync(
            $"UPDATE documents SET embedding = {new Vector(embedding)} WHERE id = {documentId}", ct);
    }

    public async Task<List<DocumentMatch>> SearchSimilarAsync(
        float[] queryEmbedding, int limit = 10, float threshold = 0.78f,
        CancellationToken ct = default)
    {
        return await db.Database.SqlQuery<DocumentMatch>($"""
            SELECT id, title, content,
              1 - (embedding <=> {new Vector(queryEmbedding)}::vector) AS similarity
            FROM documents
            WHERE 1 - (embedding <=> {new Vector(queryEmbedding)}::vector) > {threshold}
            ORDER BY embedding <=> {new Vector(queryEmbedding)}::vector LIMIT {limit}
            """).ToListAsync(ct);
    }
}

// EF Core registration
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(connectionString, npg => npg.UseVector()));
```

## Common Mistakes

| Wrong | Right | Why |
|-------|-------|-----|
| No index on embedding column | HNSW index | Full table scan, extremely slow |
| `ORDER BY similarity DESC` | `ORDER BY embedding <=> query ASC` | Operator returns distance, not similarity |
| Mixing embedding dimensions | Consistent dimensions per column | Dimension mismatch causes runtime errors |
| Full-precision for millions of rows | `halfvec` for large datasets | 2x storage savings, minimal quality loss |
| Missing RLS on embedding tables | RLS with user/tenant policies | Embeddings contain sensitive content context |
