DEV Community

Cover image for I added pgvector support to my .NET AI memory library
Aftab Bashir
Aftab Bashir

Posted on

I added pgvector support to my .NET AI memory library

BlazorMemory has had EF Core storage from the start. It works. But there is a problem with how it handles search.

The current EfCoreMemoryStore loads every memory for a user into C#, then runs cosine similarity in a loop. For a user with 50 memories that is fine. For a user with 5,000 it is a full table scan on every query.

v0.6.0 ships a new package that fixes this properly.

BlazorMemory.Storage.Pgvector

The pgvector adapter moves vector search into PostgreSQL. Instead of loading everything and computing in C#, it runs a single SQL query using the <=> cosine distance operator.

SELECT * FROM "Memories"
WHERE "UserId" = @userId
ORDER BY "Embedding" <=> @queryVector
LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

PostgreSQL handles the math. The .NET code just maps the results.

Setup

dotnet add package BlazorMemory.Storage.Pgvector
Enter fullscreen mode Exit fullscreen mode

You need PostgreSQL with the pgvector extension. If you are on a fresh database:

CREATE EXTENSION IF NOT EXISTS vector;
Enter fullscreen mode Exit fullscreen mode

Then wire it up:

builder.Services
    .AddBlazorMemory()
    .UsePgvectorStorage<AppDbContext>()
    .UseOpenAiEmbeddings(apiKey)
    .UseOpenAiExtractor(apiKey);
Enter fullscreen mode Exit fullscreen mode

Your AppDbContext needs to extend PgvectorMemoryDbContext or apply the configuration manually:

public class AppDbContext : PgvectorMemoryDbContext
{
    public AppDbContext(DbContextOptions<PgvectorMemoryDbContext> options)
        : base(options, dimensions: 1536) { }
}
Enter fullscreen mode Exit fullscreen mode

The dimensions parameter must match your embedding model. OpenAI text-embedding-3-small uses 1536. Adjust for other providers.

The HNSW index

The package creates an HNSW index on the embedding column automatically via EF migrations:

entity.HasIndex(e => e.Embedding)
      .HasMethod("hnsw")
      .HasOperators("vector_cosine_ops")
      .HasDatabaseName("IX_Memories_Embedding_Hnsw");
Enter fullscreen mode Exit fullscreen mode

HNSW is approximate nearest neighbour search. It trades a small amount of recall for much faster query times at scale. For memory retrieval this is the right tradeoff. You do not need perfect recall, you need fast and good enough.

When to use which adapter

Use BlazorMemory.Storage.EfCore when:

  • You are building a server-side Blazor or ASP.NET Core app
  • You have a small number of memories per user (under a few hundred)
  • You want the simplest possible setup with SQLite or SQL Server

Use BlazorMemory.Storage.Pgvector when:

  • You are on PostgreSQL already
  • You expect large memory stores per user
  • You want search to scale without loading data into memory

Use BlazorMemory.Storage.IndexedDb when:

  • You are building Blazor WASM
  • You want zero backend
  • Everything stays in the browser

Current state

9 packages on NuGet, 70 tests passing.

GitHub: https://github.com/aftabkh4n/BlazorMemory

Top comments (2)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

Moving cosine search into PostgreSQL removes the obvious O(n) transfer cost. A useful next test is user-skewed recall: pgvector applies a WHERE UserId = ... filter after scanning an approximate HNSW index, so a user with relatively few rows can return fewer than five matches—or lower recall—when other users dominate the graph. I’d compare each query with an exact-search baseline and record recall@5 plus p95, then choose between an index on UserId for small slices, enabling hnsw.iterative_scan, or partitioning/separate tables where isolation matters. Does the adapter expose ef_search/iterative-scan settings, and do the 70 tests exercise very uneven user sizes?

Collapse
 
aftabkh4n profile image
Aftab Bashir

Really good points, Ivan. You are right on all of them.

The current adapter does a simple composite index on UserId + LearnedAt and lets pgvector scan the HNSW graph with a WHERE filter after the fact. For a typical BlazorMemory use case (personal assistant, one user, hundreds of memories) that works fine. Your user-skewed scenario is exactly where it breaks down. A user with 20 memories sitting in a graph dominated by a user with 20,000 will get inconsistent recall.

The adapter does not expose ef_search or iterative_scan settings yet. That is a real gap. The right fix is probably a configuration option on UsePgvectorStorage that lets callers tune ef_search per query, and a note in the docs recommending partitioned tables or per-user schemas when isolation and recall guarantees matter.

The 70 tests do not cover uneven user distributions at all. They are unit tests against an in-memory fake, not integration tests against a real PostgreSQL instance with realistic data shapes. That is on the backlog but not done.

I will open an issue to track ef_search exposure and add a section to the README on multi-tenant tradeoffs. If you have run this kind of recall benchmark against pgvector with filtered HNSW I would be interested in the numbers you saw.