RAG'i Ölçmek: Recall@k, MRR, nDCG, Faithfulness ve Groundedness
RAG'i ölçmek, retrieval'ı etiketli kaynakla (recall@k, MRR, nDCG) ve generation'ı yargıç bir LLM'le (groundedness, relevance) ayrı ayrı ölçmek demek. 30 soruda dört retrieval yöntemini C#'ta karşılaştırıyor, Microsoft.Extensions.AI.Evaluation'ın soruyu ıskalayan bir cevaba 5 verdiğini gösteriyor ve response cache'le 1 dk 42 sn'lik testi 1,1 sn'ye indiriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.AI.Evaluation.Quality ve .Reporting 10.11.0, xUnit v3 4.0.2 (Microsoft.Testing.Platform ile), Entity Framework Core 10 ve PostgreSQL 17 + pgvector 0.8.7 ile derlenip denenmiştir; modeller Ollama'da gemma3:4b (cevap), gemma3:12b (yargıç) ve nomic-embed-text.
Önce Tahmin Et
İki cevabı bir LLM (Large Language Model) yargıcına, gemma3:12b'ye, Microsoft'un GroundednessEvaluator'ı üzerinden puanlatıyoruz; ölçek 1–5, 4'ün altı "başarısız". Birinci cevap: "Kitabı teslim tarihinden itibaren 30 gün içinde iade edebilirsin." Context'te 21 gün yazıyor. İkinci cevap, 120 adet kitap soran okula: "200 adet ve üzeri siparişlerde %22 indirim alırsınız." Bu cümle sözleşmede kelimesi kelimesine geçiyor, ama soruyu cevaplamıyor; 120 adet için doğru oran %15. Hangisi kaç puan alır?
q28 G=5 Rel=2 Ret=- 200 adet ve üzeri siparişlerde %22 indirim alırsınız.
q01-tampered G=3 Rel=2 Ret=5 Kitabı teslim tarihinden itibaren 30 gün içinde iade edebilirsin.
Yanlış gün sayısı 3 aldı ve başarısız sayıldı; iyi. Soruyu ıskalayan ama "context'te yazan" cümle groundedness'tan tam 5 aldı ve yalnız relevance onu yakaladı. Serinin son dört yazısında her değişikliğin bir şeyi iyileştirip başka bir şeyi bozduğunu gördük. Bu yazıda bunu göz kararıyla değil sayılarla yakalayacağız: retrieval'ı generation'dan ayrı ölçmek, recall@k, MRR (Mean Reciprocal Rank) ve nDCG (normalized Discounted Cumulative Gain) metriklerini C#'ta hesaplamak, LLM-as-judge evaluator'larını ve sınırlarını görmek ve hepsini her commit'te koşan bir test haline getirmek.
Evaluation Seti: Soru, Beklenen Kaynak, Referans Cevap
Her şey bir evaluation setiyle başlıyor. Serinin başından beri kullandığımız 30 soru tam olarak bu: her satırda soru, beklenen doküman, beklenen bölüm ve kısa bir referans cevap var.
{ "id": "q04", "question": "Param ne zaman hesabıma geri yatar?", "doc": "iade-politikasi", "section": "Para iadesi",
"answer": "Ürün depoda kontrol edildikten sonra 5 iş günü içinde başlatılır; kredi kartında 10 iş gününü bulabilir." }
Beklenen kaynak, retrieval'ı modelden bağımsız ölçmemizi sağlıyor; referans cevap ise generation'ı. İkisini ayırmak şart: yanlış bir cevapta önce "doğru chunk prompt'a girdi mi?" diye sormalısın. Girmediyse sorun retrieval'da, girdiyse modelde ya da prompt'ta.
Relevance'ı ikili değil dereceli tanımladım: beklenen bölüm 2, aynı dokümanın diğer bölümleri 1, geri kalan her şey 0. "Para iadesi" sorusunda iade politikasının "Hasarlı ürün" bölümünü getirmek, kargo sayfasını getirmekten daha iyi bir hatadır ve nDCG bunu ayırt edebilir.
Recall@k, MRR ve nDCG'yi C#'ta Hesaplamak
Üç metrik, üç farklı soru soruyor. Recall@k: doğru chunk ilk k sonucun içinde mi? MRR: doğru chunk kaçıncı sırada, ortalama olarak; birinci sıra 1, ikinci 0,5, beşinci 0,2 puan. nDCG@k: dereceli relevance'la, sıra ilerledikçe logaritmik azalan bir kazanç; ideal sıralamaya oranlanmış.
// Bir sorgunun sonucu: sıralı chunk id'leri ve her id'nin relevance derecesi (0, 1, 2)
public sealed record RankedResult(string QuestionId, IReadOnlyList<string> Ranking, IReadOnlyDictionary<string, int> Relevance);
public static class RetrievalMetrics
{
// Recall@k: en alakalı (grade 2) chunk ilk k içinde mi?
public static double RecallAt(RankedResult r, int k) =>
r.Ranking.Take(k).Any(id => r.Relevance.GetValueOrDefault(id) == 2) ? 1 : 0;
// Reciprocal rank: en alakalı chunk'ın sırasının tersi; yoksa 0
public static double ReciprocalRank(RankedResult r)
{
int index = r.Ranking.ToList().FindIndex(id => r.Relevance.GetValueOrDefault(id) == 2);
return index < 0 ? 0 : 1.0 / (index + 1);
}
// nDCG@k: dereceli relevance, sıra ilerledikçe log2 ile azalan kazanç
public static double NdcgAt(RankedResult r, int k)
{
double Dcg(IEnumerable<int> gains) => gains.Take(k)
.Select((g, i) => (Math.Pow(2, g) - 1) / Math.Log2(i + 2)).Sum();
double dcg = Dcg(r.Ranking.Select(id => r.Relevance.GetValueOrDefault(id)));
double ideal = Dcg(r.Relevance.Values.OrderByDescending(g => g));
return ideal == 0 ? 0 : dcg / ideal;
}
public static (double RecallAt1, double RecallAt5, double Mrr, double NdcgAt5) Summarize(IReadOnlyList<RankedResult> results) => (
results.Average(r => RecallAt(r, 1)),
results.Average(r => RecallAt(r, 5)),
results.Average(ReciprocalRank),
results.Average(r => NdcgAt(r, 5)));
}
Kazanç formülü 2^g - 1: grade 2 için 3, grade 1 için 1. Böylece tam isabet, aynı dokümandan komşu bir bölümün üç katı değerli. Aynı 30 soruyu bu seride kurduğumuz dört retrieval yöntemiyle koştum; reranker çağrıları disk cache'inden geçiyor (aşağıda anlatacağım):
$ dotnet run -- retrieval
method recall@1 recall@5 MRR nDCG@5
vector 0,533 0,833 0,682 0,625
keyword 0,567 0,867 0,702 0,638
hybrid 0,500 0,867 0,671 0,646
hybrid+rerank 0,767 0,900 0,821 0,715
worse after rerank: q14 RR 0,50 -> 0,20
worse after rerank: q16 RR 1,00 -> 0,25
worse after rerank: q20 RR 0,50 -> 0,17
elapsed: 112,4s
Hybrid, MRR'de vector'ün ve keyword'ün gerisinde kalırken nDCG@5'te ikisini de geçiyor: birinci sıraya daha az isabet ediyor ama ilk beşi ilgili dokümanın komşu bölümleriyle daha iyi dolduruyor. Tek bir metriğe baksaydın iki zıt sonuç çıkarabilirdin. Reranker her metrikte açık ara önde; yine de soru bazında döküm, onun üç soruyu (q14, q16, q20) bozduğunu gösteriyor. Ortalama yükseldi diye bu üç soruyu görmezden gelme; reranker yazısında gördüğümüz gibi her biri ayrı bir hata sınıfı. Vector'ün MRR'ı 0,682; serinin ikinci yazısındaki bellek içi ölçümde 0,668'di. Buradaki aday listesi 20 sonuç ve pgvector'ün HNSW (Hierarchical Navigable Small World) index'inden geliyor; küçük farkın büyük ihtimalle kaynağı bu kesme noktası ve index farkı.
Generation'ı Ölçmek: Microsoft.Extensions.AI.Evaluation
Retrieval metrikleri cevabın doğru olduğunu söylemez. Cevabı ölçmek için iki yol var: referans cevapla karşılaştırmak (BLEU (Bilingual Evaluation Understudy), F1 (precision ile recall’un harmonik ortalaması) skoru gibi NLP (Natural Language Processing) metrikleri ya da bir LLM'in "aynı anlamda mı?" kararı) veya cevabı context'e ve soruya göre yargılamak. Microsoft.Extensions.AI.Evaluation.Quality paketi ikincisi için hazır evaluator'lar veriyor; RAG (Retrieval-Augmented Generation) için üçü öne çıkıyor:
GroundednessEvaluator: cevaptaki bilgiler verilen context'te var mı? Context'iGroundednessEvaluatorContextile alır.RelevanceEvaluator: cevap soruyu cevaplıyor mu? Ek context istemez.RetrievalEvaluator: getirilen chunk'lar soruyla ne kadar ilgili? Chunk listesiniRetrievalEvaluatorContextile alır.
Ragas kütüphanesinin kavramlarıyla eşlersen: groundedness, Ragas'ın faithfulness metriğine (cevaptaki iddialardan context'le desteklenenlerin oranı) karşılık geliyor; retrieval evaluator'ı ise kabaca context precision'ın LLM'le yargılanan bir hali. Ragas'ın context recall'u referans cevaptaki bilgilerin getirilen context'te bulunup bulunmadığına bakar; bizim recall@k'mız aynı soruyu etiketli kaynakla, LLM'siz cevaplıyor.
// Cevabı gemma3:4b üretir, gemma3:12b yargılar; ikisinin cevapları da disk cache'inde
ReportingConfiguration generation = DiskBasedReportingConfiguration.Create(
storageRootPath: store, evaluators: [], chatConfiguration: new ChatConfiguration(Models.Chat("gemma3:4b")),
enableResponseCaching: true, executionName: "generation");
ReportingConfiguration judge = DiskBasedReportingConfiguration.Create(
storageRootPath: store,
evaluators: [new GroundednessEvaluator(), new RelevanceEvaluator(), new RetrievalEvaluator()],
chatConfiguration: new ChatConfiguration(Models.Chat("gemma3:12b")),
enableResponseCaching: true, executionName: "judge");
await using ScenarioRun generator = await generation.CreateScenarioRunAsync("Answer.Generation");
foreach (EvalQuestion q in questions.Where(q => ids.Contains(q.Id)).Append(Tampered(questions[0])))
{
List<Candidate> top = await retriever.HybridAsync(q.Question, 3);
string context = string.Join("\n\n", top.Select(c => c.Content));
List<ChatMessage> messages =
[
new(ChatRole.System, "You are the Bookstore help-center assistant. Answer in Turkish, briefly, using only the context."),
new(ChatRole.User, $"Context:\n{context}\n\nQuestion: {q.Question}")
];
ChatResponse response = q.Id.EndsWith("-tampered")
? new ChatResponse(new ChatMessage(ChatRole.Assistant, q.Answer))
: await generator.ChatConfiguration!.ChatClient.GetResponseAsync(messages, new ChatOptions { Temperature = 0, Seed = 42 });
await using ScenarioRun run = await judge.CreateScenarioRunAsync($"Rag.{q.Id}");
EvaluationResult result = await run.EvaluateAsync(messages, response,
[
new GroundednessEvaluatorContext(context),
new RetrievalEvaluatorContext(top.Select(c => c.Content))
]);
...
}
Yargıcı cevabı üreten modelden büyük seçtim; aynı modelin kendi cevabını yargılaması, kendi hatasını "makul" bulma eğilimi yüzünden kaçınılması gereken bir şey. Listeye bir de bilerek bozulmuş cevap ekledim: q01 için 21 yerine 30 gün. Sonuç:
$ dotnet run -- generation -v
q01 G=5 Rel=4 Ret=5 Satın aldığınız basılı kitabı teslim tarihinden itibaren 21 gün içinde iade edebilirsiniz.
q04 G=2 Rel=3 Ret=1 Siparişin kargoya verilmeden önce iptal edebilirsiniz. Kargoya verilmiş siparişler iptal e
reason(G): The response doesn't answer the question about when the money will be returned. It only discusses order cancellation policies.
q07 G=5 Rel=4 Ret=5 400 TL altındaki siparişlerde 49,90 TL.
q09 G=5 Rel=4 Ret=5 Evet, İstanbul, Ankara ve İzmir'in merkez ilçelerinde saat 12:00'ye kadar verilen siparişl
q13 G=5 Rel=4 Ret=5 Hediye kartları satın alındığı tarihten itibaren 3 yıl geçerlidir.
q17 G=5 Rel=4 Ret=5 Her 100 puandan 25 TL indirim sağlanır.
q21 G=5 Rel=4 Ret=- 750 TL ve üzeri alışverişlerde anlaşmalı bankaların kredi kartlarına 3 taksit imkânı vardı
q24 G=5 Rel=4 Ret=- Ankara Kızılay mağazamızdan ücretsiz teslim alma seçeneği mevcut.
q28 G=5 Rel=2 Ret=- 200 adet ve üzeri siparişlerde %22 indirim alırsınız.
q29 G=5 Rel=4 Ret=5 Kamu kurumları için ödeme süresi 60 gündür.
q01-tampered G=3 Rel=2 Ret=5 Kitabı teslim tarihinden itibaren 30 gün içinde iade edebilirsin.
reason(G): The response provides an incorrect timeframe for returning a book, contradicting the information provided in the context. ...
elapsed: 687,6s
q04 tablonun en dürüst satırı: retrieval yanlış chunk'ları getirdi (Ret=1), model de onlardan sipariş iptali anlattı. Groundedness'ın gerekçesi aslında relevance'ın işini yapıyor, ama yargıç sorunu doğru gördü. Üç satırda Ret=- var; değer null. Sonuç dosyasındaki diagnostics bunun sebebini söylüyor:
"Failed to parse score for 'Retrieval' from the following evaluation response:
<S0>Let's think step by step:
The query asks \"Taksit yapıyor musunuz?\" which translates to \"Do you offer installments?\". The"
Evaluator'ların prompt'ları belirli bir çıktı biçimi bekliyor; Microsoft'un dokümantasyonuna göre bu evaluator'lar Microsoft'un Copilot deneyimlerinde test edildi. 12 milyar parametrelik yerel bir model bu biçimi on denemenin üçünde tutturamadı. Eksik bir skoru sessizce ortalamaya katmamak için null'ları ayrıca say.
LLM-as-Judge'ın Sınırları
Açılıştaki iki satıra geri dönelim. Uydurma 30 gün yakalandı, ama yalnız 3 aldı; 1 değil. q28'deki yanlış cevap ise groundedness'tan 5 aldı, çünkü cümlenin kendisi gerçekten context'te yazıyor. Groundedness tanımı gereği "söylediğin şey kaynakta var mı?" diye sorar, "soruya uygulanmış hali doğru mu?" diye sormaz. Bunu bu koşuda relevance (2) yakaladı, ama ona da kör güvenme: aynı relevance, q09'un doğru ama uzun cevabına da 4 verdi, 5 değil.
Bu yüzden yargıcı da ölçmen gerekiyor. Pratik bir yöntem, bilerek bozduğun birkaç cevabı (yanlış sayı, yanlış kaynak, uydurma koşul) evaluation setinin kalıcı bir parçası yapmak. Yargıç bunları yakalayamıyorsa onun verdiği 5'lere de güvenemezsin. Bir de etiketli kaynak ve referans cevap gibi LLM'siz sinyalleri her zaman yanında tut: recall@k'yı hiçbir yargıç yanıltamaz.
CI'da Regresyon: xUnit ve Response Cache
Ölçümleri bir kez koşup tabloya bakmak yetmez; chunking'i, embedding modelini ya da prompt'u değiştirdiğinde skorun düştüğünü bir test kırılmasıyla öğrenmelisin. LLM çağrıları yavaş ve pahalı olduğu için buradaki kilit parça response cache: DiskBasedReportingConfiguration modele giden her isteği (mesajlar, seçenekler, model) anahtar olarak saklar ve aynı istek tekrar geldiğinde cevabı diskten döner.
public sealed class RagQualityTests
{
static readonly IReadOnlyList<EvalQuestion> Questions = Corpus.Questions();
static readonly List<SourceChunk> Chunks = Corpus.LoadStandard();
static readonly ReportingConfiguration Judge = DiskBasedReportingConfiguration.Create(
storageRootPath: Path.Combine(Path.GetDirectoryName(Corpus.Root)!, "eval-store"),
evaluators: [new GroundednessEvaluator()],
chatConfiguration: new ChatConfiguration(Models.Chat("gemma3:12b")),
enableResponseCaching: true,
executionName: Environment.GetEnvironmentVariable("EVAL_EXECUTION") ?? "local");
[Fact]
public async Task Hybrid_retrieval_does_not_regress()
{
...
var summary = RetrievalMetrics.Summarize(results);
// Eşikler bugünkü ölçümün biraz altında: chunking veya model değişirse test kırılır
Assert.True(summary.RecallAt5 >= 0.80, $"recall@5 = {summary.RecallAt5:F3}");
Assert.True(summary.Mrr >= 0.60, $"MRR = {summary.Mrr:F3}");
}
[Theory]
[InlineData("q01")]
[InlineData("q13")]
[InlineData("q21")]
public async Task Answer_is_grounded_in_retrieved_context(string questionId)
{
...
CancellationToken ct = TestContext.Current.CancellationToken;
await using ScenarioRun run = await Judge.CreateScenarioRunAsync($"Grounded.{questionId}", cancellationToken: ct);
// Aynı scenario'nun chat client'ı: cevap da cache'ten gelir, CI'da model çağrısı tekrarlanmaz
ChatResponse response = await run.ChatConfiguration!.ChatClient.GetResponseAsync(messages, new ChatOptions { Temperature = 0, Seed = 42 }, ct);
EvaluationResult result = await run.EvaluateAsync(messages, response, [new GroundednessEvaluatorContext(context)], ct);
NumericMetric groundedness = result.Get<NumericMetric>(GroundednessEvaluator.GroundednessMetricName);
Assert.False(groundedness.Interpretation!.Failed, $"{questionId}: {groundedness.Value} - {groundedness.Reason}");
}
}
Testi iki kez koştum. İlk koşu yargıcı gerçekten çağırıyor, ikincisi cache'ten okuyor:
$ dotnet test
Test run summary: Passed!
total: 4
failed: 0
succeeded: 4
skipped: 0
duration: 1m 42s 115ms
$ dotnet test
Test run summary: Passed!
total: 4
failed: 0
succeeded: 4
skipped: 0
duration: 1s 128ms
1 dakika 42 saniyeden 1,1 saniyeye. Bu, LLM'li testleri CI (Continuous Integration) pipeline'ında her commit'te koşulabilir yapan şey. Cache anahtarı isteğin içeriğinden oluştuğu için retrieval'ı, chunking'i ya da prompt'u değiştirdiğinde prompt değişir, cache ıskalanır ve model gerçekten yeniden çağrılır; değişmeyen testler bedava kalır. Bir not: .NET 10 SDK'sında (Software Development Kit) dotnet test'in eski VSTest modu xUnit v3'ün kullandığı Microsoft.Testing.Platform'la çalışmıyor; ilk denemede "Testing with VSTest target is no longer supported" hatası aldım ve global.json'a "test": { "runner": "Microsoft.Testing.Platform" } ekleyerek çözdüm. CI'da cache klasörünü artifact olarak saklayıp sonraki koşuya geri yüklemen gerekiyor; mekaniği ilerideki .NET ile Yapay Zeka serisinin evaluation yazısında.
Üretimde Dikkat
Cache'in iki yüzü var. İyi tarafı yukarıda; kötü tarafı, model sağlayıcısı aynı model adının arkasındaki ağırlıkları güncellerse cache'li testlerin bunu fark etmemesi. Cache'e TTL (Time To Live) ver (timeToLiveForCacheEntries), anahtara model sürümü ya da prompt sürümü gibi bilgileri cachingKeys ile ekle ve haftada bir cache'siz bir koşu yap.
Offline ölçüm production'ı temsil etmez. 30 soru, gerçek kullanıcıların yazdığı soruların dağılımını yansıtmaz. Production log'larından örnek sorular çek (yetki ve kişisel veri temizliğinden sonra), etiketle ve sete ekle. Bir de çevrim içi sinyal topla: kullanıcı cevaptan sonra aynı soruyu yeniden yazdıysa, "yardımcı olmadı" butonuna bastıysa ya da destek talebi açtıysa, o soru sete aday.
Skorları sürümle birlikte sakla. "recall@5 0,867" tek başına bir şey söylemez; hangi chunking, hangi embedding modeli, hangi aday sayısı ile ölçüldüğü yanında durmalı. executionName tam olarak bu iş için var.
Sık Düşülen Tuzaklar
Evaluation setini dokümanlardan otomatik üretip ezberlemek. Bir LLM'e "bu chunk'tan soru üret" dediğinde soru çoğu zaman chunk'ın kelimelerini taşır ve keyword search'ü haksız yere parlatır. Bizim setimizdeki "Kitap yırtık geldi" ya da "kargo bedava" gibi sorular, dokümanın kelimelerini kullanmayan gerçek kullanıcı dilini temsil ediyor ve en öğretici hatalar oradan çıktı.
Tek ortalama skora bakmak. Hybrid'in MRR'ı düşükken nDCG'si yüksekti; reranker ortalamayı yükseltirken üç soruyu bozdu. Her zaman soru bazında dökümü de üret ve "kötüleşen sorular" listesini ayrıca incele.
Yargıcın parse hatasını sıfır saymak ya da yok saymak. Null skorlar ortalamaya girmezse ortalama olduğundan iyi görünür; sıfır sayılırsa olduğundan kötü. Ayrı bir sayaçla raporla.
Cevabı üreten modelle yargılamak. Aynı modelin aynı hatayı "makul" bulma ihtimali yüksek. Mümkünse daha büyük ya da farklı aileden bir yargıç kullan ve yargıcı bozuk cevaplarla test et.
Küçük Bir Deneme
★☆☆ RetrievalMetrics'e PrecisionAt(k) ekle: ilk k sonuçta grade'i 1 veya 2 olanların oranı. Hybrid ile hybrid+rerank arasındaki fark recall@5'tekinden büyük mü?
★★☆ Evaluation setine bilerek bozulmuş beş cevap daha ekle (yanlış ücret, yanlış süre, başka dokümandan bir koşul, uydurma bir ülke, eksik bir istisna). gemma3:12b yargıcı kaçını başarısız sayıyor?
★★☆ Ingestion'da chunk'ları 300 yerine 120 token'da bölüp (ya da başlık ön ekini kaldırıp) Hybrid_retrieval_does_not_regress testini koş. Test kırılıyor mu, hangi metrik ne kadar düşüyor?
★★★ EquivalenceEvaluator ile her cevabı setteki referans cevapla karşılaştır ve sonucu groundedness'la yan yana koy. İkisinin ayrıştığı sorular (grounded ama yanlış, ya da doğru ama context dışı) hangileri?
Artık bir değişikliğin iyi mi kötü mü olduğunu sayılarla söyleyebiliyoruz. Serinin son yazısında bu ölçümlerin bize gösterdiği sınırlara bakacağız: birden fazla adımlı sorular, bütün corpus'u kapsayan "global" sorular ve modele her şeyi tek seferde vermenin RAG'den daha iyi olduğu durumlar. Agentic RAG, GraphRAG ve long context, ne zaman klasik RAG'in yerini almalı?