Microsoft.Extensions.VectorData: Vector Store Abstraction'ı ile İlk Collection
Microsoft.Extensions.VectorData, kaydı attribute'larla tanımlayıp aynı kodla bellek içi store'dan PostgreSQL + pgvector'a geçmeni sağlar; embedding'i store'a verdiğin generator üretir. Gerçek çıktılarla filtreli aramayı, ScoreThreshold'u, distance function'ın skorun anlamını nasıl tersine çevirdiğini ve InMemory'nin yakalamadığı boyut hatasını görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; 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 ve Ollama 0.34.4 (nomic-embed-text) kullanılmıştır.
Önce Tahmin Et
Bookstore'a "anlamına göre kitap arama" ekliyorsun. Geliştirirken bellek içi bir vector store kullanıyorsun; bütün demolar çalışıyor, sonuçlar mantıklı. Sonra connection string'i PostgreSQL'e çeviriyorsun ve aynı kod ilk UpsertAsync çağrısında exception fırlatıyor. Kod aynı, abstraction aynı. Ne değişmiş olabilir?
İkinci soru: arama sonucunda bir kitabın skoru 0.1457, diğerinin 0.5359. Hangisi soruya daha yakın?
İki sorunun da cevabı bu yazıdaki gerçek çıktılarda. İkisi de kodun değil, store'un davranışıyla ilgili.
VectorData Neyi Soyutlar?
Embedding'in ne olduğu ve benzerliğin nasıl ölçüldüğü LLM (Large Language Model) Temelleri serisinin "LLM'lerde Embedding ve Benzerlik" yazısında anlatıldı. Orada vektörleri bir listede tutup cosine similarity'yi elle hesaplamıştık. Gerçek bir uygulamada vektörleri bir veritabanında saklarsın ve aramayı da veritabanı yapar.
Bunu doğrudan driver'la da yapabilirsin: Npgsql ile vector tipinde kolon açar, <=> operatörüyle SQL (Structured Query Language) yazarsın. Ama o kod tek bir veritabanına bağlı kalır ve serialization işi sende kalır. Microsoft.Extensions.VectorData.Abstractions paketi bunun üstüne ORM (Object-Relational Mapper) benzeri bir katman koyar: kaydı bir .NET class'ı olarak tanımlarsın, provider paketi onu hedef veritabanının şemasına çevirir. Soyut tipler VectorStore ve VectorStoreCollection<TKey, TRecord>; Azure AI (Artificial Intelligence) Search, Qdrant, Redis, PostgreSQL ve bellek içi provider'ın hepsi bu iki tipi uygular.
Provider paketlerinin adı yakın zamanda değişti. Eskiden Microsoft.SemanticKernel.Connectors.PgVector gibi adlarla yayımlanıyorlardı. NuGet'teki deprecation mesajına göre paket Semantic Kernel'e bağımlı olmadığı için CommunityToolkit.VectorData.PgVector adını aldı ve yeni adla v1 stabil oldu (PgVector için 1.0.0, 2026-07-22). İnternette bulduğun örneklerin çoğu hâlâ eski adları kullanıyor.
Kayıt Modeli: Key, Data ve 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; }
// Embedding'i store üretir: bu string property'nin vektörü saklanır
[VectorStoreVector(768, DistanceFunction = DistanceFunction.CosineSimilarity)]
public string Embedding => Blurb;
}
Üç attribute üç rolü belirler. [VectorStoreKey] kaydın kimliği. [VectorStoreData] düz bir kolon; IsIndexed = true provider'a bu alanla filtre yapılacağını söyler (PostgreSQL'de btree index'e dönüşüyor, birazdan göreceğiz). [VectorStoreVector] ise vektör kolonu: ilk parametre boyut sayısı, DistanceFunction benzerliğin nasıl ölçüleceği.
Vector property'nin tipi string. Bu, abstraction'ın embedding üretimini üstlendiği anlamına geliyor: store'a bir IEmbeddingGenerator verirsen, upsert sırasında bu metnin embedding'ini kendisi üretir ve kolona vektörü yazar. Metnin kendisi bu kolona yazılmaz. Bu yüzden açıklamayı ayrıca Blurb data property'sinde tutuyoruz ve vector property'yi ondan türetiyoruz. 768, nomic-embed-text'in ürettiği vektörün boyutu.
Store, Collection ve İlk Arama
Katalog sekiz gerçek kitaptan oluşuyor (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); her birinin craft, architecture ya da data kategorisi ve tek cümlelik bir İngilizce açıklaması var. Program store'u argümana göre seçiyor; geri kalan her şey yalnız abstraction'ı görüyor:
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";
// Uygulamanın geri kalanı yalnız VectorStore'u bilir
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 collection yoksa oluşturur, varsa dokunmaz. UpsertAsync aynı key'li kaydı günceller, yoksa ekler; burada sekiz kaydı tek çağrıda gönderiyoruz. SearchAsync doğrudan bir string alıyor; sorgunun embedding'ini de store'a verdiğimiz generator üretiyor. Filtre, LINQ (Language Integrated Query) expression'ı olarak yazılıyor ve provider bunu kendi sorgu diline çeviriyor. Bellek içi provider'la çalıştırınca:
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]
İlk sorguda "legacy" ya da "refactoring" kelimesi geçmiyor ama doğru kitap açık farkla en üstte. İkinci sorgu filtreyle yalnız iki data kitabını döndürüyor. Üçüncü sorgu ise önemli bir davranışı gösteriyor: veritabanı replikasyonu sorusuna "craft" filtresiyle Clean Code geliyor. Vector search "en yakın N kaydı" döndürür, "yeterince yakın olanları" değil. Soruyla ilgisi olmayan kayıtlar da düşük skorla listeye girer. Bunu ScoreThreshold ile keseceğiz.
InMemory'den PostgreSQL'e Kod Değiştirmeden
pgvector eklentisi kurulu bir PostgreSQL container'ı başlatıyoruz. Şifre bir ortam değişkeninden geliyor, yazıya ya da dosyaya girmiyor:
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]
Sonuçlar ve skorlar dört basamağa kadar aynı. Provider'ın veritabanında ne oluşturduğuna bakalım:
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")
Üç şey dikkat çekiyor. Embedding kolonu vector(768); boyut attribute'tan geliyor ve PostgreSQL onu şemada sabitliyor. IsIndexed = true dediğimiz Category için btree index açılmış. Vektör kolonunda ise index yok: provider her aramada tablonun tamamını tarıyor. Sekiz kayıt için sorun değil. On binlerce kayıtta ise yaklaşık en yakın komşu index'i istersin.
Distance Function, Index ve Skorun Anlamı
Attribute'u değiştirip yeni bir collection'la (books_hnsw) aynı programı çalıştırdık:
[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)
Sıralama aynı ama sayılar değişti: 0.8543 olan skor 0.1457 oldu. Açılıştaki ikinci sorunun cevabı bu. CosineSimilarity bir benzerliktir, büyük olan daha yakındır. CosineDistance ise mesafedir (1 − similarity), küçük olan daha yakındır. Skorun anlamı tamamen distance function'a bağlı. VectorSearchOptions.ScoreThreshold'un dokümantasyonu da eşiğin yönünün distance function'a göre değiştiğini söylüyor. Eşiği koda yazan herkes önce bu attribute'a bakmalı.
IndexKind.Hnsw ise pgvector'da bir HNSW (Hierarchical Navigable Small World) index'i açtı. HNSW yaklaşık arama yapar: çok büyük tablolarda tam taramadan hızlıdır, karşılığında nadiren gerçek en yakın komşuyu kaçırabilir. Distance function ve index tipinin hangi kombinasyonlarının desteklendiği provider'dan provider'a değişir; attribute'un dokümantasyonu "varsayılan değer veritabanına göre değişir" diyor. Bu yüzden ikisini de açıkça yazmak daha güvenli.
ASP.NET Core'a Bağlamak
ASP.NET Core Web API (Application Programming Interface) projesinde store'u elle oluşturmazsın. Provider paketi DI (Dependency Injection) extension'larıyla gelir:
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();
Options'ta embedding generator vermedik. Provider, generator'ı DI container'dan buldu: AddEmbeddingGenerator ile kaydettiğimiz IEmbeddingGenerator kullanıldı. Endpoint'i çağıralım:
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"
[]
Konsol uygulamasında "craft" filtresi üç alakasız kitap döndürüyordu. Burada ScoreThreshold = 0.5 sayesinde boş liste dönüyor. Kullanıcıya "bulamadım" demek, ilgisiz bir kitap önermekten iyidir. Bu kontrol, sonuçları LLM'e bağlam olarak vereceğin RAG (Retrieval-Augmented Generation) senaryosunda daha da önemli. 0.5 bu veri seti ve bu model için gözle seçilmiş bir değer; kendi verinde ölçerek belirlemelisin.
VectorData mı, EF Core mu?
Microsoft Learn'deki VectorData sayfası bu soruyu açıkça cevaplıyor: veritabanına zaten Entity Framework Core (EF Core) ile erişiyorsan ve provider'ın vector search'ü destekliyorsa, VectorData'ya her zaman ihtiyacın yoktur; aramayı LINQ ile yazabilirsin. Çoğu dedicated vector veritabanını ise EF Core desteklemez. Bunlar için VectorData iyi bir deneyim sunar. İkisini aynı uygulamada birlikte kullanmak da normaldir. Örneğin siparişler EF Core'la, doküman parçaları DataIngestion ve VectorData ile yönetilebilir.
Bookstore için pratik ayrım şöyle. Kitap kataloğu zaten EF Core'la PostgreSQL'de duruyorsa ve aramayı aynı tabloya bir vektör kolonu ekleyerek yapacaksan, EF Core yolu daha az parça demektir. Arama verisi ayrı bir sistemde (Azure AI Search, Qdrant, Redis) duracaksa ya da RAG pipeline'ı collection'ları kendisi yönetecekse VectorData doğru katmandır. EF Core ile vector search ve RAG pipeline'ının ayrıntıları ilerideki RAG serisinin konusu.
Üretimde Dikkat
- Embedding modelini değiştirmek migration demektir. nomic-embed-text'ten başka bir modele geçersen boyut da, vektör uzayı da değişir. Eski ve yeni vektörler karşılaştırılamaz. Yeni bir collection açıp bütün kayıtları yeniden embed etmen gerekir.
- Upsert'in maliyeti embedding üretimidir. 8 kayıt anında bitti; 100.000 kayıt için upsert'leri batch'le, embedding çağrılarını sınırla ve işi bir background job'a taşı.
EnsureCollectionExistsAsyncşemayı yalnız ilk seferde oluşturur, sonradan değiştirmez. Attribute'taki boyutu ya da distance function'ı değiştirdiğinde mevcut tablo eski haliyle kalır.- Filtrelenecek alanları
IsIndexed = trueile işaretle. Kategori, dil, tenant gibi filtreler büyük tablolarda index olmadan yavaşlar. ScoreThresholddeğerini ölçerek seç ve distance function'la birlikte versiyonla. Ölçmenin yolu bu serinin sonraki yazısında, evaluation tarafında.
Sık Düşülen Tuzaklar
Açılıştaki ilk sorunun cevabıyla başlayalım. Attribute'ta boyutu yanlışlıkla 1536 yazdık (OpenAI'ın text-embedding-3-small modelinin varsayılan boyutu), model ise 768 boyut üretiyor. Aynı kodu iki provider'la çalıştırdık. Bellek içi provider hiçbir şey demedi ve arama sonuçlarını döndürdü. PostgreSQL ise ilk upsert'te durdu:
Unhandled exception. Microsoft.Extensions.VectorData.VectorStoreException: Call to vector store failed.
---> Npgsql.PostgresException (0x80004005): 22000: expected 1536 dimensions, not 768
Bellek içi provider'la yazılmış bir test bu hatayı hiç yakalamaz. Microsoft Learn'deki vector store sayfası da aynı uyarıyı yapıyor: InMemory provider prototip için iyidir, test için değil; production veritabanıyla arasında önemli farklar olabilir. Bunun yerine testleri Testcontainers ile gerçek veritabanına karşı çalıştırmayı öneriyor. Skor yönü, filtre çevirisi, şema kısıtları ve index davranışı ancak gerçek provider'da görünür.
İkinci tuzak eski paket adları. Microsoft.SemanticKernel.Connectors.PgVector NuGet'te deprecated ve son sürümü 1.74.0-preview. Yeni projede CommunityToolkit.VectorData.* paketlerini kullan. Eski bir projeyi taşırken de namespace'lerin değiştiğini unutma.
Üçüncüsü, string vector property'den metni geri okumayı beklemek. Store yalnız vektörü yazar. Metni sonuçta göstermek istiyorsan ayrı bir [VectorStoreData] property'sinde tut, tıpkı Blurb gibi.
Dördüncüsü, skoru yorumlarken distance function'ı unutmak. 0.15 bir collection'da mükemmel eşleşme, başka bir collection'da alakasız sonuç olabilir.
Küçük Bir Deneme
★☆☆ Kataloğa Türkçe açıklamalı iki kitap ekle ve aynı soruları Türkçe sor. nomic-embed-text Türkçe sorguda İngilizce açıklamalı kitapları ne kadar iyi buluyor? Skorlar nasıl değişiyor?
★★☆ BookDocument'a [VectorStoreData(IsIndexed = true)] public int Year ekle ve b => b.Category == "craft" && b.Year >= 2000 filtresiyle ara. Aynı filtreyi bellek içi provider'da ve PostgreSQL'de dene; ikisi de destekliyor mu?
★★★ Testcontainers ile pgvector container'ı açan bir xUnit testi yaz. Test, boyutu yanlış verilmiş bir kayıt modelinin upsert'te VectorStoreException fırlattığını doğrulasın. Aynı testi InMemory provider'la çalıştırınca ne oluyor?
Vector store artık Bookstore'un bir parçası. Ama "Working Effectively with Legacy Code önerisi doğru muydu?" sorusunu şimdilik gözle cevaplıyoruz. Sonraki yazıda IChatClient kullanan kodu fake client'larla test etmeyi ve Microsoft.Extensions.AI.Evaluation ile modelin cevap kalitesini ölçmeyi göreceğiz.