Chico Notes
研究与调研

RAG 系统的检索质量与可观测性

从 Recall@K、MRR、nDCG 到完整 Retrieval Trace,建立能区分召回、融合、重排、上下文组装和生成失败的生产级 RAG 评测体系。

持续修订的工程笔记

证据快照:本文核对时间为 2026-08-05。实现细节优先依据 Elasticsearch RRF RetrieverRanking Evaluation APIQuery LoggingOpenTelemetry Semantic Conventions 1.44.0GenAI 属性注册表。OpenTelemetry 的 GenAI Retrieval / Agent 语义仍处于 Development;生产实现应锁定 SemConv、模型、索引快照、Pipeline 和评测集版本,而不是只记录“使用了 Hybrid Search”。

RAG 检索质量与可观测性:查询经过 BM25、Dense、融合、重排、上下文构建与生成的主链路

图 1:RAG 查询从检索到生成的主链路概览。它用于建立整体心智模型;具体指标、失败归因和工程判断以正文中的 Retrieval Trace 与 Mermaid 技术图为准。

摘要

RAG 的失败不能只归因于“模型没答好”。相关证据可能没有进入索引、被权限过滤、没有进入候选集、在 RRF 中掉队、被 Reranker 错删、因 Token 预算被截断,或已经进入上下文却没有被模型正确使用。生产系统需要同时建设离线检索评测、端到端答案评测和逐阶段 Retrieval Trace,才能知道该改 Embedding、查询、索引、排序、上下文还是 Prompt。

读者将学到什么

读完本文,你应该能回答:

  1. Recall@K、MRR、nDCG、Context Precision 和 Faithfulness 分别衡量什么。
  2. 为什么“Top 10 看起来不错”和“答案正确”不是同一件事。
  3. 如何构造包含难负样本、无答案问题、时间版本和权限边界的评测集。
  4. 怎样追踪 BM25、kNN、RRF、Rerank、Context Builder 与 Citation 的每一步。
  5. 如何在 MySQL + Elasticsearch 架构中保存 Trace、回放查询、做 A/B 和回归测试。

1. RAG 质量不是一个分数,而是一条因果链

一个典型 RAG 请求可以拆成:

用户问题
→ Query Rewrite / Router
→ 权限与元数据过滤
→ BM25 / Sparse / Dense 候选召回
→ Fusion
→ Parent Context / Neighbor Expansion
→ Rerank / Diversify
→ Context Packing
→ LLM Generation
→ Citation Mapping
→ 用户反馈

最终答案错误,至少可能有九种原因:

阶段典型失败该改什么
数据入库文档未同步、解析错误、Chunk 缺字段Ingestion、Parser、索引一致性
Query实体、时间、语言或意图识别错误Rewrite、Router、词典
FilterACL、租户、版本或业务过滤过严权限映射、过滤顺序
Candidate正确 Chunk 未进入 Top KBM25、Embedding、kNN 参数
Fusion单路强结果被其他列表稀释RRF 窗口、权重、查询数量
Rerank正确证据被 Cross-Encoder 降级Reranker、候选窗口、训练数据
Context正确证据因去重或 Token 预算被丢弃Packing、Parent、压缩
Generation上下文存在,但模型忽略或误解Prompt、模型、结构化引用
Citation答案正确,但引用映射到错误 ChunkClaim-Evidence 对齐

因此,生产系统不应该只有:

answer_correct = true / false

而应该能回答:

gold evidence 是否已入库?
是否满足当前权限?
是否进入 BM25 Top 100?
是否进入 kNN Top 100?
RRF 后排第几?
Rerank 前后变化多少?
是否被 Context Builder 选中?
模型是否引用了它?
最终 Claim 是否被该证据支持?

这就是“可观测性”与“日志”的区别:日志告诉你系统做过什么;可观测性让你从结果反推出失败发生在哪一层。


2. 三套评测必须分开:Retrieval、Context、Answer

RAGAS、ARES、RAGChecker 等框架都强调模块化评测:检索到的上下文是否相关,生成是否忠实于上下文,答案本身是否回答了问题。[4][5][6]

可以把评测分为三层。

2.1 Retrieval Quality:候选是否找对

输入:

query + corpus snapshot + filters

输出:

ranked chunk/document list

主要问题:

  • 相关证据是否进入候选集?
  • 排名是否足够靠前?
  • 是否覆盖回答问题所需的多个证据?
  • 候选是否重复、陈旧或越权?

这是搜索系统问题,通常不需要调用生成模型就能评估。

2.2 Context Quality:送给模型的证据是否够用

输入:

retrieved candidates + token budget + packing policy

输出:

selected context

主要问题:

  • 正确候选是否在重排、去重或截断后仍被保留?
  • 多跳问题的必要证据是否完整?
  • 是否包含大量重复、冲突或无关内容?
  • 每段上下文是否保留稳定 Citation Anchor?

Context Quality 很容易被忽略。团队常说“检索 Top 100 中有正确答案”,但模型实际只看到 Top 8;那么 Top 100 的成功对最终回答没有意义。

2.3 Answer Quality:模型是否正确使用证据

输入:

question + selected context + instructions

输出:

answer + citations

主要问题:

  • 答案是否正确、完整、相关?
  • 每个事实 Claim 是否由上下文支持?
  • 是否拒绝回答语料中不存在的信息?
  • 引用是否真的指向支持该 Claim 的来源?

这层才能使用 Answer Correctness、Faithfulness、Citation Precision、Citation Recall 和人工评分。

2.4 不要用端到端分数替代检索诊断

端到端答案正确,不一定说明检索好:

  • 模型可能凭参数知识答对;
  • 问题可能过于简单;
  • 引用可能是装饰;
  • 错误证据刚好没有影响答案。

反过来,检索正确也不保证答案正确:

  • 证据需要跨段推理;
  • 模型忽略了关键限定;
  • 冲突文档没有时间排序;
  • Token 截断切掉了结论。

Salemi 与 Zamani 的 eRAG 工作指出,传统 Query-Document 相关性标签与下游 RAG 表现的相关性可能较弱,并提出用单个文档对下游任务的效用来重新定义相关性。[7] 工程上的结论不是放弃 IR 指标,而是同时保留:

传统相关性
+ 对当前答案任务的证据效用
+ 端到端回答质量

3. 先建立 Gold Evidence,而不是先跑 LLM Judge

没有可靠评测集,任何“优化”都可能只是对几十个示例过拟合。

一条评测样本至少应包含:

{
  "query_id": "q_000184",
  "query": "齐夏第一次明确提出回响理论是在什么场景?",
  "query_type": "multi_evidence_temporal",
  "tenant_id": "demo",
  "as_of": "2026-08-05T00:00:00Z",
  "allowed_scope": {
    "series_id": "series_01",
    "episode_ids": ["ep_01", "ep_02", "ep_03"]
  },
  "gold_sources": ["doc:episode_02_script"],
  "gold_chunks": ["chunk:ep02_0184", "chunk:ep02_0185"],
  "required_facts": [
    "提出者是齐夏",
    "场景发生在指定剧情段",
    "需要区分首次暗示与首次明确提出"
  ],
  "answerability": "answerable",
  "reference_answer": "...",
  "label_version": 3
}

3.1 三层 Gold Label

不要只标一个 gold_chunk_id。推荐分三层:

  1. Source-level:哪份文档、哪一集、哪个业务对象包含答案;
  2. Passage-level:哪些 Chunk 或段落直接支持答案;
  3. Fact-level:答案必须覆盖哪些原子事实。

这样可以区分:

  • Source 找对但 Chunk 切坏;
  • Chunk 找对但缺第二个证据;
  • 证据完整但答案漏掉事实。

3.2 评测集必须覆盖真实失败分布

至少包含:

类型为什么需要
精确实体测试 BM25、别名、拼写和分词
语义改写测试 Embedding 与 Query Rewrite
多跳问题测试证据覆盖与 Context Packing
时间问题测试 as_of、版本和新旧事实
比较问题测试多来源平衡与去重
无答案问题测试 Negative Rejection
权限问题测试 ACL 是否在召回前正确生效
长尾实体防止只在热门内容上表现好
表格/数字测试结构化内容和数值推理
噪声与冲突测试对错误、过时和相互矛盾来源的处理

RGB 将 RAG 能力拆成噪声鲁棒性、拒答、信息整合与反事实鲁棒性;CRAG 又加入实体流行度与事实时效性的变化。[8][9] 这些维度比“随机抽 100 个问答”更接近生产风险。

3.3 难负样本比随机负样本更重要

随机抽一个完全无关 Chunk,任何模型都能判断不相关。真正有价值的 Hard Negative 包括:

  • 同一角色、不同事件;
  • 同一产品、旧版本文档;
  • 同一关键词、不同租户;
  • 相邻段落但缺关键结论;
  • 语义相似却事实相反;
  • 正确来源中的错误 Chunk;
  • 已被业务撤销或删除的旧事实。

评测集如果没有难负样本,Reranker 和 LLM Judge 的数字会虚高。

3.4 评测集也要版本化

每次评测都要记录:

eval_dataset_version
corpus_snapshot
index_alias
physical_index
embedding_model
reranker_model
query_pipeline_version
prompt_version
generator_model

否则“本周 Recall@20 提高 4%”可能只是语料发生了变化。

3.5 Gold 不完整时,必须报告 Unjudged

现实中很难为整个语料库穷举所有相关 Chunk。常见做法是对多套 Retriever 的候选池做人工标注,但这会留下大量 **Unjudged(未判断)**结果。

这里最危险的默认假设是:

unjudged = irrelevant

它会系统性低估能找到新证据的 Retriever,也会让新模型因为“检索到标注池之外的结果”而被错误惩罚。更稳妥的做法是同时报告:

judged@K
unjudged_ratio@K
precision@K_on_judged
recall@K_on_known_gold

其中:

judged@K = Top K 中已有人工相关性标签的结果数 / K

并把低 judged@K 的 Query 标记为 评测数据不足,而不是直接判定检索失败。Elasticsearch _rank_eval 的 Precision 配置可以选择忽略未标注结果;无论采用哪种配置,都必须把这一选择写入 Experiment,避免不同实验使用了不同的 Unjudged 口径。[14]

Gold 完整性本身也应成为评测资产的质量指标:

pool_depth
label_coverage
last_reviewed_at
label_source
adjudication_status

4. 检索指标怎么选

4.1 Hit@K:最容易解释

如果 Top K 中至少出现一个 Gold Evidence,则命中:

Hit@K(q) = 1, if any gold item appears in top K
         = 0, otherwise

适合:

  • 单证据事实问答;
  • 粗略判断候选集是否有用;
  • 产品和业务方容易理解的报表。

缺点:

  • Gold 排第 1 和第 K 没区别;
  • 多证据问题只命中一个也算成功;
  • 不衡量无关内容数量。

4.2 Recall@K:候选阶段的核心指标

Recall@K =
Top K 中命中的 Gold Evidence 数
/ 全部 Gold Evidence 数

适合:

  • 多证据问题;
  • 评估 BM25、Dense、Hybrid 的候选覆盖;
  • 判断 Reranker 之前是否还有挽救空间。

对于 BM25/kNN Top100 → Reranker Top20 → LLM Top8 的链路,应该分别记录:

candidate_recall@100
reranked_recall@20
context_recall@8

三个数字的差值就是阶段损失:

rerank_loss =
candidate_recall@100 - reranked_recall@20

packing_loss =
reranked_recall@20 - context_recall@8

4.3 MRR:第一个正确结果有多靠前

RR(q) = 1 / rank_of_first_relevant
MRR = average(RR)

适合:

  • 一般只需要一个答案来源;
  • 搜索入口或 Citation 首条质量;
  • 用户往往只看最前面结果的场景。

缺点:忽略第二、第三个必要证据,不适合多跳问题单独使用。

4.4 nDCG@K:相关程度和顺序同时考虑

当标注是多级相关性,例如:

3 = 直接且完整支持
2 = 部分支持
1 = 背景相关
0 = 无关

nDCG 会让高相关文档出现在更靠前位置获得更高分。

适合:

  • 同一问题有多个不同质量的候选;
  • 需要比较排序模型;
  • 搜索结果不是简单二元相关。

Elasticsearch 的 _rank_eval 当前支持 Precision、Recall、MRR、DCG/nDCG、ERR 等典型指标,并要求为每个测试查询提供相关性评级。[14]

4.5 Precision@K:Context 阶段比 Candidate 阶段更重要

候选召回常故意过召回,所以 Precision@100 不一定要很高。真正需要高 Precision 的位置是:

送入 LLM 的 Top 5 / Top 8 / Top 12

因为无关上下文会:

  • 消耗 Token;
  • 稀释关键证据;
  • 引入冲突;
  • 增加 Prompt Injection 面;
  • 使 Citation 更难对齐。

所以建议:

Candidate 阶段优先 Recall
Context 阶段同时优化 Recall 与 Precision

4.6 Coverage、Redundancy 与 Diversity

传统指标还不够。生产 RAG 还应记录:

source_coverage
fact_coverage
duplicate_ratio
same_parent_ratio
same_source_ratio
stale_document_ratio
unauthorized_candidate_count

例如 Top 8 全来自同一个长文档的相邻 Chunk,Recall 可能很好,但对比较问题没有覆盖另一个必要来源。

4.7 不要直接比较 BM25 与向量 _score

BM25、余弦相似度、点积、Sparse 模型与 Cross-Encoder 分数通常不在同一尺度。直接做:

0.5 * bm25_score + 0.5 * vector_score

只有在分数归一化和数据分布经过验证后才有意义。

RRF 使用排名而不是原始分数,可以组合不同检索器的结果。Elasticsearch 当前 RRF Retriever 会独立执行子检索器,再按照各列表排名融合;rank_window_size 会限制每个结果集进入融合的深度。当前 Retriever 还支持为不同子检索器设置权重,但权重、窗口和 rank_constant 都必须进入实验版本,不能变成不可追踪的线上常量。[12]

4.8 IR Precision@K 不等于 RAGAS Context Precision

这两个名字相似,但评测对象和计算方式不同。

指标依赖什么回答的问题
IR Precision@K人工或规则相关性标签Top K 结果中有多少被标为相关
RAGAS Context Precision问题、上下文,以及可选 Reference / Response,由 Judge 判断与回答任务相关的上下文是否排在无关上下文之前

IR Precision 可以完全确定性计算;RAGAS Context Precision 通常包含 LLM Judge,对 Prompt、Judge 模型和输入可见范围敏感。[4]

因此 Dashboard 不要都写成 context_precision。建议明确命名:

ir_precision_at_8
ragas_context_precision
judge_model
judge_prompt_version

否则一次 Judge 模型更新,可能被误解为 Retriever 本身变好了。


5. Hybrid Search 的正确评测方式

BEIR 在多领域零样本评测中发现,BM25 是强健基线,而 Reranking 和 Late Interaction 往往有更高平均质量,但计算成本也更高。[2] 这对生产系统有两个启示:

  1. 不要因为 Dense Retrieval 更新就删除 BM25 基线;
  2. 不要只比较单个 Retriever,要比较完整预算下的 Pipeline。

5.1 分路记录,不只保存最终 RRF

每次查询都应保存:

bm25_rank
bm25_score
knn_rank
knn_score
rrf_rank
rerank_score
final_context_rank

这样才能分析:

  • BM25 独有命中率;
  • Dense 独有命中率;
  • 两路重叠率;
  • RRF 新增了多少 Gold;
  • Reranker 挽救或破坏了哪些结果。

推荐指标:

bm25_hit@K
dense_hit@K
union_hit@K
intersection_rate@K
rrf_hit@K
rerank_hit@K
rrf_incremental_recall
reranker_rescue_rate
reranker_damage_rate

5.2 RRF 不是“永远更好”

一项 2026 年生产式研究在固定候选深度、重排预算和延迟约束下发现,多查询融合提高了原始 Recall,但收益在 Rerank 与上下文截断后基本消失;部分配置的 Hit@10 从 0.51 降到 0.48,同时增加了查询改写和更大候选集的延迟。[11]

这不是证明 RRF 无效,而是说明:

retrieval gain
≠ context gain
≠ answer gain

因此每个实验都必须报告:

维度示例
候选质量Recall@100、nDCG@100
重排后质量Recall@20、MRR@20
实际上下文Fact Coverage@8、Token 利用率
答案质量Correctness、Faithfulness、Citation
系统成本p50/p95 延迟、Embedding/LLM Token、并发
失败副作用无答案误答、越权、陈旧引用

5.3 多查询检索要衡量增量价值

不要只记录“生成了 4 个查询”。要记录每个 Rewrite 的边际贡献:

rewrite_0 = 原始问题
rewrite_1 = 实体化表达
rewrite_2 = 关键词表达
rewrite_3 = 假设性答案表达

对每个 Rewrite 计算:

new_gold_found
new_unique_candidates
duplicate_candidates
latency_ms
embedding_cost
downstream_context_selected

如果某类 Rewrite 几乎从不提供最终被选中的证据,就应该删除,而不是继续扩大并发。


6. 面向 MySQL + Elasticsearch 的生产架构

在你的架构里,建议继续坚持:

  • MySQL 保存 Document、Chunk、Revision、权限、任务和评测事实;
  • Elasticsearch 保存 BM25 与向量派生索引;
  • 业务只访问稳定 Alias;
  • _v1 / _v2 物理索引用于 Reindex 和原子切换;
  • Embedding、Rerank 与 Graph 都是可重建投影,不是唯一真相。

这张图里最重要的是:评测标签与 Trace 不是 ES 搜索日志的附属字段,而是独立的质量数据产品。


7. 一条查询应该记录什么

7.1 Root Trace

{
  "trace_id": "ragtr_01K...",
  "query_id": "q_8f31...",
  "session_id": "ses_42",
  "tenant_id": "tenant_8",
  "user_id_hash": "sha256:...",
  "query_text_hash": "sha256:...",
  "query_text_ref": "secure://rag-input/q_8f31",
  "language": "zh",
  "query_type": "multi_hop",
  "answerability_prediction": "unknown",
  "corpus_snapshot": "kb_2026_08_05_01",
  "index_alias": "kb_gateway_wiki",
  "physical_index": "kb_gateway_wiki_v12",
  "pipeline_version": "rag-search@31",
  "started_at": "2026-08-05T07:10:20.184Z"
}

生产环境不建议默认把完整用户问题、文档正文和 Prompt 复制到所有 Span Attribute。完整内容可能包含隐私,而且高基数字段会显著增加遥测成本。默认保存:

  • 稳定 ID;
  • Hash;
  • 长度;
  • 分类;
  • 安全存储引用;
  • 采样后的完整内容。

OpenTelemetry 的 GenAI 属性也明确警告输入消息、检索 Query 与文档可能包含敏感信息。[16][17]

7.2 Query Rewrite Event

{
  "event": "rag.query.rewritten",
  "original_query_hash": "...",
  "rewrites": [
    {
      "id": "rw_0",
      "type": "original",
      "text_hash": "...",
      "tokens": 18
    },
    {
      "id": "rw_1",
      "type": "entity_expansion",
      "text_hash": "...",
      "tokens": 24
    }
  ],
  "router": {
    "route": "hybrid",
    "confidence": 0.91
  },
  "duration_ms": 32
}

7.3 Candidate Event

每个 Candidate 至少记录:

{
  "chunk_id": "chunk_0192",
  "document_id": "doc_44",
  "revision": 7,
  "source_type": "script",
  "retriever": "bm25",
  "rank": 3,
  "score": 11.42,
  "matched_fields": ["title", "content"],
  "filter_passed": true,
  "acl_policy_version": 12,
  "content_hash": "sha256:...",
  "is_stale": false
}

不要只记录最终 Top 8。诊断需要保存足够深的候选窗口,例如 BM25 与 kNN 各 Top 50 或 Top 100;正文可以放对象存储,Trace 表只保存 ID、排名与分数。

7.4 Rank Transformation Event

每经过一层都记录前后排名:

{
  "chunk_id": "chunk_0192",
  "bm25_rank": 3,
  "knn_rank": null,
  "rrf_rank": 11,
  "rerank_rank": 2,
  "context_rank": 1,
  "selected": true,
  "drop_reason": null
}

被丢弃的 Candidate 也要有原因:

acl_rejected
stale_revision
duplicate_chunk
same_parent_limit
low_rerank_score
token_budget
source_diversity
citation_unavailable

否则你只能看到“没有被选中”,不知道为什么。


8. 推荐的 Trace 拓扑

建议 Span 名保持低基数:

rag.query
rag.query.rewrite
rag.retrieval
rag.fusion
rag.rerank
rag.context.pack
gen_ai.generate_content
rag.citation.verify

高基数值放属性,不要把 Query、模型 ID、租户 ID拼进 Span Name。

OpenTelemetry 当前核心 Semantic Conventions 文档版本为 1.44.0;GenAI 约定已经迁移到独立仓库,该仓库当前声明的 Schema URL 为 gen-ai/1.42.0,Retrieval 与 Agent 相关约定仍处于 Development。[16][17] 因为这些约定仍在变化,生产上要把 SemConv 版本作为 Telemetry Resource Attribute 保存,并允许迁移期双写字段。


9. 一套可落地的数据模型

9.1 Query Trace

CREATE TABLE rag_query_trace (
    trace_id             VARCHAR(64) PRIMARY KEY,
    tenant_id            VARCHAR(64) NOT NULL,
    session_id           VARCHAR(64),
    query_hash           CHAR(64) NOT NULL,
    query_type           VARCHAR(32),
    corpus_snapshot      VARCHAR(64) NOT NULL,
    index_alias          VARCHAR(128) NOT NULL,
    physical_index       VARCHAR(128) NOT NULL,
    pipeline_version     VARCHAR(64) NOT NULL,
    embedding_model      VARCHAR(128),
    reranker_model       VARCHAR(128),
    generator_model      VARCHAR(128),
    status               VARCHAR(32) NOT NULL,
    total_latency_ms     INT,
    created_at           TIMESTAMP(6) NOT NULL,
    INDEX idx_tenant_time (tenant_id, created_at),
    INDEX idx_pipeline (pipeline_version, created_at)
);

9.2 Candidate Snapshot

CREATE TABLE rag_candidate_trace (
    trace_id             VARCHAR(64) NOT NULL,
    chunk_id             VARCHAR(64) NOT NULL,
    document_id          VARCHAR(64) NOT NULL,
    revision             BIGINT NOT NULL,
    retriever            VARCHAR(32) NOT NULL,
    original_rank        INT,
    original_score       DOUBLE,
    fused_rank           INT,
    fused_score          DOUBLE,
    rerank_rank          INT,
    rerank_score         DOUBLE,
    context_rank         INT,
    selected             BOOLEAN NOT NULL DEFAULT FALSE,
    drop_reason          VARCHAR(64),
    metadata_json        JSON,
    PRIMARY KEY (trace_id, chunk_id, retriever),
    INDEX idx_chunk (chunk_id),
    INDEX idx_trace_selected (trace_id, selected)
);

9.3 Claim 与 Citation

CREATE TABLE rag_answer_claim (
    trace_id             VARCHAR(64) NOT NULL,
    claim_id             VARCHAR(64) NOT NULL,
    claim_text_hash      CHAR(64) NOT NULL,
    claim_order          INT NOT NULL,
    support_status       VARCHAR(32) NOT NULL,
    verifier_score       DOUBLE,
    PRIMARY KEY (trace_id, claim_id)
);

CREATE TABLE rag_claim_citation (
    trace_id             VARCHAR(64) NOT NULL,
    claim_id             VARCHAR(64) NOT NULL,
    chunk_id             VARCHAR(64) NOT NULL,
    entailment _score     DOUBLE,
    citation_status      VARCHAR(32) NOT NULL,
    PRIMARY KEY (trace_id, claim_id, chunk_id)
);

9.4 为什么不把全部内容塞进 Trace 表

候选正文、Prompt、模型输出和工具原始结果可能很大。建议:

MySQL:
  ID、排名、版本、评分、状态、成本、引用关系

Object Storage:
  采样后的完整请求、候选正文、Prompt、响应、评测中间结果

Elasticsearch / ClickHouse:
  用于 Trace 检索、聚合、看板的投影

这与“关系库保存事实、ES 保存可重建查询投影”的原则一致。


10. Context Builder 必须被视为独立排序器

很多系统只有 Retriever 与 LLM,中间的 Context Builder 是几十行没有指标的代码。但它实际上决定模型看见什么。

Context Builder 常执行:

去重
→ Parent/Neighbor 扩展
→ 合并相邻 Chunk
→ Source Diversity
→ 时间排序
→ 权限复核
→ Token 截断
→ Citation Anchor 注入

每一步都可能丢掉 Gold Evidence。

10.1 Parent Context 的两面性

召回一个小 Chunk 后补充父段落,可以提升可读性,但也可能:

  • 把无关正文带进上下文;
  • 重复包含相邻内容;
  • 使一个来源占满 Token;
  • 让 Citation 粒度过粗。

需要同时记录:

retrieved_chunk_id
expanded_parent_id
original_tokens
expanded_tokens
gold_evidence_preserved

10.2 MMR 与去重不是免费的

MMR 能减少相似候选,但对于连续剧情、步骤说明和长论证,相邻 Chunk 可能都是必要证据。建议把去重策略按 Query Type 配置:

Query 类型建议
单事实强去重,保留最直接证据
多跳优先 Fact Coverage,不要只追求多样性
时间线保留相邻事件和时间顺序
对比强制多 Source / Entity 覆盖
摘要允许更大同源覆盖,但限制重复文本

10.3 Token 利用率

定义:

useful_context_tokens
/ total_context_tokens

“有用”可以来自 Gold Fact、人工标注或 Claim-Evidence 对齐。该指标比单纯 Context Length 更能说明 Packing 是否有效。

还可以记录:

context_token_count
context_source_count
context_document_count
context_duplicate_ratio
context_gold_fact_coverage
context_stale_ratio

11. 最小实现:带 Trace 的 Hybrid Retrieval

下面是结构化 Python 示例。它省略了鉴权和网络客户端细节,但展示了阶段化 Span、Candidate 快照和 Drop Reason。

from __future__ import annotations

from dataclasses import dataclass, field
from hashlib import sha256
from time import perf_counter
from typing import Any, Protocol, Sequence

from opentelemetry import trace

tracer = trace.get_tracer("chico.rag", "1.0.0")

@dataclass(frozen=True)
class Candidate:
    chunk_id: str
    document_id: str
    revision: int
    content: str
    retriever: str
    rank: int
    score: float
    metadata: dict[str, Any] = field(default_factory=dict)

@dataclass
class RankedCandidate:
    candidate: Candidate
    fused_rank: int | None = None
    fused_score: float | None = None
    rerank_rank: int | None = None
    rerank_score: float | None = None
    selected: bool = False
    drop_reason: str | None = None

class Retriever(Protocol):
    def search(
        self,
        query: str,
        *,
        filters: dict[str, Any],
        limit: int,
    ) -> list[Candidate]:
        ...

class Reranker(Protocol):
    def rank(
        self,
        query: str,
        candidates: list[RankedCandidate],
        *,
        limit: int,
    ) -> list[RankedCandidate]:
        ...

def reciprocal_rank_fusion(
    result_lists: Sequence[Sequence[Candidate]],
    *,
    weights: Sequence[float] | None = None,
    rank_constant: int = 60,
) -> list[RankedCandidate]:
    if rank_constant < 0:
        raise ValueError("rank_constant must be non-negative")

    actual_weights = list(weights or [1.0] * len(result_lists))
    if len(actual_weights) != len(result_lists):
        raise ValueError("weights must match result_lists")
    if any(weight < 0 for weight in actual_weights):
        raise ValueError("weights must be non-negative")

    scores: dict[str, float] = {}
    items: dict[str, Candidate] = {}

    for weight, results in zip(actual_weights, result_lists):
        for rank, item in enumerate(results, start=1):
            items[item.chunk_id] = item
            scores[item.chunk_id] = (
                scores.get(item.chunk_id, 0.0)
                + weight / (rank_constant + rank)
            )

    ordered = sorted(
        scores.items(),
        key=lambda pair: pair[1],
        reverse=True,
    )

    return [
        RankedCandidate(
            candidate=items[chunk_id],
            fused_rank=index,
            fused_score=score,
        )
        for index, (chunk_id, score) in enumerate(ordered, start=1)
    ]

def build_context(
    ranked: list[RankedCandidate],
    *,
    token_budget: int,
    max_per_document: int = 2,
) -> list[RankedCandidate]:
    selected: list[RankedCandidate] = []
    per_document: dict[str, int] = {}
    used_tokens = 0
    seen_content_hashes: set[str] = set()

    for item in ranked:
        content_hash = sha256(item.candidate.content.encode("utf-8")).hexdigest()
        if content_hash in seen_content_hashes:
            item.drop_reason = "duplicate_chunk"
            continue

        doc_id = item.candidate.document_id
        if per_document.get(doc_id, 0) >= max_per_document:
            item.drop_reason = "same_parent_limit"
            continue

        estimated_tokens = max(1, len(item.candidate.content) // 2)
        if used_tokens + estimated_tokens > token_budget:
            item.drop_reason = "token_budget"
            continue

        item.selected = True
        selected.append(item)
        seen_content_hashes.add(content_hash)
        per_document[doc_id] = per_document.get(doc_id, 0) + 1
        used_tokens += estimated_tokens

    return selected

def answer_with_rag(
    *,
    query: str,
    filters: dict[str, Any],
    bm25: Retriever,
    dense: Retriever,
    reranker: Reranker,
    trace_sink,
    llm,
) -> str:
    started = perf_counter()

    with tracer.start_as_current_span("rag.query") as root:
        root.set_attribute("rag.pipeline.version", "rag-search@31")
        root.set_attribute("rag.query.type", "unknown")
        root.set_attribute("rag.index.alias", "kb_gateway_wiki")

        with tracer.start_as_current_span("rag.retrieval") as span:
            span.set_attribute("gen_ai.operation.name", "retrieval")
            lexical = bm25.search(query, filters=filters, limit=100)
            semantic = dense.search(query, filters=filters, limit=100)
            span.set_attribute("rag.lexical.count", len(lexical))
            span.set_attribute("rag.semantic.count", len(semantic))

        trace_sink.write_candidates("bm25", lexical)
        trace_sink.write_candidates("dense", semantic)

        with tracer.start_as_current_span("rag.fusion") as span:
            fusion_weights = [1.0, 1.0]
            fused = reciprocal_rank_fusion(
                [lexical, semantic],
                weights=fusion_weights,
                rank_constant=60,
            )[:100]
            span.set_attribute("rag.fusion.rank_constant", 60)
            span.set_attribute("rag.fusion.weights", fusion_weights)

        trace_sink.write_fused(fused)

        with tracer.start_as_current_span("rag.rerank") as span:
            reranked = reranker.rank(query, fused, limit=20)
            span.set_attribute("rag.rerank.input_count", len(fused))
            span.set_attribute("rag.rerank.output_count", len(reranked))

        with tracer.start_as_current_span("rag.context.pack") as span:
            selected = build_context(
                reranked,
                token_budget=6000,
                max_per_document=2,
            )
            span.set_attribute("rag.context.count", len(selected))
            span.set_attribute(
                "rag.context.drop.token_budget",
                sum(x.drop_reason == "token_budget" for x in reranked),
            )

        trace_sink.write_final_ranks(reranked)

        with tracer.start_as_current_span(
            "gen_ai.generate_content"
        ) as span:
            answer = llm.generate(
                query=query,
                contexts=[
                    {
                        "chunk_id": x.candidate.chunk_id,
                        "content": x.candidate.content,
                    }
                    for x in selected
                ],
            )
            span.set_attribute("rag.answer.citation_count", len(answer.citations))

        root.set_attribute(
            "rag.total.duration_ms",
            int((perf_counter() - started) * 1000),
        )
        return answer.text

生产实现还要补充:

  • Query Hash 与安全内容引用;
  • tenant_id 与 ACL Policy Version;
  • Embedding Cache Hit;
  • Timeout、Retry 与降级原因;
  • Reranker 失败后回退原 Fusion 排名;
  • 零结果时扩大候选或降阈值;
  • revision 校验,避免旧 Chunk 覆盖新版本;
  • 生成后的 Claim-Citation Verification。

示例里的 SHA-256 只用于说明稳定去重键;高吞吐生产链路更适合在入库时计算并持久化规范化 content_hash,查询时直接复用。Python 内置 hash() 会随进程随机化,不应作为跨进程 Trace、缓存或去重标识。


12. Elasticsearch 中如何做回放与诊断

12.1 一个基础 Hybrid + RRF 请求

以下示例使用 Elasticsearch 当前 Retriever 语法;字段和 License 能力要按实际集群版本调整。

GET kb_gateway_wiki/_search
{
  "size": 20,
  "_source": [
    "chunk_id",
    "document_id",
    "revision",
    "title",
    "content",
    "source_type"
  ],
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "bool": {
                "must": {
                  "multi_match": {
                    "query": "Agent Memory 和 State 有什么区别",
                    "fields": [
                      "title^3",
                      "content",
                      "entities^2"
                    ]
                  }
                },
                "filter": [
                  { "term": { "tenant_id": "tenant_8" } },
                  { "term": { "index_status": "active" } }
                ]
              }
            }
          }
        },
        {
          "knn": {
            "field": "content_vector",
            "query_vector": [0.012, -0.081, 0.044],
            "k": 100,
            "num_candidates": 500,
            "filter": {
              "bool": {
                "filter": [
                  { "term": { "tenant_id": "tenant_8" } },
                  { "term": { "index_status": "active" } }
                ]
              }
            }
          }
        }
      ],
      "rank_window_size": 100,
      "rank_constant": 60
    }
  }
}

评测时要保存完整请求模板与参数,不能只保存自然语言 Query。

12.2 _rank_eval 用于检索回归

Elasticsearch 的 Ranking Evaluation API 可以对典型查询和人工评分文档计算 Precision、Recall、MRR、DCG/nDCG 等指标。[14]

适合:

  • BM25 Analyzer 改动;
  • 字段 Boost 调整;
  • Alias 切换到新物理索引;
  • RRF 参数变化;
  • 新 Embedding 模型上线前回归。

_rank_eval 只评估搜索排序,不能代替 Context Builder 与 Answer Evaluation。

12.3 Query Logging 用于完整查询可见性

Elastic 当前的 Query Logging 面向协调节点上的完整查询:它可以记录查询总耗时、请求主体或摘要,并通过 X-Opaque-Idtrace.id 等字段与应用 Trace 关联。[18]

它和 Search Slow Log 的边界不同:

工具观察范围适合解决什么
应用 Retrieval TraceRouter 到 Citation 的端到端链路质量归因、跨系统延迟、用户影响
Query LoggingElasticsearch 协调节点看到的完整请求全查询耗时、请求关联、查询模式分析
Profile API单次请求内部 Query Plan采样定位某个查询组件为什么慢
Search Slow Log单 Shard 的 Query / Fetch 阶段慢 Shard、Analyzer 或局部执行问题

Query Logging 是异步、Best-effort 的诊断通道;缓冲或下游异常时可能丢记录,因此不能把它当作审计事实源。启用单独日志集群时,还要按当前 Elastic 版本核对目标集群兼容要求。[18]

建议在应用侧保存:

trace_id
X-Opaque-Id
pipeline_version
query_template_hash

再把这些低敏感、低体积标识传给 Elasticsearch。不要默认把用户原始 Query 和完整文档复制到每个遥测系统。

12.4 Profile API 只用于采样诊断

Elasticsearch Profile API 会提供底层查询组件耗时,但官方文档明确提醒它会增加显著开销,而且不包含网络延迟、排队时间与协调节点合并结果等时间。[15]

因此:

线上全量:
  应用 Trace + ES took + timeout + shard failure

问题查询采样:
  profile=true

集群级:
  Search Slow Log + Thread Pool + Shard/Node Metrics

Search Slow Log 是按 Shard 的 Query / Fetch 阶段记录,而且阈值默认关闭;它适合定位慢 Shard,不适合代表用户端到端延迟。当前 Elastic 文档已将 Query Logging 作为整条 Search Query 的主要日志入口,而 Indexing Slow Log 仍负责慢写入诊断。[15][18]


13. 在线可观测指标

13.1 系统延迟

query_rewrite_ms
embedding_ms
bm25_ms
knn_ms
fusion_ms
parent_expand_ms
rerank_ms
context_pack_ms
llm_ttft_ms
llm_total_ms
citation_verify_ms
rag_total_ms

每个指标看:

p50 / p90 / p95 / p99
timeout rate
retry rate
fallback rate

13.2 检索健康度

zero_candidate_rate
candidate_count
bm25_dense_overlap@K
top1_top2_score_gap
rrf_unique_source_count
reranker_rank_delta
reranker_rescue_rate
reranker_damage_rate
stale_candidate_rate
acl_rejection_rate
judged_at_k
unjudged_ratio_at_k
query_log_drop_rate

top1_top2_score_gap 不是通用置信度,只能作为当前模型和当前 Query 类型下的特征;阈值必须用标注数据校准。judged_at_kunjudged_ratio_at_k 只对进入评测池的查询有意义。query_log_drop_rate 也只有在 Query Logging 或导出链路暴露丢弃统计时才能计算,不能用“日志条数变少”反推。

13.3 Context 健康度

context_chunk_count
context_token_count
context_source_count
context_duplicate_ratio
context_gold_recall
context_fact_coverage
token_budget_drop_rate
parent_expansion_multiplier

13.4 答案与引用

answer_correctness
faithfulness
unsupported_claim_rate
citation_precision
citation_recall
citation_entailment
answer_refusal_accuracy

Citation Precision 与 Recall 要分开:

Citation Precision:
引用出去的证据有多少真的支持对应 Claim

Citation Recall:
需要证据支持的 Claim 有多少得到了有效引用

答案后面挂了三个链接,不代表引用质量高。

13.5 成本

embedding_tokens
rewrite_tokens
reranker_documents
context_input_tokens
generation_input_tokens
generation_output_tokens
judge_tokens
trace_bytes

如果每次改进只报告准确率,不报告候选窗口、延迟与 Token,生产团队无法判断是否值得上线。


14. 线上反馈不能直接当 Gold Label

点击、复制、点赞、追问和人工纠错都很有价值,但都有偏差:

  • 用户可能只点击排名最靠前的结果;
  • 没有点击不代表结果无关;
  • 用户可能满意答案却没验证事实;
  • 负反馈可能针对文风而不是检索;
  • 某些错误答案很自信,反而不容易被追问。

建议把在线信号分级:

信号用途可信度
明确“引用错误”反馈Citation 评测候选
人工选择正确来源Gold Evidence
用户改写后成功Query Failure 样本中高
追问“依据是什么”Grounding 风险
点击来源弱相关性信号中低
停留时间需要结合场景
无反馈不能当正样本很低

高价值失败 Trace 应进入人工审核队列,再生成版本化 Gold Label。


15. LLM Judge 怎么用,怎么防止自欺

RAGAS 提供无需大量参考答案的自动评测思路;ARES 使用合成数据训练轻量 Judge,并结合少量人工标注做统计校准;RAGChecker进一步提供细粒度诊断指标。[4][5][6]

它们适合加快迭代,但不能替代人工基准。

15.1 Judge 必须做 Meta-Evaluation

先准备一组人工评分样本,再测 Judge:

与人工标签的相关性
分类准确率
不同 Query 类型的偏差
不同语言的偏差
长答案与短答案的偏差
不同 Judge 模型的一致性

15.2 Judge 输入要固定

记录:

judge_model
judge_prompt_version
temperature
context_visibility
reference_answer_visibility
citation_visibility
scoring_scale

Prompt 或模型一变,分数分布就可能变化。

15.3 不让同一个模型同时当选手和唯一裁判

同源模型可能共享偏好或错误模式。更稳妥的评测组合是:

可计算 IR 指标
+ 独立 LLM Judge
+ 小规模人工复核
+ 真实线上反馈

15.4 Judge 输出要保存理由,但统计只用结构化值

{
  "metric": "context_relevance",
  "score": 0.75,
  "label": "partially_relevant",
  "reason_codes": [
    "contains_primary_evidence",
    "contains_redundant_context"
  ],
  "judge_model": "judge-model-x",
  "prompt_version": "ctx-rel@7"
}

自然语言理由用于审查,Dashboard 依赖低基数 reason_codes


16. 失败归因:先找 First Evidence Loss

端到端错误通常会在后续阶段继续放大。真正需要先修复的,是必要证据第一次从“仍可形成正确答案的链路”中消失的位置,也就是 First Evidence Loss

诊断顺序应该从事实层向答案层推进:

Corpus
→ Parse / Chunk
→ Scope
→ Candidate
→ Fusion
→ Rerank
→ Context
→ Generation / Citation

不要因为最后一个错误发生在生成阶段,就默认修改 Prompt。若 Gold 在 Candidate 阶段已经消失,换 Generator 只会让模型更自信地猜。

16.1 Gold 不完整时,先判评测数据

First Evidence Loss 的前提是 Gold 足以支持归因。以下情况应先标记 EVAL_INCOMPLETE

  • Gold Source 已被替换,但标签仍指向旧快照;
  • 只有一个 Gold Chunk,实际存在多条等价证据;
  • Query 被错误标为 Answerable;
  • 权限 Scope 与标注时不一致;
  • 未判断结果占 Top K 的比例过高。

这类样本不能用于证明 Retriever 回归,应进入人工补标或仲裁队列。

16.2 失败代码矩阵

失败代码判断条件责任层
EVAL_INCOMPLETEGold、Answerability 或 Scope 不足以归因Evaluation Data
CORPUS_MISSINGGold Source 不在当前快照Ingestion
PARSE_LOSSSource 存在但 Gold Passage 未形成正确 ChunkParser/Chunker
ACL_FALSE_NEGATIVEGold 有权限却被过滤ACL/Filter
LEXICAL_MISSBM25 未召回,Dense 召回Analyzer/Query
SEMANTIC_MISSDense 未召回,BM25 召回Embedding
CANDIDATE_MISSUnion Top N 无 GoldRetriever
FUSION_DROP单路有 Gold,Fusion 窗口无 GoldFusion
RERANK_DROPFusion 有 Gold,Rerank Top M 无 GoldReranker
PACKING_DROPRerank 有 Gold,最终 Context 无 GoldContext Builder
EVIDENCE_INCOMPLETE只覆盖部分 Required FactsRetrieval/Context
GENERATOR_IGNOREContext 完整但答案漏掉Generator
UNSUPPORTED_CLAIM答案加入上下文没有的信息Generator
CITATION_MISMATCHClaim 与引用不蕴含Citation
STALE_EVIDENCE使用旧 RevisionVersioning
SHOULD_REFUSE无答案却生成确定答案Router/Generator

每个失败样本只能归一个根因吗?不一定。可以保存:

primary_failure
contributing_failures[]

例如 Gold 没进入 Context,同时答案还产生了 Unsupported Claim。


17. 离线评测与线上发布流程

推荐发布门槛:

离线:
  Recall@100 不下降
  Context Recall@8 不下降
  nDCG@20 达标
  无答案拒答不下降
  ACL 测试必须 100% 通过

性能:
  p95 retrieval latency 增幅受控
  Rerank 文档数与 Token 成本受控

线上:
  Shadow 对比旧链路
  1% → 5% → 25% → 100% Canary
  支持按 pipeline_version 一键回滚

新物理索引切换前,使用相同 Query Set 同时回放旧 Alias Target 与新索引,生成 Per-Query Diff:

Gold Rank 变化
新增/丢失 Candidate
Top K Jaccard
Reranker Rank Delta
Context Diff
Latency Diff

18. 适合小项目与生产系统的分阶段方案

阶段 1:小项目

BM25 + Dense
RRF
手工 50–100 条评测集
Recall@K + MRR
保存最终 Top K 与总延迟
人工检查答案和引用

重点是尽快建立基线,不要先搭复杂平台。

阶段 2:稳定产品

评测集版本化
BM25/Dense 分路 Trace
Reranker
Context Builder 指标
Claim-Citation Verification
按 Query Type 分桶
A/B 与回归门禁

阶段 3:生产多租户系统

MySQL 事实 + ES 评测投影
完整 Candidate Snapshot
ACL / Time / Revision 测试
Shadow / Canary / Rollback
Telemetry 内容采样与脱敏
人工审核队列
Judge Meta-Evaluation
成本与质量联合优化

阶段 4:复杂 Agent / 多跳检索

增加:

Router 决策 Trace
多轮检索步骤
Tool 与 Search Span
Graph / SQL / Web 多数据源
每步 Evidence State
最终 Claim 到多步 Trace 的映射

不要在基础检索尚未可测时直接增加 GraphRAG 或多 Agent。复杂编排会放大不可诊断的问题。


19. 实践检查清单

评测数据

  • 每条 Query 有 Source、Passage 和 Fact 三层 Gold。
  • 包含精确词、语义改写、多跳、时间、无答案和权限样本。
  • 包含旧版本、同名实体和相似错误段落等 Hard Negative。
  • Corpus Snapshot、标签和 Query Set 都有版本。
  • 评测报告包含 judged@K 与 Unjudged 口径。
  • 线上失败可进入人工审核并更新评测集。

检索

  • BM25、Dense、Fusion、Rerank 分别记录排名。
  • 同时计算 Candidate Recall、Reranked Recall 和 Context Recall。
  • RRF 的窗口、常数和 Rewrite 数量进入实验配置。
  • Reranker 有 Rescue Rate 与 Damage Rate。
  • 零结果、超时和 Reranker 失败有明确降级。

Context

  • 每个 Drop 都有原因。
  • Parent Expansion、去重、MMR 和 Token 截断可单独评估。
  • Context 保留稳定 Chunk ID 与 Citation Anchor。
  • 多跳问题测 Fact Coverage,而不只测单个命中。
  • 统计重复率、来源覆盖与 Token 利用率。

答案与引用

  • Answer Correctness 与 Faithfulness 分开。
  • Citation Precision 与 Citation Recall 分开。
  • 无答案问题测试拒答准确率。
  • Judge 模型、Prompt 和评分尺度版本化。
  • 自动 Judge 与人工标签做过相关性校准。

可观测与安全

  • Root Trace 能关联 Query、Index、Pipeline、模型和语料快照。
  • 默认不在 Span 中全量记录敏感 Query、Prompt 和文档正文。
  • 能回放单个 Trace 的完整检索请求。
  • ACL、租户和 Revision 在 Candidate 级可检查。
  • OpenTelemetry Semantic Convention 版本已锁定。
  • Query Logging、应用 Trace、Profile 与 Slow Log 的责任边界明确。
  • Profile API 只用于采样诊断,不在全量线上开启。

发布

  • 新索引与旧索引做 Per-Query Diff。
  • 离线质量、线上延迟、成本和安全都有门槛。
  • 支持 Shadow、Canary 和 Pipeline Version 回滚。
  • Dashboard 可以按 Query Type、语言、租户和模型分桶。
  • 任何“质量提升”都能指出提升发生在哪一层。

总结

RAG 的检索质量不能只看向量相似度,也不能只看最终答案。

一套可信的生产评测体系,需要同时做到:

  1. 把 Retrieval、Context 与 Answer 三层分开;
  2. 建立 Source、Passage、Fact 三层 Gold Evidence;
  3. 用 Recall@K、MRR、nDCG、Coverage 与 Citation 指标描述不同问题;
  4. 记录 BM25、Dense、RRF、Rerank 和 Context Packing 的排名变化;
  5. 为每个被丢弃的 Candidate 保存原因;
  6. 把 Query、索引快照、模型、Pipeline 与评测版本关联到同一个 Trace;
  7. 用固定语料回放、Shadow、Canary 和回滚控制上线风险;
  8. 把质量、延迟、安全和成本放在同一张实验表里。

最终,RAG 可观测性的目标不是收集更多日志,而是让团队看到一条可验证的因果链:

这个答案为什么正确?
这个答案为什么错误?
正确证据在哪一步消失?
这次改动到底改善了哪一层?

只有能回答这些问题,RAG 才从“看起来能用的 Demo”进入可持续优化的生产系统。


参考资料

  1. Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
    https://arxiv.org/abs/2005.11401

  2. Thakur et al., BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models
    https://arxiv.org/abs/2104.08663

  3. Santhanam et al., ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction
    https://arxiv.org/abs/2112.01488

  4. Es et al., RAGAS: Automated Evaluation of Retrieval Augmented Generation
    https://arxiv.org/abs/2309.15217

  5. Saad-Falcon et al., ARES: An Automated Evaluation Framework for Retrieval-Augmented Generation Systems
    https://arxiv.org/abs/2311.09476

  6. Ru et al., RAGChecker: A Fine-grained Framework for Diagnosing Retrieval-Augmented Generation
    https://arxiv.org/abs/2408.08067

  7. Salemi and Zamani, Evaluating Retrieval Quality in Retrieval-Augmented Generation
    https://arxiv.org/abs/2404.13781

  8. Chen et al., Benchmarking Large Language Models in Retrieval-Augmented Generation (RGB)
    https://arxiv.org/abs/2309.01431

  9. Yang et al., CRAG — Comprehensive RAG Benchmark
    https://arxiv.org/abs/2406.04744

  10. Strich et al., T²-RAGBench: Text-and-Table Benchmark for Evaluating Retrieval-Augmented Generation
    https://arxiv.org/abs/2506.12071

  11. Medrano, Verma and Chhabra, Scaling Retrieval Augmented Generation with RAG Fusion: Lessons from an Industry Deployment
    https://arxiv.org/abs/2603.02153

  12. Elastic, Reciprocal Rank Fusion
    https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion

  13. Elastic, Retrievers and Text Similarity Reranker
    https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers

  14. Elastic, Ranking Evaluation API
    https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval

  15. Elastic, Profile Search Requests and Slow Log Settings
    https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html
    https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-slowlog.html

  16. OpenTelemetry, Semantic Conventions 1.44.0
    https://opentelemetry.io/docs/specs/semconv/

  17. OpenTelemetry, GenAI Semantic Conventions
    https://github.com/open-telemetry/semantic-conventions-genai
    https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/

  18. Elastic, Query Logging
    https://www.elastic.co/docs/deploy-manage/monitor/logging-configuration/query-logs


图片清单

1. 原创封面图

  • 文件名:rag-retrieval-observability-cover.png
  • 比例:16
  • 用途:文章封面和社交分享图
  • Alt:一条查询穿过多层检索、排序和证据验证管线,并留下完整可观测轨迹的抽象编辑插画
  • 生成提示词:
A refined editorial illustration for a technical article about RAG retrieval quality and observability. A single luminous query enters a layered information retrieval system, splitting into lexical and semantic search paths, merging through ranked evidence cards, then passing through reranking, context selection, and citation verification. Show subtle trace lines and diagnostic signals connecting every stage, with a clear sense of causality and engineering precision. Deep navy, warm white, restrained cyan and amber accents, modern technical magazine style, spacious 16:9 composition, no text, no letters, no numbers, no logos, no watermark.

2. 生产 RAG 架构图

  • 文件名:rag-quality-observability-architecture.mmd
  • 用途:解释 MySQL 事实层、ES 检索投影、在线 Pipeline 和质量系统
  • Alt:支持完整检索质量追踪的 MySQL 与 Elasticsearch RAG 架构
  • 形式:正文第 6 节 Mermaid Flowchart

3. Retrieval Trace 时序图

  • 文件名:rag-retrieval-trace-sequence.mmd
  • 用途:解释 Query、候选召回、融合、重排、Context、生成与引用验证的事件流
  • Alt:一轮 RAG 查询从召回到引用验证的完整 Trace 时序
  • 形式:正文第 8 节 Mermaid Sequence Diagram

4. 评测发布状态机

  • 文件名:rag-evaluation-release-state-machine.mmd
  • 用途:解释 Offline Eval、Shadow、Canary、Rollback 和持续监控
  • Alt:RAG 检索改动从离线评测到全量发布的状态机
  • 形式:正文第 17 节 Mermaid State Diagram

5. First Evidence Loss 归因图

  • 文件名:rag-first-evidence-loss.mmd
  • 用途:定位必要证据第一次从可用链路中丢失的阶段
  • Alt:Gold Evidence 依次经过语料、权限、候选、融合、重排和上下文,并在首个丢失点归因
  • 形式:正文第 16 节 Mermaid Flowchart

讨论

继续讨论这篇笔记

有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。

On this page

摘要读者将学到什么1. RAG 质量不是一个分数,而是一条因果链2. 三套评测必须分开:Retrieval、Context、Answer2.1 Retrieval Quality:候选是否找对2.2 Context Quality:送给模型的证据是否够用2.3 Answer Quality:模型是否正确使用证据2.4 不要用端到端分数替代检索诊断3. 先建立 Gold Evidence,而不是先跑 LLM Judge3.1 三层 Gold Label3.2 评测集必须覆盖真实失败分布3.3 难负样本比随机负样本更重要3.4 评测集也要版本化3.5 Gold 不完整时,必须报告 Unjudged4. 检索指标怎么选4.1 Hit@K:最容易解释4.2 Recall@K:候选阶段的核心指标4.3 MRR:第一个正确结果有多靠前4.4 nDCG@K:相关程度和顺序同时考虑4.5 Precision@K:Context 阶段比 Candidate 阶段更重要4.6 Coverage、Redundancy 与 Diversity4.7 不要直接比较 BM25 与向量 _score4.8 IR Precision@K 不等于 RAGAS Context Precision5. Hybrid Search 的正确评测方式5.1 分路记录,不只保存最终 RRF5.2 RRF 不是“永远更好”5.3 多查询检索要衡量增量价值6. 面向 MySQL + Elasticsearch 的生产架构7. 一条查询应该记录什么7.1 Root Trace7.2 Query Rewrite Event7.3 Candidate Event7.4 Rank Transformation Event8. 推荐的 Trace 拓扑9. 一套可落地的数据模型9.1 Query Trace9.2 Candidate Snapshot9.3 Claim 与 Citation9.4 为什么不把全部内容塞进 Trace 表10. Context Builder 必须被视为独立排序器10.1 Parent Context 的两面性10.2 MMR 与去重不是免费的10.3 Token 利用率11. 最小实现:带 Trace 的 Hybrid Retrieval12. Elasticsearch 中如何做回放与诊断12.1 一个基础 Hybrid + RRF 请求12.2 _rank_eval 用于检索回归12.3 Query Logging 用于完整查询可见性12.4 Profile API 只用于采样诊断13. 在线可观测指标13.1 系统延迟13.2 检索健康度13.3 Context 健康度13.4 答案与引用13.5 成本14. 线上反馈不能直接当 Gold Label15. LLM Judge 怎么用,怎么防止自欺15.1 Judge 必须做 Meta-Evaluation15.2 Judge 输入要固定15.3 不让同一个模型同时当选手和唯一裁判15.4 Judge 输出要保存理由,但统计只用结构化值16. 失败归因:先找 First Evidence Loss16.1 Gold 不完整时,先判评测数据16.2 失败代码矩阵17. 离线评测与线上发布流程18. 适合小项目与生产系统的分阶段方案阶段 1:小项目阶段 2:稳定产品阶段 3:生产多租户系统阶段 4:复杂 Agent / 多跳检索19. 实践检查清单评测数据检索Context答案与引用可观测与安全发布总结参考资料图片清单1. 原创封面图2. 生产 RAG 架构图3. Retrieval Trace 时序图4. 评测发布状态机5. First Evidence Loss 归因图