Mert Özen Her Satırda Daha İleri
Yapay Zeka RAG — #07

EF Core ile Vector Search (2): PostgreSQL pgvector, HNSW Index ve Dapper

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
EF Core ile Vector Search (2): PostgreSQL pgvector, HNSW Index ve Dapper

Pgvector.EntityFrameworkCore ile Vector property, HNSW index ve CosineDistance sıralaması birkaç satırda kurulur; asıl risk sessiz hatalarda: vector_l2_ops ile kurulan index cosine sorgusunda hiç kullanılmadı, ReloadTypes unutulunca Npgsql vektörü yazamadı. Yetki filtresiyle arama, EXPLAIN kontrolü, halfvec ile storage'ı yarıya indirmek (30 sorunun 30'unda aynı top-5) ve aynı sorgu Dapper ile.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3, Pgvector.EntityFrameworkCore 0.3.0, Pgvector.Dapper 0.3.1 ve Dapper 2.1.89 ile derlenip PostgreSQL 17.11 + pgvector 0.8.7 ve Ollama 0.34.4 (nomic-embed-text) üzerinde denenmiştir.

Önce Tahmin Et

doc_chunks tablosunda HNSW (Hierarchical Navigable Small World) index'ini vector_l2_ops ile kurdun. Sorgularda ise embedding modelinin önerdiği cosine distance'ı, yani <=> operatörünü kullanıyorsun. PostgreSQL'e "sequential scan kullanma" dersen bile sorgu index'i kullanır mı? Kullanmazsa hata mı alırsın, yoksa sessizce yavaş mı çalışır?

Bu yazıda serinin vector store'unu kuruyoruz: Docker'da pgvector'lü PostgreSQL, EF (Entity Framework) Core ile mapping ve HNSW index, yetki filtresiyle arama, EXPLAIN ile index kontrolü, halfvec ile storage'ı yarıya indirmek ve aynı sorgunun Dapper ile yazılmış hali. Altıncı yazıda SQL (Structured Query Language) Server için kurup çalıştıramadığımız modelin PostgreSQL karşılığı bu kez gerçekten çalışıyor.

Docker, Bağlantı ve Model

pgvector'ın resmi image'ı PostgreSQL'i extension kurulu olarak getiriyor. Beşinci yazıda paralel HNSW kurulumunun Docker'ın varsayılan 64 MB'lık /dev/shm'sine sığmadığını görmüştük; bu yüzden --shm-size veriyorum:

$ docker run -d --name ragwork1-pg --shm-size=1g \
    -e POSTGRES_PASSWORD="$PGPW" -e POSTGRES_DB=bookstore_rag \
    -p 19701:5432 pgvector/pgvector:pg17

Bağlantı tarafında pgvector-dotnet'in README'sinin altını çizdiği bir ayrıntı var: dışarıda bir NpgsqlDataSource oluşturuyorsan UseVector()'ı hem data source builder'da hem de UseNpgsql'in options'ında çağırman gerekiyor. Biri Npgsql'e vector tipini öğretir, diğeri EF Core'a:

// Npgsql data source'una vector tipini tanıt, EF Core'a da aynı data source'u ver
NpgsqlDataSourceBuilder dataSourceBuilder = new(connectionString);
dataSourceBuilder.UseVector();
await using NpgsqlDataSource dataSource = dataSourceBuilder.Build();

DbContextOptions<RagDbContext> options = new DbContextOptionsBuilder<RagDbContext>()
    .UseNpgsql(dataSource, o => o.UseVector())
    .Options;

Model, altıncı yazıdaki SQL Server modelinin PostgreSQL karşılığı; vektör tipi SqlVector<float> değil Pgvector.Vector. HNSW index'i doğrudan model'de tanımlanıyor:

using Microsoft.EntityFrameworkCore;
using Pgvector;

namespace Bookstore.Rag.Postgres;

public class DocChunk
{
    public required string Id { get; set; }
    public required string DocId { get; set; }
    public required string Title { get; set; }
    public required string Section { get; set; }
    public required string Content { get; set; }
    public required string SourcePath { get; set; }
    public required string Tenant { get; set; }
    public required string Acl { get; set; }
    public DateOnly UpdatedAt { get; set; }
    public required string ContentHash { get; set; }
    public required string EmbeddingModel { get; set; }
    public required Vector Embedding { get; set; }
}

public class RagDbContext(DbContextOptions<RagDbContext> options) : DbContext(options)
{
    public DbSet<DocChunk> DocChunks => Set<DocChunk>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasPostgresExtension("vector");

        modelBuilder.Entity<DocChunk>(e =>
        {
            e.ToTable("doc_chunks");
            // Kolon adları snake_case: SQL ve Dapper tarafı aynı adları kullanır
            foreach (var property in e.Metadata.GetProperties())
            {
                property.SetColumnName(string.Concat(property.Name.Select((ch, i) =>
                    i > 0 && char.IsUpper(ch) ? "_" + char.ToLowerInvariant(ch) : char.ToLowerInvariant(ch).ToString())));
            }

            e.Property(c => c.Embedding).HasColumnType("vector(768)");

            e.HasIndex(c => c.Embedding)
                .HasDatabaseName("ix_doc_chunks_embedding")
                .HasMethod("hnsw")
                .HasOperators("vector_cosine_ops")
                .HasStorageParameter("m", 16)
                .HasStorageParameter("ef_construction", 64);

            e.HasIndex(c => new { c.Tenant, c.Acl });
        });
    }
}

HasPostgresExtension("vector") migration'a ya da EnsureCreated'a CREATE EXTENSION ekletiyor. HasMethod("hnsw") index türünü, HasOperators("vector_cosine_ops") operator class'ı, HasStorageParameter da m ve ef_construction'ı veriyor. Bağlantı açmadan aldığım DDL (Data Definition Language):

$ dotnet run -- schema
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE doc_chunks (
    id text NOT NULL,
    doc_id text NOT NULL,
    title text NOT NULL,
    section text NOT NULL,
    content text NOT NULL,
    source_path text NOT NULL,
    tenant text NOT NULL,
    acl text NOT NULL,
    updated_at date NOT NULL,
    content_hash text NOT NULL,
    embedding_model text NOT NULL,
    embedding vector(768) NOT NULL,
    CONSTRAINT "PK_doc_chunks" PRIMARY KEY (id)
);
CREATE INDEX ix_doc_chunks_embedding ON doc_chunks USING hnsw (embedding vector_cosine_ops) WITH (ef_construction=64, m=16);
CREATE INDEX "IX_doc_chunks_tenant_acl" ON doc_chunks (tenant, acl);

Tablo, üçüncü yazıda artımlı sync için SQL ile kurduğumuz doc_chunks ile birebir aynı ve index beklediğimiz gibi USING hnsw (embedding vector_cosine_ops).

Chunk'ları Yüklemek

Her doküman için standart chunker'ın ürettiği chunk'lar tek bir istekte embed ediliyor ve dokümanın hash'i, modelin adıyla birlikte yazılıyor (üçüncü yazıdaki artımlı güncelleme bu iki kolona dayanıyor):

static async Task<int> LoadAsync(RagDbContext db, IEmbeddingGenerator<string, Embedding<float>> embedder, List<SourceDocument> docs)
{
    foreach (SourceDocument doc in docs)
    {
        List<Chunk> chunks = Chunkers.Section([doc]);
        GeneratedEmbeddings<Embedding<float>> embeddings =
            await embedder.GenerateAsync(chunks.Select(c => "search_document: " + c.Content));
        string hash = IncrementalIndexer.DocumentHash(doc);
        db.DocChunks.AddRange(chunks.Select((c, i) => new DocChunk
        {
            Id = c.Id,
            DocId = c.DocId,
            Title = c.Title,
            Section = c.Section,
            Content = c.Content,
            SourcePath = c.SourcePath,
            Tenant = c.Tenant,
            Acl = c.Acl,
            UpdatedAt = c.UpdatedAt,
            ContentHash = hash,
            EmbeddingModel = embeddings[i].ModelId ?? "unknown",
            Embedding = new Vector(embeddings[i].Vector)
        }));
    }
    return await db.SaveChangesAsync();
}
$ dotnet run -- load
inserted 53 chunks in 368 ms

$ dotnet run -- recall
pgvector recall@1=16/30 recall@5=25/30

53 chunk yazıldı. Süreyi embedding hızı olarak okuma: bu deneylerde daha önce üretilmiş embedding'ler çalışma klasöründeki bir disk cache'inden geliyor, 368 ms çoğunlukla insert. Daha önemli satır ikincisi: pgvector üzerindeki arama, ikinci yazıda bellekteki listeyle ölçtüğümüz sonucun aynısını verdi (recall@1 16/30, recall@5 25/30). Depolama değişti, retrieval kalitesi değişmedi; olması gereken de bu.

CosineDistance ile Arama ve Yetki Filtresi

Pgvector.EntityFrameworkCore, Vector üzerinde L2Distance, MaxInnerProduct, CosineDistance, L1Distance gibi extension method'lar sunuyor; EF Core bunları pgvector operatörlerine çeviriyor. Arama sorgusu tenant ve yetki filtresini, sıralamayı ve projection'ı bir arada tutuyor:

static IQueryable<SearchHit> SearchQuery(RagDbContext db, Vector queryVector, string tenant, string[] acl, int k) =>
    db.DocChunks
        .Where(c => c.Tenant == tenant && acl.Contains(c.Acl))
        .OrderBy(c => c.Embedding.CosineDistance(queryVector))
        .Select(c => new SearchHit(c.Id, c.DocId, c.Section, c.Embedding.CosineDistance(queryVector)))
        .Take(k);

public sealed record SearchHit(string Id, string DocId, string Section, double Distance);
$ dotnet run -- sql
-- @queryVector='[0,0,0,...]' (DbType = Object)
-- @tenant='bookstore'
-- @acl={ 'public' } (DbType = Object)
-- @p='5'
SELECT d.id, d.doc_id, d.section, d.embedding <=> @queryVector
FROM doc_chunks AS d
WHERE d.tenant = @tenant AND d.acl = ANY (@acl)
ORDER BY d.embedding <=> @queryVector
LIMIT @p

Üretilen SQL tam istediğimiz gibi: filtre WHERE'de, sıralama embedding <=> @queryVector ile, embedding kolonu SELECT'te yok (parametre satırındaki sıfır vektörü okunur kalsın diye kısalttım). Gerçek bir soruyla:

$ dotnet run -- ask "Ekspres teslimat ücreti ne kadar?"
0.203  kargo-ve-teslimat#1          Ekspres teslimat
0.282  kargo-ve-teslimat#0          Standart kargo
0.314  kargo-ve-teslimat#5          Teslim edilemeyen gönderiler

Distance, cosine distance (1 eksi cosine similarity); küçük olan daha yakın. Ekspres teslimat bölümü 0.203 ile en üstte. Birinci yazıdaki yetki sızıntısını aynı sorguyla tekrar deneyelim:

$ dotnet run -- ask "Çalışanlarınıza indirim yapıyor musunuz?"
0.306  sadakat-programi#1           Puan kullanmak
0.338  kurumsal-satis-sozlesmesi#0  1. Toplu alım indirimi
0.339  sadakat-programi#2           Puanların süresi

$ dotnet run -- ask "Çalışanlarınıza indirim yapıyor musunuz?" public,staff
0.300  personel-indirimi#0          Personel indirimi
0.306  sadakat-programi#1           Puan kullanmak
0.330  personel-indirimi#2          Müşteriye söylenmemesi gerekenler

Müşterinin aramasında personel-indirimi chunk'ları aday bile olmuyor; personel rolüyle aynı soru 0.300 ile o chunk'ı en üste getiriyor. Filtre SQL'in içinde olduğu için yetkisiz satır uygulamaya hiç taşınmıyor.

Index Kullanılıyor mu? EXPLAIN ve Operator Class

53 satırlık bir tabloda PostgreSQL index'i zaten kullanmaz; bütün tabloyu taramak daha ucuz. Index'in bu sorgu tarafından kullanılabilir olup olmadığını görmek için transaction içinde enable_seqscan'i kapatıp planı istedim, önce index'i vector_l2_ops ile, sonra vector_cosine_ops ile kurarak:

SqlMapper.AddTypeHandler(new VectorTypeHandler());
await using NpgsqlConnection conn = await dataSource.OpenConnectionAsync();
string ops = args[1] == "l2" ? "vector_l2_ops" : "vector_cosine_ops";
await conn.ExecuteAsync($"""
    DROP INDEX IF EXISTS ix_doc_chunks_embedding;
    CREATE INDEX ix_doc_chunks_embedding ON doc_chunks USING hnsw (embedding {ops}) WITH (m = 16, ef_construction = 64);
    """);
Vector queryVector = new(await embedder.GenerateVectorAsync("search_query: Ekspres teslimat ücreti ne kadar?"));
await using NpgsqlTransaction tx = await conn.BeginTransactionAsync();
await conn.ExecuteAsync("SET LOCAL enable_seqscan = off", transaction: tx);
IEnumerable<string> plan = await conn.QueryAsync<string>("""
    EXPLAIN (COSTS OFF)
    SELECT id FROM doc_chunks ORDER BY embedding <=> @queryVector LIMIT 5
    """, new { queryVector }, tx);
Console.WriteLine($"index: {ops}");
foreach (string line in plan) Console.WriteLine(line);
$ dotnet run -- explain l2
index: vector_l2_ops
Limit
  ->  Sort
        Sort Key: ((embedding <=> '[-0.02134457,0.02082554,...]'::vector))
        ->  Seq Scan on doc_chunks

$ dotnet run -- explain cosine
index: vector_cosine_ops
Limit
  ->  Index Scan using ix_doc_chunks_embedding on doc_chunks
        Order By: (embedding <=> '[-0.02134457,0.02082554,...]'::vector)

Açılıştaki sorunun cevabı: hata yok, uyarı yok. vector_l2_ops ile kurulan index, cosine operatörü <=> kullanan bir sıralamaya hizmet edemediği için planner'ın elinde tek seçenek kalıyor: sequential scan ve sort. Seq scan'i kapatmış olmamız bile bunu değiştirmiyor. Operator class'ı vector_cosine_ops yapınca plan Index Scan using ix_doc_chunks_embedding'e dönüyor. Bu hata 53 satırda hiç fark edilmez; bir milyon satırda her sorgu saniyeler sürer ve sebebi yalnız EXPLAIN'da görünür.

Sorgu anındaki hnsw.ef_search ise oturum ya da transaction bazında ayarlanıyor. Beşinci yazıda varsayılan 40'ın 100.000 sentetik vektörde 0.830 recall verdiğini, 100'ün 0.898'e çıkardığını görmüştük. Aşağıdaki Dapper örneğinde bunu SET LOCAL ile yalnız o transaction için yapıyorum; böylece bağlantı havuza döndüğünde ayar sızmıyor.

halfvec ile Storage'ı Yarıya İndirmek

pgvector'ın halfvec tipi her boyutu 2 byte'lık yarı hassasiyetli float olarak saklıyor. Mevcut kolonu dönüştürüp ayrı bir HNSW index'iyle karşılaştırdım:

await conn.ExecuteAsync("""
    ALTER TABLE doc_chunks ADD COLUMN IF NOT EXISTS embedding_half halfvec(768);
    UPDATE doc_chunks SET embedding_half = embedding::halfvec(768);
    CREATE INDEX IF NOT EXISTS ix_doc_chunks_embedding_half ON doc_chunks USING hnsw (embedding_half halfvec_cosine_ops);
    """);
$ dotnet run -- halfvec
vector=3076 B/row, halfvec=1542 B/row, index: 224 kB vs 120 kB
identical top-5 with halfvec: 30/30

Satır başına 3.076 byte'tan 1.542 byte'a, index 224 kB'tan 120 kB'a indi ve 30 sorunun 30'unda ilk 5 sonuç birebir aynı çıktı. nomic-embed-text'in normalize edilmiş, küçük değerli vektörlerinde yarım hassasiyet sıralamayı değiştirmedi. Bu sonucu kendi modelinle ve kendi soru setinle doğrula; ama bir milyon chunk'ta 1,5 GB'lık tasarruf, ölçmeye değer bir kazanç. pgvector README'sine göre HNSW index'i vector için 2.000, halfvec için 4.000 boyuta kadar destekliyor; 3.072 boyutlu bir model kullanıyorsan HNSW index kurmanın en basit yolu halfvec.

Aynı Sorgu Dapper ile

EF Core'un çevirisinin ötesine geçmek istediğinde (oturum ayarları, CTE (Common Table Expression), ileride hybrid search) SQL'i elle yazmak daha rahat. Dapper serisinde anlattığımız mekanik aynı; tek ek, Vector için bir type handler kaydetmek:

SqlMapper.AddTypeHandler(new VectorTypeHandler());
await using NpgsqlConnection conn = await dataSource.OpenConnectionAsync();
Vector queryVector = new(await embedder.GenerateVectorAsync("search_query: " + args[1]));
await using NpgsqlTransaction tx = await conn.BeginTransactionAsync();
// ef_search yalnız bu transaction için
await conn.ExecuteAsync("SET LOCAL hnsw.ef_search = 100", transaction: tx);
IEnumerable<ChunkHit> hits = await conn.QueryAsync<ChunkHit>("""
    SELECT id, section, embedding <=> @queryVector AS distance
    FROM doc_chunks
    WHERE tenant = @tenant AND acl = ANY(@acl) AND updated_at >= @since
    ORDER BY embedding <=> @queryVector
    LIMIT 3
    """,
    new { queryVector, tenant = "bookstore", acl = new[] { "public" }, since = new DateOnly(2026, 1, 1) }, tx);
foreach (ChunkHit h in hits) Console.WriteLine($"{h.Distance:F3}  {h.Id,-28} {h.Section}");
$ dotnet run -- dapper "Ekspres teslimat ücreti ne kadar?"
0.203  kargo-ve-teslimat#1          Ekspres teslimat
0.282  kargo-ve-teslimat#0          Standart kargo
0.314  kargo-ve-teslimat#5          Teslim edilemeyen gönderiler

Sonuç EF Core sorgusuyla aynı; bu kez filtreye updated_at da eklendi. Type handler'ı kaydetmeyi unutursan Dapper sorguyu çalıştırmadan reddediyor; bu yazının ilk denemesinde explain komutunda tam olarak bu oldu:

Unhandled exception. System.NotSupportedException: The member queryVector of type Pgvector.Vector cannot be used as a parameter value

Tip Kaydı: UseVector ve ReloadTypes

Npgsql bir veritabanının tiplerini data source'un ilk bağlantısında yükleyip cache'liyor. vector extension'ını uygulama çalışırken, aynı data source'tan açılmış bir bağlantıda oluşturursan o cache'te vector tipi yok. Yeni bir veritabanında denedim:

// Yeni bir veritabanında extension'ı bu bağlantıda oluşturup ReloadTypes çağırmamak
await using NpgsqlConnection conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync("DROP EXTENSION IF EXISTS vector CASCADE; CREATE EXTENSION vector;");
if (args.Contains("--reload")) await conn.ReloadTypesAsync();
try
{
    await using NpgsqlCommand cmd = new("SELECT @v::vector <=> @v::vector", conn);
    cmd.Parameters.AddWithValue("v", new Vector(new float[] { 1, 2, 3 }));
    Console.WriteLine($"distance = {await cmd.ExecuteScalarAsync()}");
}
catch (Exception ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
$ PGDB=reloadtest dotnet run -- reload
InvalidCastException: Writing values of 'Pgvector.Vector' is not supported for parameters having no NpgsqlDbType or DataTypeName. Try setting one of these values to the expected database type..

$ PGDB=reloadtest dotnet run -- reload --reload
distance = 0

ReloadTypesAsync() olmadan Npgsql Vector parametresini yazamadı; çağırınca sorgu çalıştı. Üçüncü yazıdaki sync komutu bu yüzden CREATE EXTENSION'dan hemen sonra ReloadTypesAsync() çağırıyor. Production'da extension'ı uygulama başlamadan, migration ile kurmak bu sorunu tamamen ortadan kaldırır.

Üretimde Dikkat

Index'i veriyle birlikte düşün. HNSW boş tabloda da kurulabilir ve satırlar eklendikçe güncellenir; ama toplu yüklemede index'i yüklemeden sonra kurmak çok daha hızlıdır. Büyük bir yeniden embed işleminde (ikinci yazıdaki model değişimi) yeni kolonu doldur, index'i en son kur.

Filtre kolonlarına B-tree index ekle. Modeldeki (tenant, acl) index'i, beşinci yazıda gördüğümüz pre-filter planının ön koşulu: seçici bir tenant filtresinde planner önce bu index'le satırları daraltıp sonra exact distance hesaplayabilir. Filtreyle HNSW'nin birlikte kullanıldığı sorgularda pgvector 0.8'in iterative scan ayarını ve dönen satır sayısını izle.

Ayarları transaction'a bağla. hnsw.ef_search ya da hnsw.iterative_scan gibi ayarları SET ile değil SET LOCAL ile ver; connection pool'da bir isteğin ayarı bir sonrakine taşınmasın.

Şemayı migration'la yönet. Bu yazıda deney için EnsureCreated kullandım; production'da EF Core migration'ı HasPostgresExtension sayesinde CREATE EXTENSION'ı, HasMethod ve HasOperators sayesinde HNSW index'ini de üretiyor. Büyük bir tabloda index'i değiştirmek (ör. m'yi artırmak ya da operator class'ı düzeltmek) dakikalar sürebilir ve sırasında yazma işlemleri yavaşlar; bu migration'ı ayrı bir bakım penceresinde, mümkünse CREATE INDEX CONCURRENTLY ile elle yazılmış bir SQL adımı olarak çalıştır. EF Core serisinin "EF Core Migrations" yazısında gördüğümüz migrationBuilder.Sql ile elle SQL adımı ekleme yaklaşımı tam olarak bunun için.

Embedding kolonunu gereksiz yere okuma. Altıncı yazıda SQL Server için gördüğümüz durum burada da geçerli: projection'sız DocChunks sorguları her satır için 3 KB taşır. Listeleme ekranlarında ve hash kontrollerinde projection kullan.

Sık Düşülen Tuzaklar

Birincisi, operator class'ı metric'le uyumsuz seçmek. Hata vermez; index sessizce kullanılmaz. Her yeni sorgu şekli için EXPLAIN'a bak ve planda Index Scan using ...embedding satırını ara.

İkincisi, UseVector()'ı yalnız bir yerde çağırmak. Data source builder'da çağırıp UseNpgsql options'ında unutursan EF Core Vector property'sini map edemez; tersinde Npgsql parametreyi yazamaz.

Üçüncüsü, ReloadTypes()'ı unutmak. Extension uygulama çalışırken oluşturulduysa, aynı data source'tan gelen bağlantılar vector tipini bilmez; yukarıdaki InvalidCastException.

Dördüncüsü, Dapper'da type handler kaydını unutmak. SqlMapper.AddTypeHandler(new VectorTypeHandler()) uygulama başlarken bir kez çağrılmalı; aksi halde NotSupportedException alırsın.

Beşincisi, küçük tabloda "index çalışıyor" sanmak. 53 satırda planner seq scan seçer ve sonuçlar doğrudur; index'in yanlış kurulduğunu ancak büyük veride ya da enable_seqscan = off ile görürsün.

Küçük Bir Deneme

★☆☆ ask komutunu CosineDistance yerine MaxInnerProduct ile çalıştır. nomic-embed-text'in vektörleri normalize olduğu için sıralama aynı kalıyor mu? Hangi operator class'la index kurman gerekir?

★★☆ doc_chunks'a beşinci yazıdaki sentetik veriden 100.000 satır ekle (tenant'ı farklı olsun) ve tenant = 'bookstore' filtreli sorgunun planına bak. Planner hangi index'i seçiyor, iterative scan gerekiyor mu?

★★★ embedding_half kolonunu EF Core modeline HalfVector property'si olarak ekle, HNSW index'ini halfvec_cosine_ops ile model'de tanımla ve SearchQuery'yi bu kolona taşı. 30 soruda recall değişiyor mu?

Blok A burada bitiyor: ingestion, chunking, embedding ve vector store hazır. Ama retrieval hâlâ "bedava" ile "ücretsiz"i, BKS-20114 gibi ürün kodlarını kaçırıyor. Bir sonraki yazıda keyword aramasını vector aramayla birleştiren hybrid search'e ve Reciprocal Rank Fusion'a geçiyoruz.