Mert Özen Further With Every Line
Artificial Intelligence AI with .NET — #09

Microsoft.Extensions.VectorData: Your First Collection with the Vector Store Abstraction

Mert Özen Nov 8, 2026 11 min 4 views
Microsoft.Extensions.VectorData: Your First Collection with the Vector Store Abstraction

Microsoft.Extensions.VectorData lets you define a record with attributes and move from an in-memory store to PostgreSQL + pgvector with the same code; the generator you give the store produces the embeddings. With real outputs we look at filtered search, ScoreThreshold, how the distance function flips the meaning of the score, and a dimension bug the InMemory provider never catches.

Version note: The code in this post was built and run with .NET 10, Microsoft.Extensions.VectorData.Abstractions 10.11.0, CommunityToolkit.VectorData.InMemory 1.0.1, CommunityToolkit.VectorData.PgVector 1.0.2, PostgreSQL 18.2 + pgvector 0.8.1, OllamaSharp 5.5.2 and Ollama 0.34.4 (nomic-embed-text).

Guess First

You are adding "search books by meaning" to the Bookstore. During development you use an in-memory vector store; every demo works and the results make sense. Then you point the connection string at PostgreSQL and the same code throws on the first UpsertAsync call. Same code, same abstraction. What could have changed?

Second question: in a search result one book scores 0.1457 and another 0.5359. Which one is closer to the query?

Both answers are in the real outputs of this post. Both are about the store's behavior, not your code.

What Does VectorData Abstract?

What an embedding is and how similarity is measured were covered in the LLM (Large Language Model) Fundamentals series post "Embeddings and Similarity for LLMs". There we kept vectors in a list and computed cosine similarity by hand. In a real application you store vectors in a database and let the database do the search.

You can do that with the driver directly: open a vector column with Npgsql and write SQL (Structured Query Language) with the <=> operator. But that code stays tied to one database and the serialization work stays with you. The Microsoft.Extensions.VectorData.Abstractions package adds an ORM-like (Object-Relational Mapper) layer on top: you define the record as a .NET class and the provider package maps it to the target database's schema. The abstract types are VectorStore and VectorStoreCollection<TKey, TRecord>; Azure AI (Artificial Intelligence) Search, Qdrant, Redis, PostgreSQL and the in-memory provider all implement these two types.

The provider packages were renamed recently. They used to ship under names like Microsoft.SemanticKernel.Connectors.PgVector. According to the deprecation message on NuGet, because the package has no dependency on Semantic Kernel it was renamed to CommunityToolkit.VectorData.PgVector, and it became v1 stable under the new name (1.0.0 for PgVector, 2026-07-22). Most samples you find online still use the old names.

The Record Model: Key, Data and Vector

using Microsoft.Extensions.VectorData;

namespace Bookstore.Assistant.Search;

public sealed class BookDocument
{
    [VectorStoreKey]
    public int Id { get; set; }

    [VectorStoreData]
    public required string Title { get; set; }

    [VectorStoreData(IsIndexed = true)]
    public required string Category { get; set; }

    [VectorStoreData]
    public required string Blurb { get; set; }

    // The store generates the embedding: the vector of this string property is stored
    [VectorStoreVector(768, DistanceFunction = DistanceFunction.CosineSimilarity)]
    public string Embedding => Blurb;
}

Three attributes define three roles. [VectorStoreKey] is the record's identity. [VectorStoreData] is a plain column; IsIndexed = true tells the provider you will filter on this field (in PostgreSQL it becomes a btree index, as we will see shortly). [VectorStoreVector] is the vector column: the first argument is the number of dimensions, and DistanceFunction is how similarity is measured.

The vector property's type is string. That means the abstraction takes over embedding generation: if you give the store an IEmbeddingGenerator, it generates the embedding of this text during upsert and writes the vector to the column. The text itself is not written to that column. That is why we keep the description separately in the Blurb data property and derive the vector property from it. 768 is the size of the vector nomic-embed-text produces.

The catalog has eight real books (Clean Code, Refactoring, Working Effectively with Legacy Code, The Pragmatic Programmer, Domain-Driven Design, Patterns of Enterprise Application Architecture, Designing Data-Intensive Applications, Database Internals); each has a craft, architecture or data category and a one-sentence English description. The program picks the store from an argument; everything else sees only the abstraction:

using System.Globalization;
using Bookstore.Assistant.Search;
using CommunityToolkit.VectorData.InMemory;
using CommunityToolkit.VectorData.PgVector;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using OllamaSharp;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

IEmbeddingGenerator<string, Embedding<float>> embedder =
    new OllamaApiClient(new Uri("http://localhost:11434"), "nomic-embed-text");

string provider = args.Length > 0 ? args[0] : "inmemory";

// The rest of the app knows only VectorStore
VectorStore store = provider switch
{
    "inmemory" => new InMemoryVectorStore(new() { EmbeddingGenerator = embedder }),
    "postgres" => new PostgresVectorStore(
        Environment.GetEnvironmentVariable("BOOKS_DB") ?? throw new InvalidOperationException("Set BOOKS_DB."),
        new PostgresVectorStoreOptions { EmbeddingGenerator = embedder }),
    _ => throw new ArgumentException($"Unknown provider '{provider}'."),
};

VectorStoreCollection<int, BookDocument> books = store.GetCollection<int, BookDocument>("books");
await books.EnsureCollectionExistsAsync();
await books.UpsertAsync(Catalog.Books);

Console.WriteLine($"Provider: {provider}");
await Search("how do I safely change old code without tests?");
await Search("how do databases keep copies of data in sync?", b => b.Category == "data");
await Search("how do databases keep copies of data in sync?", b => b.Category == "craft");

async Task Search(string query, System.Linq.Expressions.Expression<Func<BookDocument, bool>>? filter = null)
{
    Console.WriteLine($"\nQuery: {query}{(filter is null ? "" : $"  filter: {filter.Body}")}");
    VectorSearchOptions<BookDocument> options = new() { Filter = filter };
    await foreach (VectorSearchResult<BookDocument> hit in books.SearchAsync(query, top: 3, options))
    {
        Console.WriteLine($"  {hit.Score:F4}  {hit.Record.Title} [{hit.Record.Category}]");
    }
}

EnsureCollectionExistsAsync creates the collection if it doesn't exist and leaves it alone if it does. UpsertAsync updates a record with the same key or inserts it; here we send all eight records in one call. SearchAsync takes a string directly; the query's embedding is produced by the generator we gave the store. The filter is written as a LINQ (Language Integrated Query) expression and the provider translates it to its own query language. Running with the in-memory provider:

dotnet run -- inmemory
Provider: inmemory

Query: how do I safely change old code without tests?
  0.8543  Working Effectively with Legacy Code [craft]
  0.6400  Refactoring [craft]
  0.4641  Clean Code [craft]

Query: how do databases keep copies of data in sync?  filter: (b.Category == "data")
  0.6134  Designing Data-Intensive Applications [data]
  0.5738  Database Internals [data]

Query: how do databases keep copies of data in sync?  filter: (b.Category == "craft")
  0.4467  Clean Code [craft]
  0.4371  Working Effectively with Legacy Code [craft]
  0.3971  The Pragmatic Programmer [craft]

The first query doesn't contain the words "legacy" or "refactoring", yet the right book is clearly on top. The second query returns only the two data books thanks to the filter. The third shows an important behavior: a database replication question with the "craft" filter returns Clean Code. Vector search returns "the N closest records", not "the ones that are close enough". Records unrelated to the query still make the list with low scores. We will cut those with ScoreThreshold.

From InMemory to PostgreSQL Without Code Changes

We start a PostgreSQL container with the pgvector extension installed. The password comes from an environment variable and never goes into the post or a file:

docker run -d --name aiwork2-pgvector -p 19653:5432 \
  -e POSTGRES_PASSWORD="$PG_PASSWORD" -e POSTGRES_DB=bookstore \
  pgvector/pgvector:0.8.1-pg18

BOOKS_DB="Host=localhost;Port=19653;Database=bookstore;Username=postgres;Password=$PG_PASSWORD" \
  dotnet run -- postgres
Provider: postgres

Query: how do I safely change old code without tests?
  0.8543  Working Effectively with Legacy Code [craft]
  0.6400  Refactoring [craft]
  0.4641  Clean Code [craft]

Query: how do databases keep copies of data in sync?  filter: (b.Category == "data")
  0.6134  Designing Data-Intensive Applications [data]
  0.5738  Database Internals [data]

Query: how do databases keep copies of data in sync?  filter: (b.Category == "craft")
  0.4467  Clean Code [craft]
  0.4371  Working Effectively with Legacy Code [craft]
  0.3971  The Pragmatic Programmer [craft]

Results and scores match to four decimal places. Let's see what the provider created in the database:

docker exec aiwork2-pgvector psql -U postgres -d bookstore -c '\d books'
                               Table "public.books"
  Column   |    Type     | Collation | Nullable |             Default
-----------+-------------+-----------+----------+----------------------------------
 Id        | integer     |           | not null | generated by default as identity
 Title     | text        |           | not null |
 Category  | text        |           | not null |
 Blurb     | text        |           | not null |
 Embedding | vector(768) |           | not null |
Indexes:
    "books_pkey" PRIMARY KEY, btree ("Id")
    "books_Category_index" btree ("Category")

Three things stand out. The Embedding column is vector(768); the size comes from the attribute and PostgreSQL fixes it in the schema. Category, which we marked IsIndexed = true, got a btree index. The vector column has no index: the provider scans the whole table on every search. For eight records that's fine. For tens of thousands of records you want an approximate nearest neighbor index.

Distance Function, Index and What the Score Means

We changed the attribute and ran the same program against a new collection (books_hnsw):

[VectorStoreVector(768, DistanceFunction = DistanceFunction.CosineDistance, IndexKind = IndexKind.Hnsw)]
Provider: postgres

Query: how do I safely change old code without tests?
  0.1457  Working Effectively with Legacy Code [craft]
  0.3600  Refactoring [craft]
  0.5359  Clean Code [craft]

docker exec aiwork2-pgvector psql -U postgres -d bookstore -c '\d books_hnsw' | grep Embedding_index
    "books_hnsw_Embedding_index" hnsw ("Embedding" vector_cosine_ops)

Same order, different numbers: the score that was 0.8543 became 0.1457. That answers the second opening question. CosineSimilarity is a similarity, so bigger means closer. CosineDistance is a distance (1 − similarity), so smaller means closer. What the score means depends entirely on the distance function. The documentation of VectorSearchOptions.ScoreThreshold also says the direction of the threshold depends on the distance function. Anyone who hard-codes a threshold should check this attribute first.

IndexKind.Hnsw created an HNSW (Hierarchical Navigable Small World) index in pgvector. HNSW does approximate search: on very large tables it is faster than a full scan, and in exchange it may occasionally miss the true nearest neighbor. Which combinations of distance function and index kind are supported varies by provider; the attribute's documentation says "the default value varies by database type". So it is safer to set both explicitly.

Wiring It into ASP.NET Core

In an ASP.NET Core Web API (Application Programming Interface) project you don't create the store by hand. The provider package ships DI (Dependency Injection) extensions:

using Bookstore.Assistant.Search;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using OllamaSharp;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddEmbeddingGenerator(
    new OllamaApiClient(new Uri("http://localhost:11434"), "nomic-embed-text"));

builder.Services.AddPostgresCollection<int, BookDocument>(
    "books", builder.Configuration.GetConnectionString("Books")!);

WebApplication app = builder.Build();

app.MapGet("/books/search", async (string q, string? category,
    VectorStoreCollection<int, BookDocument> books, CancellationToken ct) =>
{
    VectorSearchOptions<BookDocument> options = new()
    {
        Filter = category is null ? null : b => b.Category == category,
        ScoreThreshold = 0.5,
    };

    List<object> hits = [];
    await foreach (VectorSearchResult<BookDocument> hit in books.SearchAsync(q, top: 3, options, ct))
    {
        hits.Add(new { hit.Record.Title, hit.Record.Category, Score = Math.Round(hit.Score ?? 0, 4) });
    }
    return hits;
});

app.Run();

We didn't pass an embedding generator in the options. The provider found it in the DI container: the IEmbeddingGenerator registered with AddEmbeddingGenerator was used. Let's call the endpoint:

curl -s "localhost:19661/books/search?q=how%20do%20databases%20keep%20copies%20of%20data%20in%20sync%3F"
[{"title":"Designing Data-Intensive Applications","category":"data","score":0.6134},{"title":"Database Internals","category":"data","score":0.5738},{"title":"Patterns of Enterprise Application Architecture","category":"architecture","score":0.5251}]

curl -s "localhost:19661/books/search?q=how%20do%20databases%20keep%20copies%20of%20data%20in%20sync%3F&category=craft"
[]

In the console app the "craft" filter returned three unrelated books. Here, thanks to ScoreThreshold = 0.5, we get an empty list. Telling the user "nothing found" beats recommending an unrelated book. This check matters even more in a RAG (Retrieval-Augmented Generation) scenario, where you hand the results to an LLM as context. 0.5 is a value picked by eye for this data set and this model; you should set it by measuring on your own data.

VectorData or EF Core?

The VectorData page on Microsoft Learn answers this directly: if you already access the database with Entity Framework Core (EF Core) and your provider supports vector search, you don't necessarily need VectorData; you can express the search in LINQ. Most dedicated vector databases, however, aren't supported by EF Core, and VectorData gives you a good experience for those. Using both in the same application is normal too. For example, orders can be handled with EF Core while document chunks are managed by DataIngestion and VectorData.

For the Bookstore the practical split looks like this. If the book catalog already lives in PostgreSQL through EF Core and you will search by adding a vector column to the same table, the EF Core route means fewer moving parts. If search data lives in a separate system (Azure AI Search, Qdrant, Redis), or a RAG pipeline manages collections itself, VectorData is the right layer. EF Core vector search and the details of a RAG pipeline belong to the upcoming RAG series.

In Production

  • Changing the embedding model is a migration. If you move from nomic-embed-text to another model, both the dimension and the vector space change. Old and new vectors can't be compared. You need a new collection and you have to re-embed every record.
  • The cost of an upsert is embedding generation. 8 records finished instantly; for 100,000 records, batch the upserts, throttle the embedding calls and move the work into a background job.
  • EnsureCollectionExistsAsync creates the schema only the first time and never changes it afterward. If you change the dimension or the distance function in the attribute, the existing table keeps its old shape.
  • Mark the fields you filter on with IsIndexed = true. Filters such as category, language or tenant slow down on large tables without an index.
  • Pick the ScoreThreshold value by measuring and version it together with the distance function. How to measure is covered in the next post of this series, on the evaluation side.

Common Pitfalls

Let's start with the answer to the first opening question. We accidentally wrote 1536 as the dimension in the attribute (the default size of OpenAI's text-embedding-3-small model), while the model produces 768 dimensions. We ran the same code with both providers. The in-memory provider said nothing and returned search results. PostgreSQL stopped at the first upsert:

Unhandled exception. Microsoft.Extensions.VectorData.VectorStoreException: Call to vector store failed.
 ---> Npgsql.PostgresException (0x80004005): 22000: expected 1536 dimensions, not 768

A test written against the in-memory provider never catches this error. The vector store page on Microsoft Learn gives the same warning: the InMemory provider is good for prototyping, not for testing, since there can be important differences between it and your production database. It recommends running tests against the real database with Testcontainers instead. Score direction, filter translation, schema constraints and index behavior only show up with the real provider.

The second pitfall is old package names. Microsoft.SemanticKernel.Connectors.PgVector is deprecated on NuGet and its last version is 1.74.0-preview. Use the CommunityToolkit.VectorData.* packages in new projects, and remember that the namespaces changed when you migrate an old one.

The third is expecting to read the text back from a string vector property. The store writes only the vector. If you want to show the text in results, keep it in a separate [VectorStoreData] property, just like Blurb.

The fourth is forgetting the distance function when interpreting a score. 0.15 can be a perfect match in one collection and an unrelated result in another.

A Small Experiment

★☆☆ Add two books with Turkish descriptions to the catalog and ask the same questions in Turkish. How well does nomic-embed-text find books with English descriptions for a Turkish query? How do the scores change?

★★☆ Add [VectorStoreData(IsIndexed = true)] public int Year to BookDocument and search with the filter b => b.Category == "craft" && b.Year >= 2000. Try the same filter with the in-memory provider and with PostgreSQL; do both support it?

★★★ Write an xUnit test that starts a pgvector container with Testcontainers. The test should verify that a record model with the wrong dimension throws VectorStoreException on upsert. What happens when you run the same test with the InMemory provider?

The vector store is now part of the Bookstore. But for now we answer "was recommending Working Effectively with Legacy Code correct?" by eye. In the next post we look at testing code that uses IChatClient with fake clients and measuring the model's answer quality with Microsoft.Extensions.AI.Evaluation.