RAG 可观测性实战:上线后必须能定位“为什么答错“(五)
目录
前言:
1、为什么 RAG 比普通应用更需要可观测性
2、全链路追踪:六个节点一个都不能漏
2.1 RAG 全链路的六个节点
2.2 每个节点记录什么
2.3 Trace 的代码实现
3、结构化日志:每个字段都有它的用途
3.1 九个字段锁定问题根因
3.2 结构化日志的写入
3.3 日志分级
4、监控指标:六把尺子量系统健康度
4.1 每个指标的深层含义
4.1.1 延迟:要看分布,不看平均值
4.1.2 Token 成本:关注趋势而非绝对值
4.1.3 检索命中率:最需要基线的指标
4.1.4 拒答率:系统诚实的信号
4.1.5 用户点赞 / 点踩率:最真实的质量信号
4.1.6 人工修正率:最硬核的质量指标
5、问题定位:四类错误,四种查法
5.1 检索不到:相关文档压根没被召回
5.2 检索错了:召回了,但排序不对
5.3 生成错了:上下文有正确答案,但模型没用
5.4 幻觉:模型编造了上下文中没有的信息
6、生产最佳实践与工具链
6.1 可观测性工具链
6.2 上线检查清单
一句话总结
前言:
RAG 系统上线只是开始。Demo 阶段"能不能回答"很容易验证,但生产环境里每天都会出现"答错、答偏、答不出来"的 case,而 RAG 是一条多阶段流水线——查询改写错了?检索没召回?重排序把对的排下去了?上下文组装丢了关键片段?还是 LLM 生成阶段幻觉了?
没有可观测性,工程师面对用户投诉只能反复盲猜 prompt;有了可观测性,任何一个 bad case 都能在十分钟内定位到具体环节。这就是 RAG 运营和 RAG 玩具的分水岭。
RAG 系统上线后,不能只看"能不能回答",还要能定位"为什么答错"。可观测性不是日志打得越多越好,而是围绕"归因"来设计:每一层留下足够的证据,让错误可以逐环节排除。
1、为什么 RAG 比普通应用更需要可观测性
普通的 API 服务,出了问题通常很明显:500 报错、延迟飙升、内存溢出。但 RAG 系统出问题的方式非常隐蔽——它不会报错,不会崩溃,它只是"答得不够好"。
而且这个"不够好"可能发生在链路的任何一个环节:
- 查询理解错了— 用户问"上季度的利润增长",Query 改写把"增长"理解成了"增长率"
- 检索没召回到— 相关文档存在,但 Embedding 相似度不够,排在了 Top-K 之外
- 召回对了但排序错了— 最相关的文档排在第 15 位,你的窗口只取了 Top 10
- Reranker 把好文档排下去了— Cross-Encoder 对某些 Query 的判断不如向量检索准
- 上下文组装截断了— 关键信息在文档末尾,被 Token 限制截掉了
- 模型生成跑偏了— 上下文有正确答案,但模型"选择性失明",编了一个不同的
核心痛点
上述六个环节,每一个都可能独立出错,也可能组合出错。如果没有全链路追踪,你只能看到"最终回答不好",但根本不知道问题出在哪一步。就像一个黑盒——输入和输出你都能看到,但中间发生了什么,完全是盲区。
更麻烦的是,RAG 系统的问题往往是渐进式退化而非突发式崩溃。文档库增长导致检索精度慢慢下降,新文档质量参差不齐拉低了整体效果,模型 API 的行为随着版本更新悄悄漂移——这些都不是"今天突然坏了",而是"不知不觉变差了"。没有基线对比和趋势监控,你甚至意识不到问题在发生。
2、全链路追踪:六个节点一个都不能漏
全链路追踪的核心思想:给每个请求分配一个唯一trace_id,从用户输入到最终输出,经过的每一个环节都记录在这个 trace 上。出了问题,拿着trace_id一查,全链路每一步的状态一目了然。
2.1 RAG 全链路的六个节点
① 用户输入 (query) ↓ ② 查询改写结果 (rewritten_query) ← 改写/扩展/多轮改写/HyDE ↓ ③ 检索结果 (retrieved_chunks) ← 向量检索 + 关键词检索 ↓ ④ 重排序结果 (reranked_chunks) ← 顺序变了必须记! ↓ ⑤ 最终上下文 (final_context) ← 截断/去重/组装后的真实输入 ↓ ⑥ LLM 输出 (completion) ← 含引用、拒答与否- 为什么记"查询改写":多轮对话场景下,改写是错误重灾区。"它多少钱"被改写成错误指代,后面全链路跟着错。改写环节的输入(原始 query + 对话历史)和输出都要留。
- 为什么检索和重排序分开记:这是归因的关键分界。正确的 chunk 在检索结果里、但被重排序挤出了 top-k——问题在 rerank;检索结果里根本没有——问题在召回。两个阶段各记
chunk_id 列表 + 分数,一比就知道错在哪一跳。 - 为什么记"最终上下文"而不是只记检索结果:检索到 ≠ 进了 prompt。截断策略、去重、token 预算都可能把正确答案在组装阶段丢掉。第⑤环节记录的是模型真实看到的东西,这是审 case 时的 ground truth。
工程要点trace 全量落库成本高,常见策略是采样 + 全量留存 bad case:正常请求按 1–5% 采样,但用户点踩、拒答、低置信度的请求100% 留全量 trace——因为你要排查的恰恰就是这些。工 Existing 上可直接用 OpenTelemetry 的 span 模型或 LangSmith / Langfuse / Phoenix 这类 LLM 观测平台的 trace 能力,不必自研全部。
每个节点必须记录:输入是什么、输出是什么、耗时多少、是否成功。这样出了问题,你能精确定位到具体哪一步。
2.2 每个节点记录什么
| 节点 | 记录输入 | 记录输出 | 关键指标 |
|---|---|---|---|
| 用户输入 | 原始 Query | — | — |
| 查询改写 | 原始 Query | 改写后 Query(可能多个) | 改写是否改变语义、耗时 |
| 检索 | 改写后 Query | 召回的 Chunk 列表 + 相似度分数 | 召回数量、Top-1 分数、分数分布 |
| 重排序 | 召回的 Chunk 列表 | 重排后的 Chunk 列表 + 新分数 | 排序变化(哪些被提上来了/打下去了) |
| 上下文组装 | 重排后 Top-K Chunks | 最终 Prompt(含截断信息) | Token 数、是否截断、截断位置 |
| LLM 生成 | 最终 Prompt | Completion 文本 | 延迟、Token 消耗、是否触发了安全策略 |
2.3 Trace 的代码实现
import uuid from dataclasses import dataclass, field from typing import Any import time @dataclass class RAGTrace: trace_id: str = field(default_factory=lambda: str(uuid.uuid4())) timestamp: float = field(default_factory=time.time) # 六个节点的完整记录 raw_query: str = "" rewritten_query: str = "" retrieved_chunks: list[dict] = field(default_factory=list) # [{text, score, doc_id, chunk_id}] reranked_chunks: list[dict] = field(default_factory=list) final_context: str = "" # 实际送给 LLM 的 Prompt completion: str = "" # 每步耗时 rewrite_latency_ms: float = 0 retrieval_latency_ms: float = 0 rerank_latency_ms: float = 0 llm_latency_ms: float = 0 total_latency_ms: float = 0 # 成本 input_tokens: int = 0 output_tokens: int = 0 estimated_cost: float = 0.0 # 用户反馈 user_feedback: str | None = None # "up" / "down" / None def to_dict(self) -> dict: return { "trace_id": self.trace_id, "timestamp": self.timestamp, "raw_query": self.raw_query, "rewritten_query": self.rewritten_query, "retrieved_chunks": [ {"doc_id": c["doc_id"], "score": c["score"], "text_preview": c["text"][:100]} for c in self.retrieved_chunks ], "reranked_chunks": [ {"doc_id": c["doc_id"], "score": c["score"]} for c in self.reranked_chunks ], "final_context_tokens": len(self.final_context) // 4, # 粗估 "completion": self.completion[:200], "latency": { "rewrite": self.rewrite_latency_ms, "retrieval": self.retrieval_latency_ms, "rerank": self.rerank_latency_ms, "llm": self.llm_latency_ms, "total": self.total_latency_ms, }, "tokens": {"input": self.input_tokens, "output": self.output_tokens}, "cost": self.estimated_cost, "user_feedback": self.user_feedback, } # 在 RAG 管道中使用 def rag_pipeline(query: str) -> str: trace = RAGTrace(raw_query=query) # Step 1: 查询改写 t0 = time.time() trace.rewritten_query = rewrite_query(query) trace.rewrite_latency_ms = (time.time() - t0) * 1000 # Step 2: 检索 t0 = time.time() trace.retrieved_chunks = vector_search(trace.rewritten_query, top_k=20) trace.retrieval_latency_ms = (time.time() - t0) * 1000 # Step 3: 重排序 t0 = time.time() trace.reranked_chunks = rerank(trace.retrieved_chunks, query, top_k=5) trace.rerank_latency_ms = (time.time() - t0) * 1000 # Step 4: 组装上下文 trace.final_context = build_prompt(query, trace.reranked_chunks) # Step 5: LLM 生成 t0 = time.time() response = llm.invoke(trace.final_context) trace.llm_latency_ms = (time.time() - t0) * 1000 trace.completion = response.text trace.input_tokens = response.usage.input_tokens trace.output_tokens = response.usage.output_tokens # 记录完整 Trace log_trace(trace.to_dict()) return response.text关键设计点
retrieved_chunks和reranked_chunks都要记录完整的 score 和 doc_id。因为排查问题时,你首先看的就是"相关文档有没有被召回"和"召回后排序对不对"——如果只存了最终 Prompt,这些中间信息就丢了。
3、结构化日志:每个字段都有它的用途
全链路追踪告诉你"每一步做了什么",结构化日志告诉你"每一步的详细数据是什么"。日志必须结构化(JSON),不能是纯文本——因为你要按字段查询、聚合、统计。
3.1 九个字段锁定问题根因
| 字段 | 类型 | 记录什么 | 排查时怎么用 |
|---|---|---|---|
query | string | 用户原始输入 | 判断问题是不是出在输入本身(太模糊、太短) |
rewritten_query | string | 改写后的查询 | 对比原始 Query,看改写有没有改变语义 |
retrieved_chunks | array | 召回的文档片段(doc_id + score + text) | 检查相关文档有没有被召回、分数分布是否合理 |
scores | array | 检索分数和重排分数的对比 | 看 Reranker 有没有把好文档排下去 |
prompt | string | 最终送给 LLM 的完整 Prompt | 看上下文有没有截断、格式对不对 |
completion | string | LLM 返回的回答 | 对比上下文,看模型有没有"无视"正确信息 |
latency | object | 各环节耗时(ms) | 定位延迟瓶颈在哪个环节 |
token_usage | object | 输入 token、输出 token | 成本分析和预算监控 |
user_feedback | string | 用户点赞 / 点踩 / 修正 | 标注数据质量,用于离线分析和模型优化 |
高价值技巧:user_feedback 闭环
user_feedback不只是统计指标——点踩的请求自动关联 trace 落入 bad case 池,定期人工归因后沉淀为回归评测集。这样线上问题会持续转化为测试资产,每次调整分块策略、换 embedding 模型、改 prompt 时跑一遍,防止"修好一个坏三个"。这是 RAG 系统能持续进化的关键机制。
隐私与合规提醒:日志里的 query、chunks、completion 可能含敏感内容,落库前要过脱敏策略,并设置保留期限(如 30–90 天),trace 采样与全量留存的边界也要过安全评审。
3.2 结构化日志的写入
import json import logging # 结构化 Logger:每条日志都是一个 JSON 对象 logger = logging.getLogger("rag") def log_trace(trace_data: dict): # 写入结构化日志,支持后续 ELK / Loki 检索 logger.info(json.dumps(trace_data, ensure_ascii=False)) # 同时写入 trace 存储(如 ClickHouse / Postgres) # 便于做聚合查询和趋势分析 trace_store.insert(trace_data) # 查询示例:找出所有用户点踩的请求 # SELECT trace_id, raw_query, completion, user_feedback # FROM rag_traces WHERE user_feedback = 'down' ORDER BY timestamp DESC LIMIT 50;日志存储的坑
retrieved_chunks和prompt字段可能很大(每个 chunk 几百字,20 个 chunk 加 Prompt 轻松超过 10KB)。如果用 Elasticsearch 存全量,成本会很高。建议:chunk 内容只存前 100 字 preview,完整内容存对象存储(S3 / OSS),日志里只存 URL 指针。
3.3 日志分级
不是所有日志都要全量记录。按场景分级:
| 级别 | 记录频率 | 记录内容 | 存储位置 |
|---|---|---|---|
| 全量 Trace | 每个请求 | 九字段完整记录 | ClickHouse / Postgres(保留 30 天) |
| 异常 Trace | 出错时 | 全量 + 堆栈 + 环境信息 | Elasticsearch / Loki(长期保留) |
| 采样 Trace | 1% 正常请求 | 全量(含完整 Prompt 和 Completion) | 对象存储(用于离线分析) |
| 聚合指标 | 每分钟 | QPS、平均延迟、成功率 | Prometheus / Grafana |
4、监控指标:六把尺子量系统健康度
日志是"事后排查"用的,指标是"实时监控"用的。六个核心指标,覆盖 RAG 系统的延迟、成本、质量三个维度。
4.1 每个指标的深层含义
4.1.1 延迟:要看分布,不看平均值
延迟必须看分位数(P50 / P95 / P99),不看平均值。RAG 系统的延迟长尾很重——90% 的请求 2 秒返回,10% 因为检索了更多文档或触发了重试要 15 秒。平均值看起来 3.5 秒还行,但那 10% 的用户体验已经崩了。
# 分段延迟监控:每个环节独立追踪 latency_breakdown = { "rewrite": {"p50": 480, "p99": 1200}, # ms "retrieval": {"p50": 85, "p99": 320}, "rerank": {"p50": 210, "p99": 580}, "llm_first_token": {"p50": 850, "p99": 3200}, "llm_total": {"p50": 1800, "p99": 6500}, "end_to_end": {"p50": 2625, "p99": 8600}, } # 上面可以看出:瓶颈在 LLM 生成阶段,尤其是 P99 # 重排 P99 580ms 也有优化空间(考虑换更快的 Reranker)4.1.2 Token 成本:关注趋势而非绝对值
单日成本的绝对值意义不大(取决于流量),但成本趋势非常重要。如果日均单请求成本从 0.03 元涨到 0.05 元,可能是因为:
- 检索召回的文档变长了(分块策略变了或文档更新了)
- 上下文组装逻辑有 Bug,塞了多余的 Chunk
- 模型版本更新,Token 计数方式变了
- Query 改写生成了更多变体,导致多路检索
4.1.3 检索命中率:最需要基线的指标
检索命中率(Recall@K)需要一个标注数据集作为基线——人工标注 100-500 个 Query 对应的正确文档,定期跑一遍,看正确文档是否出现在 Top-K 内。如果命中率突然下降,第一时间检查:
- 有没有新文档入库,拉低了相似度分布
- Embedding 模型有没有被意外更换
- 向量索引有没有被重建(重建时可能参数变了)
4.1.4 拒答率:系统诚实的信号
拒答率不是越低越好。一个从不拒答的系统,要么是检索太强(什么都能找到),要么是模型在"硬编"答案。适当的拒答率(5-10%)说明系统的忠实度约束在工作——该说"不知道"的时候说了。
警惕拒答率突升
如果拒答率突然从 8% 涨到 25%,通常不是"系统变诚实了",而是检索出了问题——文档没召回,导致系统频繁说"找不到相关信息"。先查检索层,再查 Prompt 层。
4.1.5 用户点赞 / 点踩率:最真实的质量信号
这是唯一来自用户的主观质量指标。但要注意采样偏差——会主动点踩的用户通常是"极度不满"的,满意的用户不一定点赞。所以绝对值不重要,趋势变化才重要。如果点踩率从 8% 涨到 15%,系统一定出了问题。
4.1.6 人工修正率:最硬核的质量指标
如果你的系统有"用户编辑答案后重新提交"的功能,修正率是最硬核的质量指标——它直接量化了"AI 回答离用户满意有多远"。修正率高说明回答方向对但细节不够,修正率低但点踩率高说明回答方向就错了。
5、问题定位:四类错误,四种查法
有了全链路追踪和结构化日志,问题定位就有章可循。用户反馈"回答不好"时,按以下四条路径逐一排查:
5.1 检索不到:相关文档压根没被召回
症状:系统说"找不到相关信息"或答非所问。但你知道知识库里明明有相关文档。
排查清单:
- 查分块策略— 是不是 chunk 太小,导致相关段落被切散了?或者 chunk 太大,相关内容被稀释了?
- 查 Embedding— 换个 Embedding 模型试试。中文场景下 BGE 和 m3e 的表现差异很大
- 查索引— 向量索引有没有被正确构建?HNSW 的
ef_search参数是否太小? - 查过滤条件— 元数据过滤是否把相关文档排除了?(比如时间范围、权限过滤)
- 查 Query 改写— 改写后 Query 的语义是否偏了?原 Query 明明是对的,改写后反而检索不到
# 快速诊断:用原始 Query 直接检索,对比改写后 Query 的结果 results_original = vector_search(raw_query, top_k=20) results_rewritten = vector_search(rewritten_query, top_k=20) # 如果原始 Query 能召回但改写后不能 → 改写出问题了 # 如果都召不回 → 检索层/索引/分块出问题了 # 如果能召回但分数很低 → Embedding 模型对该类 Query 效果差5.2 检索错了:召回了,但排序不对
症状:相关文档被召回了,但排在第 15 名,你的 Top-K=10 取不到它。
排查清单:
- 查相似度分数分布— Top-1 到 Top-20 的分数差距是否很小?如果是,说明 Embedding 区分度不够
- 查混合检索权重— 如果用了 BM25 + 向量混合检索,权重配比对排序影响很大
- 查重排序— Reranker 是否把相关文档排下去了?对比 rerank 前后的排序变化
- 查 Top-K 设置— K 是否太小?考虑增大 K 但加 Reranker 筛选
# 对比 Reranker 前后排序变化 for i, chunk in enumerate(trace.retrieved_chunks[:10]): rerank_pos = next( (j for j, rc in enumerate(trace.reranked_chunks) if rc["doc_id"] == chunk["doc_id"]), None ) if rerank_pos is not None and rerank_pos > i: print(f"⚠️ doc {chunk['doc_id']} 从第{i+1}名被排到第{rerank_pos+1}名")5.3 生成错了:上下文有正确答案,但模型没用
症状:最终上下文里明明包含了正确信息,但 LLM 的回答完全无视了它。
排查清单:
- 查 Prompt— System Prompt 有没有明确要求"基于上下文回答"?上下文的格式是否清晰?
- 查上下文位置— 正确信息在上下文的什么位置?如果在中间,可能是 Lost in the Middle
- 查 Token 截断— 上下文是否被截断?正确信息可能在被截掉的部分
- 查模型能力— 换一个更强的模型试试。某些小模型对长上下文的利用能力确实差
- 查指令冲突— System Prompt 中的指令是否互相矛盾?比如既要求"简洁"又要求"详细"
# 检查正确信息在上下文中的位置 correct_info = "利润增长15%" # 已知正确答案关键词 position = trace.final_context.find(correct_info) total_len = len(trace.final_context) if position == -1: print("❌ 正确信息不在最终上下文中 → 检索/截断问题") elif position < total_len * 0.3: print("✅ 在开头,模型应该能注意到") elif position > total_len * 0.7: print("⚠️ 在末尾,可能被忽略") else: print("⚠️ 在中间,Lost in the Middle 风险")5.4 幻觉:模型编造了上下文中没有的信息
症状:回答看起来很合理、很自信,但内容是编的。上下文中根本没有这个信息。
排查清单:
- 查忠实度约束— System Prompt 有没有"只能基于以下信息回答"的硬约束?
- 查拒答策略— 上下文不包含答案时,系统是否被指示说"我不知道"?还是默认"尽力回答"?
- 查上下文质量— 上下文是否包含误导性内容?模型可能被低质量 Chunk 带偏
- 查温度设置— Temperature 是否过高?降到 0-0.3 减少创造性
- 查引用溯源— 是否要求模型标注信息来源?没有溯源的答案很难判断真假
# 幻觉检测:答案中的事实是否都能在上下文中找到 def detect_hallucination(completion: str, context: str) -> list[str]: """ 提取答案中的数字/日期/名称等事实 检查是否在上下文中出现 """ import re # 提取数字、百分比、日期等关键事实 facts = re.findall(r'\d+\.?\d*%?|\d{4}年|\d{1,2}月\d{1,2}日', completion) missing = [] for fact in facts: if fact not in context: missing.append(fact) return missing # 返回上下文中找不到的事实 # 如果 missing 非空,说明答案包含上下文中没有的数据 → 幻觉排查流程总结
拿到一个"回答不好"的 case,按顺序走四条路径:先查检索有没有召回 → 再查排序对不对 → 再查模型有没有用上下文 → 最后查有没有幻觉。每一步都用
trace_id拉出全链路数据,对照排查清单逐项检查。90% 的问题都能在 10 分钟内定位。
6、生产最佳实践与工具链
6.1 可观测性工具链
追踪层
OpenTelemetry / LangSmith / Langfuse — 负责全链路 Trace 的采集、存储、可视化。LangSmith 和 Langfuse 专门为 LLM 应用设计,原生支持 Prompt / Completion / Token 等字段的展示。
日志层
ELK(Elasticsearch + Logstash + Kibana)或 Grafana Loki — 存储结构化日志,支持按
trace_id、user_feedback、latency等字段检索和聚合。
指标层
Prometheus + Grafana — 实时监控延迟分布、Token 成本、检索命中率等指标。设置告警阈值(如 P99 > 10s 或 成本日环比 > 20% 触发告警)。
评估层
Ragas / TruLens — 离线评估 RAG 质量的框架。定期在标注数据集上跑评估,生成 Faithfulness(忠实度)、Answer Relevancy(答案相关性)、Context Precision(上下文精度)等指标,作为系统质量的基线。
6.2 上线检查清单
- 每个请求都有唯一
trace_id,贯穿全链路所有日志 - 六个节点(输入→改写→检索→重排→上下文→生成)的输入输出都被记录
- 检索结果保存完整 score 和 doc_id,不只是 text
- 延迟按环节分段记录,至少有 P50 和 P99
- Token 用量和成本每次请求都记录
- 有用户反馈通道(点赞/点踩/修正),反馈绑定到 trace
- 有标注数据集做定期基线评估(至少 100 个 Query)
- 关键指标有告警阈值(延迟、成本、成功率)
- 日志有保留策略(全量 30 天,采样长期保留)
- 有"问题 case 归档"机制,点踩的 case 自动进入待分析队列
一句话总结
RAG 的可观测性 = 全链路留痕(能查) + 结构化日志(有据) + 指标大盘(能盯) + 故障归因树(会判) + 用户反馈闭环(能进化)。
RAG 可观测性的核心不是"装一堆监控工具",而是建立一条从用户反馈到问题根因的完整链路:用户说"答错了" → 拿 trace_id 拉全链路数据 → 看是检索没召回、排序不对、上下文截断、还是模型幻觉 → 针对性修复。没有这条链路,你永远在猜问题出在哪;有了这条链路,10 分钟定位根因。
RAG文章:
RAG 深度解析:从原理到工程实践(一)
RAG 检索深度指南:从 Embedding 到 Query 处理(二)
RAG 进阶双引擎:重排序精准化与生成可控化的工程实践(三)
RAG 进阶之路Agent 自主决策与评估体系(四)
