RAGToolkit.AI.Evaluation
0.2.0
dotnet add package RAGToolkit.AI.Evaluation --version 0.2.0
NuGet\Install-Package RAGToolkit.AI.Evaluation -Version 0.2.0
<PackageReference Include="RAGToolkit.AI.Evaluation" Version="0.2.0" />
<PackageVersion Include="RAGToolkit.AI.Evaluation" Version="0.2.0" />
<PackageReference Include="RAGToolkit.AI.Evaluation" />
paket add RAGToolkit.AI.Evaluation --version 0.2.0
#r "nuget: RAGToolkit.AI.Evaluation, 0.2.0"
#:package RAGToolkit.AI.Evaluation@0.2.0
#addin nuget:?package=RAGToolkit.AI.Evaluation&version=0.2.0
#tool nuget:?package=RAGToolkit.AI.Evaluation&version=0.2.0
RAGToolkit.AI.Evaluation
RAG 检索质量的离线评估。纯 IR 指标(不调模型、同一份数据永远算出同一个数)+ 一层薄薄的
Microsoft.Extensions.AI.Evaluation
适配,让它能和微软自带的 LLM 裁判(Groundedness / Retrieval / Completeness …)
并排跑、进同一份报告。
类型
| 类型 | 说明 |
|---|---|
RetrievalMetrics |
纯函数指标:IsHit / RecallAtK / PrecisionAtK / ReciprocalRank / NdcgAtK / AveragePrecisionAtK |
RetrievalMetricsSummary |
跨用例取平均(Summarize);自动排除「没标期望目标」的用例 |
PrecisionDenominator |
查准率分母口径:Cutoff(固定 K,默认)/ Retrieved(实际返回条数) |
RetrievalMetricsEvaluator |
IEvaluator 实现,一个评估器出 6 个指标(Hit@K / Recall@K / Precision@K / MRR@K / nDCG@K / AP@K) |
RetrievalEvaluationContext |
把检索结果带进 MEAI 的评估管线(派生自 EvaluationContext) |
EvaluationCase / RetrievedItem / CaseOutcome |
数据模型:期望目标、一条检索结果(含正文)、一条用例的结果 |
IRetrievalUnderTest |
被测系统的唯一契约:RetrieveAsync(query, topK, …) |
RetrievalEvaluationRunner |
把评估集跑一遍(串行),产出 RetrievalEvaluationReport |
RetrievalEvaluationReport |
汇总 + 逐条明细;ToText() 出纯文本 |
RetrievalComparison |
A/B 对比:配对 bootstrap 出每个指标的差值和置信区间(Compare) |
RetrievalComparisonReport |
对比结果;ToText() 出一张带赢/平/输和判定列的表 |
RetrievalMetricComparison |
单个指标的两侧均值、差值、区间、是否显著、赢/平/输计数 |
RetrievalTally |
逐条差值分档计数(B 赢 / 持平 / A 赢) |
快速开始
一、只算指标(不接任何模型)
EvaluationCase[] cases = [new EvaluationCase("这套服务怎么部署?", ["deploy.md", "ops.md"])];
RetrievalEvaluationRunner runner = new(new MyRetrieval(), k: 5);
RetrievalEvaluationReport report = await runner.RunAsync(cases);
Console.WriteLine(report.ToText());
被测系统包一层就行:
sealed class MyRetrieval : IRetrievalUnderTest
{
public async Task<IReadOnlyList<RetrievedItem>> RetrieveAsync(
string query, int topK, CancellationToken cancellationToken = default)
{
// 调你自己的检索。Rank 从 1 开始;Text 是这条结果的正文(可以留空)。
return [.. hits.Select((hit, index) =>
new RetrievedItem(hit.Id, index + 1, hit.Score, hit.Text))];
}
}
评估集从哪来,包不掺和 —— 它只认 EvaluationCase。想放 JSON 文件里就自己读,
字段名跟 record 一致,加个大小写不敏感就能容忍手写文件:
JsonSerializerOptions options = new() { PropertyNameCaseInsensitive = true };
List<EvaluationCase> cases =
JsonSerializer.Deserialize<List<EvaluationCase>>(json, options) ?? [];
// [ { "query": "蓝鲸的心脏有多大?", "expectedIds": ["bluewhale.md"] },
// { "query": "这套服务怎么部署?", "expectedIds": ["deploy.md", "ops.md"] } ]
按知识库分几个文件、文件叫什么名字、要不要进数据库 —— 都是你的决定。
二、接微软的 Reporting(出 HTML 报告)
本包不依赖 Microsoft.Extensions.AI.Evaluation.Reporting —— 接入点是 IEvaluator 接口本身。
加一个包引用,然后把评估器塞进去:
ReportingConfiguration configuration = DiskBasedReportingConfiguration.Create(
storageRootPath: "eval-results",
evaluators: [new RetrievalMetricsEvaluator(k: 5)],
chatConfiguration: null, // ★ 指标评估器不调模型,这里可以省
enableResponseCaching: false,
executionName: "baseline");
// ★ 一个 ScenarioRun 只能 EvaluateAsync 一次 → 一条用例开一个
await using ScenarioRun run = await configuration.CreateScenarioRunAsync(
scenarioName: query, iterationName: "baseline");
await run.EvaluateAsync(
userRequest: query,
modelResponse: "(仅检索指标,未调用模型)",
additionalContext: [new RetrievalEvaluationContext(outcome)]);
三、接大模型当裁判
chatConfiguration 填上即可 —— 任何 IChatClient 都行(OpenAI / Azure / Ollama / 自建代理):
ChatConfiguration chatConfiguration = new(chatClient);
ReportingConfiguration configuration = DiskBasedReportingConfiguration.Create(
storageRootPath: "eval-results",
evaluators:
[
new RetrievalMetricsEvaluator(k: 5), // 我们的:不调模型
new RetrievalEvaluator(), // 微软的 LLM 裁判
new GroundednessEvaluator(), // 同上
],
chatConfiguration: chatConfiguration,
enableResponseCaching: false,
executionName: "with-judges");
await run.EvaluateAsync(
userRequest: query,
modelResponse: answer,
additionalContext:
[
new RetrievalEvaluationContext(outcome), // 喂我们的指标
new RetrievalEvaluatorContext(chunks), // 喂 Retrieval 裁判
new GroundednessEvaluatorContext(string.Join("\n", chunks)), // 喂 Groundedness 裁判
]);
两边的指标并排进同一份报告,名字不冲突(我们的带 @K,裁判的用词不同),实测可以共存于同一个 CompositeEvaluator:
Hit@5 = 1 Recall@5 = 1 Precision@5 = 0.2 MRR@5 = 1 nDCG@5 = 1 AP@5 = 1
Retrieval = 4 ← LLM 裁判
Groundedness = 4 ← LLM 裁判
★ 喂给 LLM 裁判的正文就是 RetrievedItem.Text —— 想让裁判能用,检索结果里得带上 chunk 正文。
四、A/B 对比(改了检索策略,是不是真变好了)
同一批用例跑两遍,逐条配对求差,再对差值做 bootstrap:
RetrievalEvaluationReport baseline = await new RetrievalEvaluationRunner(retrievalOld, k: 3)
.RunAsync(cases);
RetrievalEvaluationReport candidate = await new RetrievalEvaluationRunner(retrievalNew, k: 3)
.RunAsync(cases);
// K 和口径直接从两份报告里取;两边不一致会抛,免得比出口径不同的假结论。
// 还能传 confidenceLevel / resamples / seed / tieEpsilon,默认值够用。
RetrievalComparisonReport comparison = RetrievalComparison.Compare(baseline, candidate);
Console.WriteLine(comparison.ToText());
实测输出(候选版把 8 条的目标从第 3 位提到第 1 位,但把另外 4 条挤出了前 3):
K = 3 | 用例 12(配对 12 / 未标期望 0) | 查准率口径 Cutoff
置信水平 95% | 配对 bootstrap 重采样 1000 次(种子 0)
指标 基线 候选 差值 95% 区间 赢/平/输 判定
Hit@3 1.000 0.667 -0.333 [-0.583, -0.083] 0/8/4 显著变差
Recall@3 1.000 0.667 -0.333 [-0.583, -0.083] 0/8/4 显著变差
Precision@3 0.333 0.222 -0.111 [-0.194, -0.028] 0/8/4 显著变差
MRR@3 0.333 0.667 +0.333 [+0.083, +0.583] 8/0/4 显著变好
nDCG@3 0.500 0.667 +0.167 [-0.083, +0.417] 8/0/4 无显著差异
AP@3 0.333 0.667 +0.333 [+0.083, +0.583] 8/0/4 显著变好
赢/平/输 按逐条差值分档:超出 ±1E-09 才算赢或输,中间算持平 —— 均值区间只说平均差多少,这里说多少条真的动了。
显著 ≠ 有用:区间 [+0.001, +0.002] 也叫显著,值不值得改看差值大小。
各指标各自算 95% 区间,没有做多重比较校正 —— 比六个指标时「至少一个假阳性」的概率约 26%。
这就是为什么不能只看一个汇总分:MRR 和 AP 显著变好、Hit 和 Recall 显著变差,
「变好了吗」取决于你更在乎排得准还是找得全。
赢/平/输那一列是另一件事。 上表 Hit@3 是 0/8/4 —— 八条用例压根没动(基线本来就命中、
候选也命中),只有四条被挤出去。均值 -0.333 只说「平均掉了三分之一」,它说不出「八条原地不动」。
对照 MRR@3 的 8/0/4:这一项 12 条全动了(8 条从第 3 位提到第 1 位、4 条被挤出去)。
两份差值的绝对值一样大,一条只影响四分之一的用例,另一条影响全部。
差值在 ±tieEpsilon(默认 1e-9)内的算持平。这个阈值只吃浮点噪声 ——
IR 指标最小的非零步长是 1/K 量级(K 上千也有 1e-3),所以真实的改善不会被误判成持平。
它怎么算的:
| 决策 | 做法 | 为什么 |
|---|---|---|
| 配对还是独立 | 配对:按用例算 B_i − A_i 再汇总 |
同一批用例里难题对两边都难,逐条作差把这部分方差消掉,同样的用例数能看出更小的差别 |
| 逐条分档 | 差值在 ±tieEpsilon(默认 1e-9)内算持平,另给赢/平/输计数 |
均值只说平均差多少。「50 条里 30 条变好」和「50 条里 3 条大改」是同一份均值、完全不同的结论。阈值取 1e-9 是因为 IR 指标最小非零步长是 1/K 量级,它只吃浮点噪声 |
| 统计方法 | 百分位 bootstrap(默认 1000 次) | 不假设正态 —— 命中率是 0/1 的,t 检验的前提不成立 |
| 随机性 | 固定种子(默认 0) | 同一份输入永远算出同一个区间,报告才拿得去跟上一版比 |
| 进不进 MEAI | 不进(不做 IEvaluator) |
MEAI 的 IEvaluator 是单条评估,A/B 是跨用例的,层级不同 |
| 多重比较 | 不校正,但在报告里把假阳性风险写出来 | 校正会让人以为「不显著 = 没差别」,把判断权留给读报告的人 |
两边必须是同一份评估集、同样的顺序 —— 用例数、每条的查询、每条的期望目标都对得上,否则直接抛。 静默错配会算出一份看着很正式的假显著性,比失败危险得多。
指标口径
用之前先看清楚,否则跟别人的数字对不上。
| 约定 | 做法 |
|---|---|
| 去重 | 召回率、nDCG、AP 按去重后的期望目标算。文档级标注下,同一个文档的多个分块同时被召回只算命中一个目标 —— 不去重会让 DCG 超过理想值,算出大于 1 的 nDCG。 |
| 查准率分母 | 默认固定 K(PrecisionDenominator.Cutoff);切成 Retrieved 则除以实际返回条数。 |
| nDCG 的理想排序 | 按 min(期望目标数, K) 截断 —— 只惩罚排序,不惩罚召回不足。 |
三个指标的分工:nDCG@K 说「排得准不准」,Recall@K 说「找得全不全」,
MAP@K 两者都管(AP 的分母是期望目标总数,漏召回会被罚)。
跟 LlamaIndex 对不上数,多半是这两条:
- 它的 nDCG 理想排序不截断 → 期望 10 个、K=3、前 3 条全中时它给
0.469,我们给1.000(我们跟trec_eval的ndcg_cut一致)。 - 它的 DCG / AP 不去重 → 同一个 id 返回两次会算出 nDCG > 1。
设计约定
- 指标层不调模型。 同一份标注配同一份检索结果,永远算出同一个数 —— 这正是它跟 LLM 裁判最本质的区别(LLM 打分需要校准)。
- 一个用例抛异常,整个跑批失败。 刻意如此:静默跳过会印出一份「召回 0.6」的假报告。
- 跑批串行、不并发。 评估要的是可复现,不是快。
- 缺上下文不抛异常,而是返回空值指标 + 一条 error 诊断(MEAI 惯例,报告里能直接看到为什么没有值)。
- 指标名带截断位置(
Recall@3)—— 不带 K 会让 topK=3 / topK=5 两次运行撞成同一个名字。 - A/B 对比是跨用例的,不是 MEAI 的
IEvaluator。 它比的是「两套系统在同一批用例上的平均表现」, 一条用例的评估结果撑不起一个置信区间。
依赖
- .NET 10
Microsoft.Extensions.AI.Evaluation
许可证
MIT —— 见 LICENSE。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.AI.Evaluation (>= 10.10.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.