Learnixo
Back to blog
Backend Systemsintermediate

pgvector with EF Core — Vector Search Without a Separate Database

Add vector similarity search to your .NET PostgreSQL application using pgvector and EF Core: store embeddings, create HNSW indexes, run cosine similarity queries, and build semantic search.

Asma Hafeez KhanMay 25, 20267 min read
.NETC#EF CorepgvectorPostgreSQLvector searchAIembeddings
Share:𝕏

pgvector with EF Core — Vector Search Without a Separate Database

pgvector is a PostgreSQL extension that adds a native vector data type and similarity search operators. If you already use PostgreSQL, you can add semantic search without Qdrant, Weaviate, or any other dedicated vector database.


Why pgvector?

Dedicated vector DB (Qdrant, Weaviate, Pinecone):
  ✓ Optimised for vector search at massive scale
  ✓ Rich metadata filtering
  ✗ Extra infrastructure to manage
  ✗ Data lives in two systems (your DB + vector DB)
  ✗ Sync complexity — keep them consistent

pgvector:
  ✓ Your data and vectors in one PostgreSQL database
  ✓ Joins between vectors and regular tables (native SQL)
  ✓ Same backup/replication as your existing data
  ✓ HNSW index — fast approximate nearest-neighbour search
  ✗ Not as fast as dedicated vector DBs at 1B+ vectors
  → Right choice for < 10M vectors with mixed query patterns

Step 1: Enable pgvector

SQL
-- Run once on your PostgreSQL database
CREATE EXTENSION IF NOT EXISTS vector;
Bash
# Docker  use pgvector-enabled image
docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 pgvector/pgvector:pg16

Step 2: Install NuGet Package

XML
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.*" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.*" />

Step 3: Entity with Embedding Column

C#
using Pgvector;

// Product entity with a 1536-dimension embedding (text-embedding-3-small)
public class Product
{
    public int    Id          { get; set; }
    public string Name        { get; set; } = "";
    public string Description { get; set; } = "";
    public string Category    { get; set; } = "";
    public decimal Price      { get; set; }

    // The vector column — stores the embedding
    public Vector? Embedding  { get; set; }
}
C#
// DbContext — register pgvector and configure the column
public class AppDbContext(DbContextOptions<AppDbContext> opts) : DbContext(opts)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder model)
    {
        model.HasPostgresExtension("vector");   // ensure extension exists

        model.Entity<Product>(entity =>
        {
            entity.Property(p => p.Embedding)
                .HasColumnType("vector(1536)");   // dimensions must match the embedding model

            // HNSW index — fast approximate nearest-neighbour, built once
            entity.HasIndex(p => p.Embedding)
                .HasMethod("hnsw")
                .HasOperators("vector_cosine_ops");   // cosine similarity
        });
    }
}
C#
// Register with pgvector support
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseNpgsql(connectionString,
        npgsql => npgsql.UseVector()));   // enables Vector type mapping

Step 4: Generate and Store Embeddings

C#
public class ProductEmbeddingService(
    AppDbContext context,
    IEmbeddingGenerator<string, Embedding<float>> embedder)
{
    public async Task GenerateAndStoreAsync(int productId, CancellationToken ct)
    {
        var product = await context.Products.FindAsync([productId], ct)
            ?? throw new NotFoundException(nameof(Product), productId);

        // Create a rich text representation for embedding
        var text = $"{product.Name}. {product.Description}. Category: {product.Category}.";

        var result    = await embedder.GenerateAsync([text], cancellationToken: ct);
        var floats    = result[0].Vector.ToArray();

        product.Embedding = new Vector(floats);
        await context.SaveChangesAsync(ct);
    }

    public async Task GenerateBatchAsync(int batchSize = 100, CancellationToken ct = default)
    {
        // Find products without embeddings
        var products = await context.Products
            .Where(p => p.Embedding == null)
            .Take(batchSize)
            .ToListAsync(ct);

        if (products.Count == 0) return;

        var texts  = products.Select(p => $"{p.Name}. {p.Description}. Category: {p.Category}.").ToList();
        var results = await embedder.GenerateAsync(texts, cancellationToken: ct);

        for (int i = 0; i < products.Count; i++)
            products[i].Embedding = new Vector(results[i].Vector.ToArray());

        await context.SaveChangesAsync(ct);
    }
}

Step 5: Semantic Search Queries

C#
public class ProductSearchService(
    AppDbContext context,
    IEmbeddingGenerator<string, Embedding<float>> embedder)
{
    // Cosine similarity search — returns most semantically similar products
    public async Task<List<ProductSearchResult>> SearchAsync(
        string query,
        int topK = 10,
        CancellationToken ct = default)
    {
        var queryResult   = await embedder.GenerateAsync([query], cancellationToken: ct);
        var queryVector   = new Vector(queryResult[0].Vector.ToArray());

        return await context.Products
            .Where(p => p.Embedding != null)
            .OrderBy(p => p.Embedding!.CosineDistance(queryVector))   // lowest = most similar
            .Take(topK)
            .Select(p => new ProductSearchResult(
                p.Id,
                p.Name,
                p.Category,
                p.Price,
                1 - p.Embedding!.CosineDistance(queryVector)))   // similarity score 0–1
            .ToListAsync(ct);
    }

    // Hybrid search — combine vector similarity with keyword filter
    public async Task<List<ProductSearchResult>> HybridSearchAsync(
        string query,
        string? category = null,
        decimal? maxPrice = null,
        int topK = 10,
        CancellationToken ct = default)
    {
        var queryResult = await embedder.GenerateAsync([query], ct);
        var queryVector = new Vector(queryResult[0].Vector.ToArray());

        var q = context.Products.Where(p => p.Embedding != null);

        if (category is not null)
            q = q.Where(p => p.Category == category);

        if (maxPrice is not null)
            q = q.Where(p => p.Price <= maxPrice);

        return await q
            .OrderBy(p => p.Embedding!.CosineDistance(queryVector))
            .Take(topK)
            .Select(p => new ProductSearchResult(
                p.Id, p.Name, p.Category, p.Price,
                1 - p.Embedding!.CosineDistance(queryVector)))
            .ToListAsync(ct);
    }

    // Find similar products (given a product, find related ones)
    public async Task<List<ProductSearchResult>> FindSimilarAsync(
        int productId,
        int topK = 5,
        CancellationToken ct = default)
    {
        var product = await context.Products.FindAsync([productId], ct);
        if (product?.Embedding is null) return [];

        return await context.Products
            .Where(p => p.Id != productId && p.Embedding != null)
            .OrderBy(p => p.Embedding!.CosineDistance(product.Embedding))
            .Take(topK)
            .Select(p => new ProductSearchResult(
                p.Id, p.Name, p.Category, p.Price,
                1 - p.Embedding!.CosineDistance(product.Embedding!)))
            .ToListAsync(ct);
    }
}

public record ProductSearchResult(int Id, string Name, string Category, decimal Price, double Similarity);

Step 6: Migration

C#
// EF Core migration — pgvector column and HNSW index
public partial class AddProductEmbeddings : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS vector");

        migrationBuilder.AddColumn<Vector>(
            name:       "Embedding",
            table:      "Products",
            type:       "vector(1536)",
            nullable:   true);

        // HNSW index — build after populating embeddings for better performance
        migrationBuilder.Sql("""
            CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_embedding_hnsw
            ON "Products" USING hnsw ("Embedding" vector_cosine_ops)
            WITH (m = 16, ef_construction = 64)
            """,
            suppressTransaction: true);   // HNSW index cannot be built in a transaction
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn("Embedding", "Products");
    }
}

Step 7: RAG with pgvector

C#
// Retrieval-Augmented Generation — use pgvector to fetch context for LLM
public class ProductQnA(
    ProductSearchService search,
    IChatClient chatClient)
{
    public async Task<string> AnswerAsync(string question, CancellationToken ct)
    {
        // 1. Retrieve relevant products
        var products = await search.SearchAsync(question, topK: 5, ct);

        // 2. Build context for the LLM
        var context = string.Join("\n\n", products.Select(p =>
            $"Product: {p.Name} (Category: {p.Category}, Price: {p.Price:C})\nSimilarity: {p.Similarity:P0}"));

        // 3. Ask the LLM with context
        var messages = new List<ChatMessage>
        {
            new(ChatRole.System, """
                You are a product advisor. Answer questions based ONLY on the provided product catalogue.
                If the answer is not in the catalogue, say so.
                """),
            new(ChatRole.User, $"Product catalogue:\n{context}\n\nQuestion: {question}"),
        };

        var response = await chatClient.CompleteAsync(messages, cancellationToken: ct);
        return response.Message.Text ?? "";
    }
}

HNSW vs IVFFlat Index

pgvector supports two index types:

HNSW (Hierarchical Navigable Small World):
  - Build once — no training required
  - Fast query time — O(log n)
  - More memory than IVFFlat
  - Parameters: m (connections per node, default 16), ef_construction (build quality, default 64)
  - ef_search at query time: SET hnsw.ef_search = 100; (higher = more accurate but slower)
  → Recommended for most use cases

IVFFlat (Inverted File with Flat):
  - Requires training (needs existing data to cluster)
  - Lower memory than HNSW
  - Parameters: lists (number of clusters, sqrt(rows) is a good start)
  → Better for very large datasets (> 1M vectors)

For < 1M vectors and typical SaaS: HNSW with default parameters is the right choice.

Interview Answer

"pgvector adds a native vector data type and similarity search to PostgreSQL. With Pgvector.EntityFrameworkCore, you map a Vector property to a vector(1536) column, create an HNSW index (fast approximate nearest-neighbour with no training step), and query with CosineDistance — EF Core translates this to native pgvector SQL operators. Embeddings are generated with IEmbeddingGenerator (Microsoft.Extensions.AI) and stored as Vector values. Semantic search orders by ascending CosineDistance (lowest = most similar) and takes the top K results. The main advantage over dedicated vector databases: your product data and embeddings live in one PostgreSQL database with native JOINs, same backup strategy, no sync complexity. The limitation: slower than dedicated vector DBs at tens of millions of vectors. The HNSW index must be built with suppressTransaction: true in EF Core migrations because PostgreSQL doesn't allow it inside a transaction."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.