# Neon pgvector Standard

> **Scope:** neon, dotnet-vsa
> **Layer:** 1 (on scope)
> **Keywords:** pgvector, vector, embeddings, similarity search, HNSW, cosine
> **Load When:** pgvector or embedding keywords detected

**Verified against:** .NET 10 + Pgvector.EntityFrameworkCore + pgvector (HNSW). Last-verified: 2026-05-20.

---

Stack: .NET VSA + Neon Serverless Postgres

## Core Rules

- ALWAYS use HNSW indexes for production (faster queries, no training data required)
- ALWAYS match vector dimensions to your embedding model (e.g., 1536 for text-embedding-3-small)
- NEVER store embeddings without an index -- full table scan at query time
- ALWAYS call `UseVector()` on `NpgsqlDataSourceBuilder` before using vector types
- ALWAYS register the `vector` extension in `OnModelCreating`
- Use `ReadOnlyMemory<float>` for vector properties in EF Core entities

## Enable pgvector

```sql
CREATE EXTENSION IF NOT EXISTS vector;
```

## EF Core Entity Mapping

### NuGet Package

```bash
dotnet add package Pgvector.EntityFrameworkCore
```

### Entity Definition

```csharp
using System.ComponentModel.DataAnnotations.Schema;
using Pgvector;

public class Document
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public string? Metadata { get; set; }

    [Column(TypeName = "vector(1536)")]
    public Vector? Embedding { get; set; }

    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
```

### IEntityTypeConfiguration

```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class DocumentConfiguration : IEntityTypeConfiguration<Document>
{
    public void Configure(EntityTypeBuilder<Document> builder)
    {
        builder.ToTable("documents");
        builder.HasKey(d => d.Id);
        builder.Property(d => d.Id).HasDefaultValueSql("gen_random_uuid()");
        builder.Property(d => d.Title).IsRequired();
        builder.Property(d => d.Content).IsRequired();
        builder.Property(d => d.Metadata).HasColumnType("jsonb");
        builder.Property(d => d.Embedding).HasColumnType("vector(1536)");
        builder.Property(d => d.CreatedAt).HasDefaultValueSql("now()");

        // HNSW index for cosine similarity
        builder.HasIndex(d => d.Embedding)
            .HasMethod("hnsw")
            .HasOperators("vector_cosine_ops")
            .HasStorageParameter("m", 16)
            .HasStorageParameter("ef_construction", 64);
    }
}
```

### DbContext Setup

```csharp
public class AppDbContext : DbContext
{
    public DbSet<Document> Documents => Set<Document>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasPostgresExtension("vector");
        modelBuilder.ApplyConfiguration(new DocumentConfiguration());
    }
}

// Registration with vector support
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
```

## HNSW Index Creation (Raw SQL)

```sql
-- Cosine distance (most common for normalized embeddings)
CREATE INDEX idx_documents_embedding ON documents
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

-- L2 (Euclidean) distance
CREATE INDEX idx_documents_embedding_l2 ON documents
  USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);

-- Tune search accuracy per session
SET hnsw.ef_search = 100;
```

### HNSW Parameters

| Parameter | Default | Tuning |
|-----------|---------|--------|
| `m` | 16 | Higher = better recall, more memory |
| `ef_construction` | 64 | Higher = better index quality, slower build |
| `ef_search` | 40 | Higher = better recall at query time |

## Distance Operators

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

```sql
CREATE OR REPLACE FUNCTION match_documents(
  query_embedding vector(1536),
  match_threshold float DEFAULT 0.78,
  match_count int DEFAULT 10
) 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 1 - (d.embedding <=> query_embedding) > match_threshold
  ORDER BY d.embedding <=> query_embedding
  LIMIT match_count;
$$;
```

### C# Repository

```csharp
using Pgvector;

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);
    }
}

public record DocumentMatch(Guid Id, string Title, string Content, float Similarity);
```

## 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
    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 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) AS score
  FROM vector_results v FULL OUTER JOIN text_results t ON v.id = t.id
  ORDER BY score DESC LIMIT match_count;
$$;
```

## Npgsql Vector Type Handler

The `Pgvector.EntityFrameworkCore` package automatically registers the vector type handler when you call `UseVector()`. For raw Npgsql (without EF Core):

```csharp
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.UseVector();
await using var dataSource = dataSourceBuilder.Build();
```

## 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` | Operators return distance, not similarity |
| Missing `UseVector()` | Call on `NpgsqlDataSourceBuilder` | Npgsql cannot map vector types without it |
| Mixing embedding dimensions | Consistent dimensions per column | Dimension mismatch causes runtime errors |
| `float[]` for EF Core property | `Vector` from Pgvector package | EF Core mapping requires the Pgvector type |
| No `HasPostgresExtension("vector")` | Add in `OnModelCreating` | Extension must be declared for migrations |
