Hybrid Search ve Reciprocal Rank Fusion: Keyword ile Vector Aramayı Birleştirmek
Vector search ürün kodu, ISBN ve nadir terimleri kaçırır; PostgreSQL tsvector (turkish) ile keyword aramayı ekleyip iki listeyi skorla değil sırayla, Reciprocal Rank Fusion ile birleştirmek bu açığı kapatır. EF Core, tek SQL sorgusu ve VectorData HybridSearchAsync ile kuruyor, 30 soruda vector, keyword ve hybrid recall'unu ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Entity Framework Core 10 (Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3, Pgvector.EntityFrameworkCore 0.3.0), Dapper 2.1.89, CommunityToolkit.VectorData.PgVector 1.0.2, PostgreSQL 17 + pgvector 0.8.7 ve Ollama'daki nomic-embed-text ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un ürün kataloğunda 14 ürün var ve her birinin BKS-20114 gibi bir stok kodu (SKU, Stock Keeping Unit) var. Müşteri asistanına "BKS-20114 kodlu kitabın fiyatı nedir?" diye soruyor. Embedding'leri nomic-embed-text ile üretilmiş 53 chunk'lık index'te vector search en yakın beş chunk'ı getiriyor. Sence doğru ürün olan Designing Data-Intensive Applications bu beşin içinde mi?
Cevap: değil. Beş sonucun beşi de ürün kataloğundan, hepsinde bir BKS- kodu var, ama hiçbiri BKS-20114 değil. Embedding modeli "bu bir ürün kodu sorusu" kısmını çok iyi anlıyor; hangi kod olduğunu ise umursamıyor. Bu yazıda vector search'ün kaçırdığını keyword search ile yakalayacak, iki listeyi Reciprocal Rank Fusion (RRF) ile birleştirecek ve seri boyunca kullandığımız 30 soruluk setle bunun gerçekten ne kazandırdığını ölçeceğiz.
Vector Search Neyi Kaçırır?
Embedding, metnin anlamını sıkıştırılmış bir vector'e çevirir. Bu sıkıştırma "kitap yırtık geldi" ile "hasarlı ürün" arasındaki bağı kurar; keyword search bunu asla yapamaz. Ama aynı sıkıştırma nadir ve keyfi string'leri kaybeder: stok kodu, ISBN (International Standard Book Number), sipariş numarası, özel isim, sürüm numarası. BKS-20114 ile BKS-20107 embedding uzayında neredeyse aynı noktadır, çünkü model için ikisi de "ürün kodu gibi görünen bir şey"dir.
İlk denemenin gerçek çıktısı:
$ dotnet run -- query "BKS-20114 kodlu kitabın fiyatı nedir?"
-- vector
1. urun-katalogu#9 0,7095 Kodlamayı Öğreniyorum: Çocuklar için Scratch (BKS-30042)
2. urun-katalogu#6 0,7021 Clean Code (BKS-20107)
3. urun-katalogu#10 0,6962 Dünya Atlası - Okul Baskısı (BKS-30077)
4. urun-katalogu#12 0,6938 Kitap Ayracı Seti - 6'lı (BKS-40011)
5. urun-katalogu#4 0,6922 Simyacı (BKS-10610)
Benzerlik skorlarına bak: 0,7095 ile 0,6922 arasında yalnız 0,017 fark var. Model ürünleri birbirinden ayıramıyor, hepsi aynı torbada. Doğru chunk (urun-katalogu#7) altıncı sırada. Seriyi buraya kadar takip ettiysen bu tabloyu tanıyorsun: serinin ilk yazısında "retrieval kaçırdı" diye sınıfladığımız hatanın en tipik örneği.
PostgreSQL Full-Text Search: tsvector ve turkish Config'i
Keyword tarafında ayrı bir arama motoru kurmak zorunda değilsin; PostgreSQL'in full-text search'ü zaten var. Fikir şu: her chunk'ın metnini bir kez tsvector'e (normalize edilmiş kelime kökleri ve pozisyonları) çevirip saklamak, GIN (Generalized Inverted Index) ile index'lemek ve sorguyu tsquery'ye çevirip eşleştirmek. Entity Framework Core'un (EF Core) Npgsql provider'ı bunu model configuration'ına taşıyor:
public sealed class DocChunk
{
public required string Id { get; init; }
public required string DocId { get; init; }
public required string Title { get; init; }
public required string Section { get; init; }
public required string Content { get; init; }
public required string Acl { get; init; }
public Vector? Embedding { get; init; }
public NpgsqlTsVector SearchVector { get; init; } = null!;
}
public sealed class RagDbContext(DbContextOptions<RagDbContext> options) : DbContext(options)
{
public DbSet<DocChunk> Chunks => Set<DocChunk>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<DocChunk>(b =>
{
b.ToTable("doc_chunks");
b.Property(c => c.Id).HasColumnName("id");
b.Property(c => c.DocId).HasColumnName("doc_id");
b.Property(c => c.Title).HasColumnName("title");
b.Property(c => c.Section).HasColumnName("section");
b.Property(c => c.Content).HasColumnName("content");
b.Property(c => c.Acl).HasColumnName("acl");
b.Property(c => c.Embedding).HasColumnName("embedding").HasColumnType("vector(768)");
// Türkçe stemming ile STORED generated tsvector kolonu ve GIN index
b.HasGeneratedTsVectorColumn(c => c.SearchVector, "turkish", c => new { c.Content })
.HasIndex(c => c.SearchVector)
.HasMethod("GIN");
b.Property(c => c.SearchVector).HasColumnName("search_vector");
});
}
}
db.Database.GenerateCreateScript() bu modelden şu DDL'i (Data Definition Language) üretiyor; mevcut doc_chunks tablosuna yalnız son iki parçayı ALTER TABLE ile ekledim:
CREATE TABLE doc_chunks (
id text NOT NULL,
...
embedding vector(768),
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('turkish', content)) STORED NOT NULL,
CONSTRAINT "PK_doc_chunks" PRIMARY KEY (id)
);
CREATE INDEX "IX_doc_chunks_search_vector" ON doc_chunks USING GIN (search_vector);
Generated column olduğu için uygulama kodu search_vector'a hiç yazmaz; ingestion content'i güncellediğinde PostgreSQL kolonu kendisi yeniden hesaplar. Ingestion yazısındaki artımlı güncelleme mantığı burada bedavaya geliyor.
Türkçe Stemming'i Gözünle Gör
turkish config'i Snowball stemmer'ını kullanır. "kitap" ile "kitabı"yı aynı köke indirmesini istiyoruz. Gerçekte ne yaptığına bakalım:
bookstore_rag=# select w, to_tsvector('turkish', w)
from unnest(array['iade','iadesi','iadeler','kitabı','kitaplar','kargom','kargosu','kartı','kartımı','BKS-20114']) w;
w | to_tsvector
-----------+--------------------
iade | 'ia':1
iadesi | 'iades':1
iadeler | 'iade':1
kitabı | 'kitap':1
kitaplar | 'kitap':1
kargom | 'kargo':1
kargosu | 'kargos':1
kartı | 'kar':1
kartımı | 'kart':1
BKS-20114 | '-20114':2 'bks':1
(10 rows)
"kitabı" ve "kitaplar" doğru şekilde kitap'a iniyor. Ama "iade" ia'ya, "iadesi" iades'e, "iadeler" iade'ye gidiyor: aynı kelimenin üç hali üç farklı kök. "kartı" kar olurken "kartımı" kart oluyor. Stemmer kural tabanlı ve Türkçenin eklemeli yapısında sık sık ya fazla kesiyor ya eksik. Belgede "iade", soruda "iadesi" geçerse eşleşme olmaz. Ürün koduna da dikkat: parser BKS-20114'ü bks ve -20114 olarak iki token'a bölüyor; doküman da sorgu da aynı parser'dan geçtiği için eşleşme yine de çalışıyor.
Doğal Dildeki Soruyu tsquery'ye Çevirmek
Npgsql'in LINQ (Language Integrated Query) çevirisiyle keyword search şöyle görünüyor:
public async Task<List<Hit>> KeywordAsync(string query, int k, bool matchAll = false)
{
// websearch_to_tsquery kelimeleri AND ile bağlar; doğal dildeki soru için "or" ile OR'a çeviriyoruz
string web = matchAll ? query : ToOrQuery(query);
return await db.Chunks
.Where(c => c.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("turkish", web)))
.OrderByDescending(c => c.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("turkish", web)))
.Take(k)
.Select(c => new Hit(c.Id, c.Section,
c.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("turkish", web))))
.ToListAsync();
}
public static string ToOrQuery(string query) => string.Join(" or ", query
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Trim('"', '-', '?', '!', '.', ','))
.Where(w => w.Length > 0));
ToOrQuery neden var? websearch_to_tsquery ve plainto_tsquery kelimeleri AND ile bağlar. "Bir kitabı kaç gün içinde iade edebilirim?" sorusu 'bir' & 'kitap' & 'kaç' & 'gün' & 'iç' & 'ia' & 'edebilir' olur ve bu yedi kökün hepsini içeren chunk yoktur. Ölçünce durum net:
$ dotnet run -- eval 5 --and
recall@5 (30 questions, keyword AND): vector 25/30, keyword 3/30, hybrid 25/30
AND ile keyword search 30 sorunun yalnız 3'ünde doğru chunk'ı ilk beşe koyuyor; çoğu soruda hiç sonuç dönmüyor. Kelimeleri or ile bağlayınca websearch_to_tsquery bunu OR olarak yorumluyor. Üstelik bu fonksiyon dokümantasyonuna göre kullanıcı girdisinde asla syntax hatası fırlatmaz; baştaki - ve tırnakları temizlememizin sebebi, onların NOT ve phrase operatörü olarak yorumlanması. Sıralamada ts_rank kullanıyorum, ts_rank_cd değil: OR sorgusunda ts_rank_cd SKU sorusundaki ilk altı chunk'a aynı 0,3 skorunu verdi, ts_rank ise doğru ürünü ayırdı.
Reciprocal Rank Fusion: Skoru Değil Sırayı Birleştir
Elimizde iki liste var: biri cosine benzerliğiyle (0,6–0,75 bandında), diğeri ts_rank ile (0,01–0,05 bandında) sıralanmış. Bu iki sayı aynı şeyi ölçmüyor ve aynı ölçekte değil. RRF, Cormack, Clarke ve Büttcher'in 2009 SIGIR (Special Interest Group on Information Retrieval) makalesinde önerdiği basit bir çözüm: skorları tamamen at, yalnız sıraya bak. Bir dokümanın skoru, geçtiği her listede 1 / (k + rank) değerlerinin toplamı; makaledeki sabit k = 60.
// Reciprocal Rank Fusion: yalnız sıra (rank) kullanılır, ham skor değil
public static List<Hit> Rrf(int k, int top, params List<Hit>[] rankings) =>
WeightedRrf(k, top, rankings.Select(r => (r, 1.0)).ToArray());
public static List<Hit> WeightedRrf(int k, int top, params (List<Hit> Ranking, double Weight)[] rankings)
{
Dictionary<string, (Hit Hit, double Score)> fused = [];
foreach ((List<Hit> ranking, double weight) in rankings)
{
for (int rank = 1; rank <= ranking.Count; rank++)
{
Hit hit = ranking[rank - 1];
double add = weight / (k + rank);
fused[hit.Id] = fused.TryGetValue(hit.Id, out var e) ? (e.Hit, e.Score + add) : (hit, add);
}
}
return fused.Values
.OrderByDescending(x => x.Score)
.Take(top)
.Select(x => x.Hit with { Score = x.Score })
.ToList();
}
Her iki listeden 20 aday alıp birleştiriyoruz. SKU sorusunda sonuç:
-- keyword
1. urun-katalogu#7 0,0405 Designing Data-Intensive Applications (BKS-20114)
2. urun-katalogu#13 0,0329 Sesli Kitap: Kürk Mantolu Madonna (BKS-50518)
3. urun-katalogu#12 0,0329 Kitap Ayracı Seti - 6'lı (BKS-40011)
...
-- hybrid (RRF k=60)
1. urun-katalogu#6 0,0318 Clean Code (BKS-20107)
2. urun-katalogu#7 0,0315 Designing Data-Intensive Applications (BKS-20114)
3. urun-katalogu#12 0,0315 Kitap Ayracı Seti - 6'lı (BKS-40011)
Keyword search doğru ürünü birinci getirdi. Hybrid ikinci sıraya koydu: Clean Code vector listesinde 2., keyword listesinde 4. olduğu için toplamda bir tık önde. RRF "iki listede de makul yerde olanı" ödüllendirir; bir listede birinci, diğerinde altıncı olan kazanamayabilir. Bunu aklında tut, ölçüm bölümünde geri döneceğiz.
Aynı Şey Tek SQL Sorgusunda
İki round trip yerine füzyonu veritabanına da yaptırabilirsin. Dapper serisinde gördüğümüz düz SQL (Structured Query Language) yaklaşımı ve iki CTE (Common Table Expression) ile:
WITH semantic AS (
SELECT id, ROW_NUMBER() OVER () AS rank
FROM (SELECT id FROM doc_chunks ORDER BY embedding <=> @Embedding LIMIT @Candidates) v
),
keyword AS (
SELECT id, ROW_NUMBER() OVER () AS rank
FROM (SELECT c.id FROM doc_chunks c, websearch_to_tsquery('turkish', @Query) q
WHERE c.search_vector @@ q
ORDER BY ts_rank(c.search_vector, q) DESC
LIMIT @Candidates) t
)
SELECT COALESCE(s.id, k.id) AS id, d.section,
s.rank::int AS vectorrank, k.rank::int AS keywordrank,
COALESCE(1.0 / (@K + s.rank), 0.0) + COALESCE(1.0 / (@K + k.rank), 0.0)::float8 AS score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id
JOIN doc_chunks d ON d.id = COALESCE(s.id, k.id)
ORDER BY score DESC
LIMIT @Top
$ dotnet run -- sql "BKS-20114 kodlu kitabın fiyatı nedir?"
urun-katalogu#6 vector=2 keyword=4 0,0318
urun-katalogu#7 vector=6 keyword=1 0,0315
urun-katalogu#12 vector=4 keyword=3 0,0315
urun-katalogu#13 vector=9 keyword=2 0,0306
urun-katalogu#9 vector=1 keyword=14 0,0299
C# versiyonuyla birebir aynı sıra; bu kez her satırın hangi listeden kaçıncı geldiğini de görüyorsun. FULL OUTER JOIN kritik: yalnız bir listede geçen chunk da sonuca girmeli. pgvector'ün kendi README'sindeki hybrid örneği de aynı kurguyu kullanıyor.
Microsoft.Extensions.VectorData ile Hybrid Search
Microsoft.Extensions.VectorData abstraction'ının kendisini ilerideki .NET ile Yapay Zeka serisinin 09. yazısına bırakıyoruz; burada yalnız hybrid tarafını kullanacağız. Hybrid destekleyen provider'lar IKeywordHybridSearchable<TRecord> interface'ini implemente eder. PostgreSQL provider'ı artık CommunityToolkit.VectorData.PgVector paketinde; full-text dilini property bazında vermek için attribute yerine definition kullanmak gerekiyor, çünkü varsayılan dil english:
static readonly VectorStoreCollectionDefinition Definition = new()
{
Properties =
[
new VectorStoreKeyProperty(nameof(ChunkRecord.Id), typeof(string)) { StorageName = "id" },
new VectorStoreDataProperty(nameof(ChunkRecord.Section), typeof(string)) { StorageName = "section" },
new VectorStoreDataProperty(nameof(ChunkRecord.Content), typeof(string)) { StorageName = "content", IsFullTextIndexed = true }
.WithFullTextSearchLanguage("turkish"),
new VectorStoreVectorProperty(nameof(ChunkRecord.Embedding), typeof(ReadOnlyMemory<float>), 768)
{ StorageName = "embedding", DistanceFunction = DistanceFunction.CosineDistance },
]
};
using PostgresCollection<string, ChunkRecord> collection =
new(dataSource, "doc_chunks", ownsDataSource: false, new PostgresCollectionOptions { Definition = Definition });
await foreach (VectorSearchResult<ChunkRecord> r in collection.HybridSearchAsync(vector, keywords, top: 5))
{
Console.WriteLine($"{r.Record.Id,-28} {r.Score:F4} {r.Record.Section}");
}
Sorunun bütün kelimelerini keywords olarak verince sonuç saf vector sıralamasıyla aynı çıktı (skorlar 1/61, 1/62…). PostgreSQL log'unu açıp üretilen SQL'e baktım:
keyword_search AS (
SELECT "id", RANK () OVER (ORDER BY ts_rank_cd(to_tsvector('turkish', "content"), query) DESC) AS rank
FROM "doc_chunks", plainto_tsquery('turkish', $1) query
WHERE to_tsvector('turkish', "content") @@ query
...
DETAIL: Parameters: $1 = 'BKS-20114 kodlu kitabın fiyatı nedir', ..., $3 = '60'
Provider keyword'leri boşlukla birleştirip plainto_tsquery'ye veriyor, yani AND. Bütün soru verilince hiçbir chunk eşleşmiyor ve keyword tarafı boş kalıyor. API'nin (Application Programming Interface) beklediği şey gerçekten "anahtar kelimeler": yalnız ["BKS-20114"] verince doğru ürün 0,0315 ile birinci geliyor. Ayrıca sorgu to_tsvector('turkish', "content")'i her satır için anında hesaplıyor; bizim generated kolonumuzu kullanmıyor. Büyük tabloda bu ifadeye birebir uyan bir expression index (USING GIN (to_tsvector('turkish', content))) kurmazsan her sorgu tüm metinleri yeniden parse eder. Abstraction işini kolaylaştırıyor ama altında ne çalıştığını bilmen gerekiyor.
SQL Server Tarafı: EF Core 11, FreeTextTable ve FullJoin
SQL Server'da aynı fikir full-text index ve vector search'ün birleşimi. EF Core 11 (.NET 11 ile, GA (General Availability) 10 Kasım 2026) FreeTextTable/ContainsTable table-valued function'larını ve .NET 11'in yeni FullJoin LINQ operatörünü çeviriyor; Microsoft Learn'deki hybrid örneği ikisini VectorSearch() ile tek sorguda birleştiriyor. .NET 11 SDK'sı (Software Development Kit) henüz GA olmadığı için bu kodu derleyip çalıştırmadım. Bir uyarı: Learn'deki örnek (Kasım 2026 itibarıyla) RRF formülüne sıra numarası yerine FREETEXTTABLE'ın RANK değerini ve vector Distance'ını koyuyor. RANK 0–1000 arası bir relevance değeri ve büyük olan daha alakalı; 1.0 / (k + RANK) en alakalı satırı en az ödüllendirir. Örneği kopyalarsan önce iki tarafı ROW_NUMBER() ile sıraya çevir.
Ölç: Vector, Keyword, Hybrid
Serinin 30 soruluk setinde her soru için beklenen doküman ve bölüm belli. Bir sorgu, beklenen chunk ilk k sonuçtaysa isabet sayılıyor. Aynı soruları üç yöntemle koştum (RRF k=60, her listeden 20 aday):
| Yöntem | recall@1 | recall@3 | recall@5 | recall@10 |
|---|---|---|---|---|
| Yalnız vector | 16/30 | 24/30 | 25/30 | 28/30 |
| Yalnız keyword (OR) | 17/30 | 25/30 | 26/30 | 28/30 |
| Hybrid (RRF) | 15/30 | 24/30 | 26/30 | 28/30 |
| Hybrid (RRF, keyword ağırlığı 2) | 19/30 | 24/30 | 26/30 | 28/30 |
Skorla "RRF" (1/(k+distance) + 1/(k+ts_rank)) | 12/30 | 21/30 | 22/30 | 26/30 |
Pazarlama slaytındaki "hybrid her zaman kazanır" bu tabloda yok. Eşit ağırlıklı RRF recall@5'te keyword'le berabere, recall@1'de ikisinden de kötü. Daha ilginç olan hangi sorularda kaybettikleri. Vector q04, q08, q10, q11 ve q26'yı (SKU sorusu) kaçırıyor; keyword q05, q06, q11 ve q30'u kaçırıyor. Kesişim yalnız q11 ("Almanya'ya kitap gönderiyor musunuz?": dokümanda "Almanya" geçmiyor, "Yurt dışı gönderim" bölümü var). İki yöntem farklı yerlerde hata yapıyor ve hybrid'in değeri tam burada: q05'te ("Kitap yırtık geldi") keyword "yırtık" ile "hasarlı"yı eşleyemiyor, vector eşliyor; q26'da tersi. Ama q30'da ("Çocuğum için kodlama öğreten bir kitap") vector doğru ürünü birinci getirirken keyword listesinin gürültüsü onu hybrid'de dördüncüye itiyor. Sebebi yine stemmer: soru "kodlama"yı kodla'ya, ürün adı "Kodlamayı"yı kodlama'ya indiriyor.
Keyword listesine 2 ağırlık verince recall@1 19/30'a çıkıyor; bu settaki en iyi sonuç. 30 soruyla ağırlık ayarlamak overfitting'e açık; bunu kendi gerçek soru log'larınla doğrulamadan production'a taşıma. k sabitini 1, 10, 60 ve 200 ile denedim; recall@5 en fazla bir soru oynadı (26–27), bu boyutta k'nın etkisi küçük. Skorla yapılan sahte RRF ise açık ara en kötü: distance ve ts_rank 60'ın yanında küçük kaldığı için sıralamayı gürültü belirliyor, yüksek ts_rank da cezalandırılıyor.
Üretimde Dikkat
Aday sayısı ile son top-k'yı ayır. Burada her listeden 20 aday alıp 5'e indirdik; aday sayısını büyütmek recall'u artırır ama sonraki yazıdaki reranker'ın işini de büyütür. İki aramayı paralel koşmak istersen aynı DbContext'i iki thread'de kullanma; ya iki ayrı context aç ya da füzyonu tek SQL sorgusunda yap.
Dil config'i doküman bazında düşün. Kataloğumuzdaki İngilizce başlıklar turkish stemmer'dan geçince designingi, katalok gibi tuhaf köklere dönüşüyor. Kodlar ve özel isimler için simple config'iyle ikinci bir tsvector kolonu tutmak ve iki keyword listesini de RRF'e katmak yaygın bir çözüm. RRF zaten istediğin kadar listeyi birleştirebilir.
Sorgu tipini tanı. Soruda BKS-\d{5} gibi bir kod varsa önce birebir lookup yap; bulursan doğrudan o chunk'ı en üste koy. Hybrid search'ü her şeyi çözen bir kara kutu olarak değil, makul bir varsayılan olarak gör.
Son olarak, PostgreSQL'in yerleşik ts_rank'i BM25 (Best Matching 25) değildir; doküman uzunluğunu ve terim nadirliğini BM25 kadar iyi hesaba katmaz. Büyük ve heterojen bir corpus'ta keyword kalitesi yetmezse BM25 sunan bir extension'a ya da Elasticsearch gibi bir motora bakmak gerekebilir; bu seride ANN (Approximate Nearest Neighbor) index ayrıntısı kadar o tarafa girmiyoruz.
Sık Düşülen Tuzaklar
RRF'te rank yerine ham skor kullanmak. Tablodaki son satır bunun bedelini gösteriyor: 22/30. Formülün adı "rank fusion"; içine distance, cosine benzerliği ya da relevance skoru girdiği anda RRF değildir.
Doğal dildeki soruyu doğrudan AND sorgusuna vermek. plainto_tsquery, websearch_to_tsquery ve HybridSearchAsync'in keyword tarafı varsayılan olarak AND'dir; uzun bir soru neredeyse hiçbir şeyle eşleşmez ve hybrid sessizce vector-only'ye düşer. Hata vermez, yalnız sonuç kötüleşir.
Türkçe stemming'e körü körüne güvenmek. "iade" ile "iadesi" farklı köklere iniyor. Kritik terimler için (iade, kargo, fatura) her iki config'i de test et ve keyword kaçırdığında vector'ün yakalayabilmesi için hybrid'i koru.
Paket sürümlerini karıştırmak. İlk denemede eski Microsoft.SemanticKernel.Connectors.PgVector 1.74.0-preview paketini Microsoft.Extensions.VectorData.Abstractions 10.11.0 ile kullandım; derleme geçti, HybridSearchAsync çalışırken MissingMethodException: Method not found: '... HybridSearchOptions`1.get_OldFilter()' fırlattı. Provider paketini abstraction sürümüyle uyumlu olanla, yani CommunityToolkit.VectorData.PgVector ile değiştirince düzeldi.
ts_rank_cd ile OR sorgusunu birleştirip eşit skorları fark etmemek. Eşit skorlu satırların sırası belirsizdir ve RRF bu belirsiz sırayı sanki bilgiymiş gibi kullanır.
Küçük Bir Deneme
★☆☆ simple config'iyle ikinci bir generated tsvector kolonu ekle ve to_tsvector('simple', 'iade') ile to_tsvector('simple', 'iadesi')'ni karşılaştır. q30 sorusu bu kolonla keyword listesinde kaçıncı sıraya çıkıyor?
★★☆ Üç listeli RRF kur: vector, turkish keyword ve simple keyword. 30 soruluk sette recall@1 ve recall@5'i iki listeli versiyonla karşılaştır.
★★☆ Sorudaki BKS-\d{5} kodunu regex ile yakalayıp önce birebir lookup yapan bir "exact match" adımı ekle. q26 için hybrid'de ikinci olan doğru ürün birinci oluyor mu, diğer sorular etkileniyor mu?
★★★ HybridSearchAsync'e soru yerine bir LLM'in (Large Language Model) çıkardığı anahtar kelimeleri ver (gemma3:4b'den structured output ile string[] iste). 30 soruda bunun recall@5'ini bizim ToOrQuery yaklaşımımızla karşılaştır; ekstra LLM çağrısının gecikmesini de not et.
Hybrid search aday listesini zenginleştirdi ama sıralamayı hâlâ iki kaba sinyal belirliyor: embedding benzerliği ve kelime örtüşmesi. q30'da doğru ürünün dördüncüye düşmesi bunun bedeli. Bir sonraki yazıda geniş getirip dar vermeyi konuşacağız: 20–50 adayı soru ve chunk'ı birlikte okuyan bir reranker'dan geçirmek, sohbet bağlamındaki "peki iadesi?" gibi soruları yeniden yazmak ve HyDE (Hypothetical Document Embeddings) ile hipotetik bir cevapla aramak.