RAG 工程与检索系统设计

第3章 RAG 的失败模式:找不到、找错、塞不下、答不准

作者 杨艺韬 · 10,882 字 · 发布于 · 更新于

"Every mature retrieval system is a museum of past failures." — Bruce Croft, IR 经典教材作者

本章要点

  • RAG 的失败可以归成四大家族:找不到(retrieval miss)找错(retrieval error)塞不下(context packing failure)答不准(generation failure)
  • 每一类都由若干具体子模式组成,症状各异但根因可追溯
  • 失败往往级联——找不到导致塞进无关证据,无关证据触发幻觉,幻觉又通过 LLM 流畅表达被用户接受
  • 归因工具不是"看日志找 bug",而是结构化归因:拆阶段、比基线、查证据流
  • 把失败家族内建到系统监控里,是 RAG 从 demo 走向生产的分水岭

3.1 为什么不先讲怎么成功

第 2 章拆完完整链路后,一个朴素的写作顺序是:从知识入库讲到回答生成,每一步都追求"做对"。本专栏不走这条路——我们先讨论失败。

原因是 RAG 的成功路径非常狭窄。一个召回 recall@10=70% 的向量检索、一个 rerank@5 覆盖率 85% 的重排器、一个 hallucination rate 8% 的生成器单独拼起来,整条链路的端到端答对率可能只有 40%。每一个局部指标都"不错",整体却像四个都漏 20% 水的过滤器串联——最终只剩 41% 的有效信号。

这不是理论推算。Anthropic 在 2024 年公开的 Contextual Retrieval 工作中报告:仅用 Embedding 召回(top-20)在 Claude 的内部知识库上错误率约 5.7%;加上 BM25 融合后错误率降到 4.4%;再加 Rerank 到 2.9%;最后配合 Contextual Chunk 预处理降到 1.9%(Anthropic Research 2024,Contextual Retrieval)。每一步都在消化前一步的失败——如果你不知道前一步失败在哪,后一步的优化就是无的放矢。

理解失败模式因此优先于理解成功姿势。本章给出四大失败家族的分类学,后续每一章都会回到这张地图上定位自己讨论的机制是在堵哪一类漏洞。

3.2 四大失败家族全景

flowchart TD
    classDef miss fill:#fee,stroke:#c00
    classDef err fill:#ffd,stroke:#c80
    classDef ctx fill:#def,stroke:#08c
    classDef gen fill:#fef,stroke:#80c
    Q[用户问题] --> R[检索链路]
    R --> M1[找不到 Retrieval Miss]:::miss
    R --> M2[找错 Retrieval Error]:::err
    M1 & M2 --> P[上下文链路]
    P --> M3[塞不下 Packing Failure]:::ctx
    M3 --> G[生成链路]
    G --> M4[答不准 Generation Failure]:::gen
    M4 -.反馈.-> M1
    M4 -.反馈.-> M2
    classDef hidden fill:none,stroke:none

四条主通路各有典型症状:

家族 核心症状 用户看到的 系统观测点
找不到 正确文档不在 top-k "系统说不知道,其实文档里有" recall@k、召回覆盖率
找错 无关/过时文档排在前面 "答非所问""引用的是别的产品" nDCG、MRR、Hit-rate on gold
塞不下 上下文截断或被稀释 "答案前半对、后半漏了""引用只覆盖一部分问题" context utilization、evidence density
答不准 生成环节引入错误 "引用对、回答错""句子通顺但事实错" faithfulness、citation-grounding rate

这四个家族不是并列关系,而是级联关系——前面的失败会放大后面的失败。3.7 节会专门讨论级联。

3.3 找不到:Retrieval Miss

"找不到"意思是:在知识库里确实存在回答问题所需的片段,但检索系统没把它拉到候选集里。这是 RAG 系统里最隐蔽、最致命的失败——因为下游的 rerank 和生成都看不到缺失的证据,无从补救。

子模式 1:Embedding 语义漂移。问题用了一种说法("单点登录"),文档用了另一种说法("SSO""SAML""统一身份认证")。通用 Embedding 模型可能把这两类表述映射到相近但不重合的向量空间,召回时被其他更贴近问题字面的无关文档挤出 top-k。这是 Embedding-only 检索的结构性弱点——第 12 章讲 BM25 为什么仍然重要时会展开。

子模式 2:Chunk 边界切断证据。文档被按 500 token 固定切分,关键结论在段落边界附近被切成两半,分到两个 chunk。每个 chunk 单独看语义都不完整——既不匹配问题也不匹配答案。这种失败的典型特征是:如果把两个相邻 chunk 拼起来,答案就在里面。第 6 章讨论的结构化分块和语义分块就是针对这个子模式。

子模式 3:多文档同义表述稀释。知识库里有 30 份文档都提到 SSO,但只有 1 份准确描述了新版企业版的 SSO 配置。Embedding 相似度把 30 份都召回到 top-20,但 gold 文档的得分不是最高。MRR(Mean Reciprocal Rank)下降——答案的证据被同类噪声掩盖。

子模式 4:索引漂移。离线索引在周一构建,文档在周三更新,在线检索在周五查询——命中的 chunk 指向一个已经被改写、过期或删除的段落。这是第 8 章增量索引要处理的一致性问题。

子模式 5:过滤过严。权限过滤、时间过滤、业务线过滤叠加后,召回候选从 1000 条降到 3 条,其中没有真正相关的。这种失败在企业知识库里非常常见——第 7 章讨论 metadata 和权限模型时会给出具体的工程权衡。

真实数据:BEIR benchmark(Thakur et al., arXiv:2104.08663)在 18 个数据集上评估主流 Embedding 模型,发现 dense retrieval 在 BioASQ(医学)和 TREC-COVID 上 nDCG@10 普遍低于 BM25 5-15 个百分点——领域漂移是 retrieval miss 的第一大来源。MTEB leaderboard(Muennighoff et al., arXiv:2210.07316)让这种差异可量化——生产选 Embedding 前先看自己领域在 MTEB 子榜上的表现,而不是看综合排名。

3.4 找错:Retrieval Error

"找错"意思是:候选集里有相关文档,但排在前面的是错的——rerank 拿到的输入质量不够,生成拿到的证据被污染。

子模式 1:表面相似度高于语义相关。Embedding 模型对字面重叠敏感——"企业版 SSO 价格"这个查询会召回一堆同时含 "企业版" 和 "SSO" 的文档,其中可能包含竞品分析、旧版 FAQ、历史合同,真正相关的产品规格书可能因为用词不完全重合而排在第 15 位。

子模式 2:长尾问题的错召回。训练 Embedding 的语料分布决定了模型对高频领域(新闻、百科、通用问答)比对低频专业领域(法规条文、行业合同、工程规范)更敏感。专业领域查询容易召回表达风格相似但语义无关的通用文档。

子模式 3:Rerank 放大偏差。Rerank 模型对上下文长度敏感——cross-encoder 通常只看前 512 token。如果文档开头是寒暄、免责声明、版本说明,真正的内容在后半段被截断。Rerank 基于截断后的文本打分,把无关文档误判为高相关。第 14 章会讨论 rerank 的上下文窗口工程。

子模式 4:时间/版本维度缺失。问题问的是"2025 年最新企业版套餐",但召回没有考虑 publish_date。一份 2022 年的旧定价表和 2025 年的新定价表都含"企业版",Embedding 看不出时间差异。必须用 metadata 硬性过滤或在 rerank 中注入时间特征。

子模式 5:查询歧义未解。"ES 版本升级"——用户指的是 Elasticsearch 还是公司内部代号为 ES 的产品?Query 层面没有消歧,检索就已经注定走偏。第 15 章 Query Rewrite 的目标之一就是把这类歧义在查询阶段就消化掉。

真实案例:Microsoft 在 Bing Chat 早期暴露过一个典型的"找错"事故——当时 Copilot 对"Intel 11 代 CPU"类查询频繁召回 2018 年的文章(11 代还没发布)并据此生成。根因是 Bing Web Index 的 recency 信号在 RAG 通路被弱化了。解决方案是给 rerank 模型显式注入 publish_date 特征并给最近 180 天文档加权。类似的时间偏差问题在 GitHub Copilot Chat 最早期也出现过(召回 Python 2 语法的 Stack Overflow 旧答案),后来通过过滤 creation_date < 2020 的答案压制。时间维度是检索领域的第一类系统性偏差,任何带历史演化的知识库都必须显式处理。

3.5 塞不下:Context Packing Failure

"塞不下"意思是:候选证据足够好,但在打包进 LLM 上下文时被裁剪、稀释或错误编排,导致生成阶段拿不到足够信号。

子模式 1:硬性截断。top-k 取 10,每段 800 token,加上 prompt 模板,超过模型上下文窗口。系统按原始顺序截断到第 6 段——后 4 段被丢弃。被丢弃的那几段可能恰好包含关键结论。

子模式 2:Lost in the Middle。Liu et al. 在 arXiv:2307.03172 的实证研究表明:LLM 对上下文首尾的信息更敏感,中间位置的信息更容易被忽略。在 GPT-3.5-turbo-16k 上,把 gold 文档放在 20 个文档的第 10 位,准确率比放在第 1 位低 20+ 个百分点。如果你把 rerank 的 top-10 按相关度降序塞进 prompt,最相关的在开头、第二相关的在末尾、中间全是"不错但不关键"的候选——整条 prompt 的信息利用率很低。

子模式 3:去重不充分。召回返回 10 段,其中 5 段来自同一份文档的相邻 chunk,内容高度重复。装进 prompt 后模型看到的实际上是"同一件事说 5 遍",独立证据只剩 5 段——有效上下文利用率腰斩。

子模式 4:格式污染。文档原始格式带来的噪声:表格结构丢失、代码块被破坏、多级列表变平、OCR 错误保留。模型面对满屏的奇怪字符更倾向于忽略这段上下文。

子模式 5:引用锚点丢失。打包时没有在每段 chunk 前面加稳定的标识(如 [doc-3, chunk-7]),生成阶段模型写出的引用是自由文本,无法机械验证。第 17 章讨论引用、溯源与答案可信度时会回到这点。

关于 Lost in the Middle 的工程对策:知道了这个现象后,有三种主流对策。

  • 对策一:Rerank 后按 U 形排列——相关度最高的放开头、第二相关的放结尾、其他按相关度从两端往中间塞。这利用 LLM 对首尾的高注意力、把中间"不敏感区"留给次要证据。
  • 对策二:显式 prompt 强调——在 context 后面加一句 "请特别关注上下文的中段部分——那里可能包含关键信息"。这是弱对策、模型不一定遵守,但几乎零成本。
  • 对策三:减少 context 长度——与其把 top-10 全塞进去让 Lost in the Middle 噬咬后五个,不如只塞 top-3 让模型能全量关注。Rerank 精度够高时这是最优解。

三种方案在 Contextual Retrieval 博客的附录中都有实测对比——Anthropic 选的是对策三+精准 rerank 的组合。第 16 章 Context Packing 会专门展开这种排列策略。

3.6 答不准:Generation Failure

"答不准"意思是:证据已经正确召回并打包进上下文,但生成阶段没有正确使用这些证据。

子模式 1:幻觉加权。模型有"知道"一部分答案的预训练记忆——它会用先验知识覆盖上下文里的证据。当上下文里的事实与训练期间记忆的事实冲突时,模型可能忽略新证据。对于时效性强的信息(价格、政策、版本),这种失败特别明显。

子模式 2:引用错位。模型在回答末尾给出 [1][3] 的引用标记,但 [1] 的内容其实来自 [4][3] 的内容被生成时重述得走样。如果系统只检查"有没有引用",不检查"引用是否 grounding 在被引用文档里",就会漏掉这类失败。

子模式 3:问题误读。用户问"私有化部署是否包含 SSO"——证据明确说"私有化部署支持 SSO 但需要企业版套餐"。模型回答"是,私有化部署包含 SSO"——丢掉了"需要企业版"的条件。这种失败在复合问题(多个条件/子问题)上特别频繁。

子模式 4:过度稳健。为了避免幻觉,模型被 prompt 或 RLHF 训练成"证据不足就拒绝回答"。结果是即使证据明明够,模型也倾向于答"根据现有资料无法确定"——用户体验受损。这是 faithfulness 和 helpfulness 的张力。

子模式 5:长答案稀释。模型写了 800 字回答,真正回答用户问题的只有第 3 段的 4 行。用户需要扫描才能找到关键信息——这是 UX 失败而非准确性失败,但在生产场景里被用户感知为"没说重点"。

这件事有专门的研究,但结论和直觉相反:Longpre 等人的Entity-Based Knowledge Conflicts in Question Answering 做的正是这个实验——把检索到的段落里的答案实体替换掉,再看模型是照着上下文答,还是照着自己记住的原答案答。他们把后者叫「记忆比率」(memorization ratio)。两个发现值得记住:一是模型经常无视被改过的上下文,仍然给出记忆里的那个答案;二是这个比率随参数量上升而升高——论文的原话是它在两个数量级的跨度内从「小于 15%」涨到「不小于 50%」。

换句话说,「换个更大的模型就更听上下文的话」这个假设是不成立的,规模上去了,先验记忆的话语权也跟着上去。所以 citation grounding 这类校验不是给小模型项目的补丁,而是任何依赖检索结果的系统都要有的东西;评测时也要专门造「上下文与模型先验冲突」的样本,否则这类失败在常规问答集上根本不会暴露。

关于 RAG 里"过度稳健"的工程权衡:在金融、法律、医疗场景中 "拒答总好过错答",过度稳健是可接受的偏向。在客服、内部知识问答等场景中错答风险可控、用户期望得到直接回复,过度稳健会让产品体验崩溃。Prompt 设计需要针对场景调整——这是第 22 章"从零构建生产级 RAG"会展开的应用层决策。

3.7 延迟失败:被忽视的第五类

严格说 RAG 有第五类失败家族——延迟失败。答对了、证据对、引用对,但花了 8 秒返回。在对话场景用户已经关闭窗口、在 Agent 场景已经超时被上层重试。延迟失败不会出现在 QA 准确率报表里,但会直接影响产品 DAU 和 Agent 成功率。

延迟失败的子模式:

  • 多路召回串行化:Embedding 和 BM25 顺序调用而不是并行,每路 100ms 变成 200ms。
  • Rerank 模型过大:cross-encoder 选了 7B 规模的 reranker(如 bge-reranker-v2-gemma),单次 rerank top-20 的延迟超过 1s。对延迟敏感场景应该选更小的 reranker(bge-reranker-v2-m3 约 250ms)或 LLM rerank 异步化。
  • 向量库 recall stage 慢:HNSW 索引没针对 top-k 调优,ef_search 过大,nprobe 过多,单次查询 >200ms。
  • LLM 生成流式没开:用户要等完整回答生成完再看到首字。开启 streaming 后首字延迟(TTFT)从 3s 降到 600ms,感知速度天差地别。

第 21 章会专门讨论延迟调优。本章之所以把延迟失败放进来,是因为生产监控里必须和答对率一起看——任何一个单看都能产生误导。

3.8 级联:失败如何互相放大

四大家族的真正威力在于级联

flowchart LR
    A[找不到 recall 70%] -->|漏 30%| B[rerank 只能从存在的候选里选]
    B -->|召回中 gold 排第 8| C[top-5 拿不到 gold]
    C -->|prompt 无 gold| D[模型被迫用先验]
    D -->|生成看似合理的错答| E[用户难以分辨]
    E -->|错答被接受| F[反馈闭环失效]
    F -.回流.-> A
    style A fill:#fee
    style D fill:#fef
    style F fill:#fdd

一个典型级联:

  1. Embedding 漂移让 gold chunk 不在 top-20(找不到,子模式 1)
  2. Rerank 从剩下的候选里挑,选了一个"企业版 SSO 价格表(2022 年版)"(找错,子模式 4)
  3. Prompt 只能基于这份过时文档生成答案
  4. 模型按上下文回答"企业版 SSO 包含在基础包中"——事实上 2025 年新版已改为"企业版可选附加包"(答不准,子模式 1)
  5. 回答文本流畅、有引用标记,用户接受
  6. 用户没有纠错,feedback 数据里记的是"回答被采纳" —— 整条链路看起来很健康

最致命的部分是第 6 步——失败不被标记。产品看板显示"用户满意度 88%",实际上 30% 的回答存在不同程度的事实错误。没有专门的 hallucination 检测(第 20 章)、没有 citation grounding 验证(第 17 章)、没有 gold-set 回归测试,这些失败永远不会被发现。

3.9 结构化归因:从"看日志"到"拆阶段比基线"

定位 RAG 失败不靠直觉,靠结构化归因。每次失败都用同一张表格归因:

阶段 本次输出 预期/基线 差异 可能根因
Query 改写 单跳查询 多跳拆解 漏拆子问题 Rewrite prompt 过弱
向量召回 top-20 gold 在第 14 位 前 5 位 排名偏低 Embedding 不适配领域
BM25 召回 top-20 gold 不在列表 应命中 关键词不重合 同义词/术语问题
Hybrid 融合 gold 在第 9 位 前 3 位 RRF 权重偏 Embedding 融合系数需调
Rerank top-5 gold 未进 top-5 进 top-3 Rerank 对长文档偏差 Rerank 截断文档
Context Pack top-5 无 gold 应含 gold 上游缺失 无需 pack 层改动
生成 幻觉 拒答或指向 gold 模型用先验 faithfulness 不足

按这张表拆开,每次失败都能定位到第一处出问题的阶段——这是调优的下手点。如果只看"用户 feedback=踩",你不知道该调 Embedding、Rerank 还是 prompt。

结构化归因的前提是:每个阶段的中间结果都被结构化记录。第 2 章 §2.11 讨论的"在线链路与离线链路的数据契约"就是在说这件事——日志不是一坨字符串,而是分阶段的结构化 trace。

一份最小可用的 RAG trace schema:

{
  "trace_id": "abc123",
  "query": "私有化部署是否包含 SSO?",
  "stages": {
    "query_rewrite": {
      "rewrites": ["私有化部署 SSO 企业版", "on-prem deployment SAML support"],
      "subqueries": ["私有化部署支持的身份认证方式", "SSO 功能所属套餐"],
      "latency_ms": 180
    },
    "retrieval_dense": {
      "top_k": 20,
      "hits": [
        {"doc_id": "faq-123", "chunk_id": 5, "score": 0.82, "published": "2025-03-01"},
        ...
      ],
      "latency_ms": 45
    },
    "retrieval_sparse": { "top_k": 20, "hits": [...], "latency_ms": 30 },
    "fusion": { "method": "RRF", "k": 60, "final_top_20": [...] },
    "rerank": {
      "model": "bge-reranker-v2-m3",
      "top_5": [...],
      "scores_before_after": [...],
      "latency_ms": 220
    },
    "context_pack": {
      "selected_chunks": 5,
      "total_tokens": 3200,
      "truncated": false,
      "evidence_density": 0.78
    },
    "generation": {
      "model": "claude-sonnet-4-5",
      "latency_ms": 1800,
      "citations": [{"marker": "[1]", "doc_id": "faq-123", "chunk_id": 5}],
      "faithfulness_score": 0.92
    }
  },
  "user_feedback": null
}

每个阶段记录输入、输出、关键参数、延迟、可观测指标。出问题时按 stages 顺序逐级查——第一个异常的 stage 就是根因点。这个 schema 用 OpenTelemetry 的 span 模型实现最自然——每个 stage 一个 span,stage 内部细节在 attributes 里。

归因不等于调参

工程上有个常见混淆:把"找到失败根因"等同于"立刻修"。归因的价值是决策顺序——你知道 70% 的 badcase 是 retrieval miss、20% 是 rerank 误排、10% 是生成幻觉。那优化顺序就很清晰:先投资 Embedding 和 hybrid search(第 9-13 章),再调 rerank(第 14 章),最后收拾 prompt 和 faithfulness 校验(第 17 章)。不要本末倒置去先花三周调 prompt——prompt 改动对 10% 的生成失败有效,对 70% 的召回失败无效。

归因工作流:从 bug 到指标

成熟 RAG 团队的失败处理工作流:

  1. 收集样本:用户 feedback "踩"的 case + 人工 gold set 定期回归 + 客服转人工的问题 + A/B 实验中对照组胜出的 case
  2. 分类打标:每个 case 按本章 4 家族 20+ 子模式标注——这一步可以部分自动化(第 20 章会讲自动归因模型)
  3. 统计分布:哪一类子模式占比最高?上周新增了哪一类?这决定优先级
  4. 针对性优化:针对最大子模式做 experiment——改 Embedding、调 rerank、改 prompt
  5. 监控回归:优化后观察该类子模式占比下降、确保没有新的子模式上升

这条工作流的关键在于步骤 2——对失败分类是一次性投资。初期可能觉得麻烦(20 种子模式要分辨),但一旦建立起来、每个 case 都能快速归位,后续每次优化的 ROI 都被显著放大。相比之下,"看 badcase 凭感觉调"的团队长期看优化速度只有前者的 1/3-1/5。

反模式:什么不是失败

归因时要警惕把不是 RAG 失败的问题归成 RAG 问题:

  • 用户问题超纲:知识库里就没有,系统回答"不知道"是正确行为,不是 retrieval miss
  • 多步推理需求:问题需要跨多份文档综合推理,单轮 RAG 本就不适合——应该走 Agent 多轮检索(第 18 章)
  • 时效性强的问题:问昨天的新闻,知识库是月度更新——这是产品定位问题,不是 RAG 链路问题
  • 合规拒答:涉及 PII、商业机密时模型拒答是特性而非 bug

把上述情况混在失败样本里会稀释归因信号、让优化方向跑偏。每个 RAG 系统都应该有明确的"能力边界"文档,边界外的问题独立统计、不进主流监控。

3.10 可观测性最小集:每类失败配一个指标

讨论失败模式的目的不是欣赏失败,是能在生产环境及时发现它们。每个失败家族都至少要有一个能自动计算的 leading indicator——异常时值会变、好时不报警。

找不到家族

  • recall@20 on gold set:每天用一组固定的 QA pair 跑全链路,看 top-20 是否包含 gold chunk。这组 gold set 需要人工维护、定期刷新。异常阈值:低于 moving average 5% 触发告警。
  • empty_retrieval_rate:top-k 返回 0 条或分数全部低于阈值的比例。这个指标高意味着大量问题被"温柔地拒答"——真正的答案可能就在库里。
  • recall_by_segment:按业务线/语言/用户角色分桶统计 recall。某个 segment 突降意味着该域出了问题,比整体平均更敏锐。

找错家族

  • MRR@10 on gold set:gold chunk 的倒数排名平均值,反映"相关的有没有排在前面"。
  • top-1_swap_rate:rerank 前后 top-1 的变化率。太低意味着 rerank 没作用、太高意味着 embedding 不靠谱。健康值在 30-60%。
  • stale_doc_rate:top-5 中 last_modified 早于 N 个月的比例。时效性场景的关键指标。

塞不下家族

  • context_utilization:prompt 里 chunk 的独立证据数 / 总 chunk 数。去重不足时该值低。
  • truncation_rate:因 token 预算被截断的请求比例。>5% 说明 prompt 设计需优化。
  • middle_evidence_rate:gold chunk 处于 prompt 中段的比例。配合 Lost in the Middle 监控。

答不准家族

  • citation_grounding_rate:模型输出的每条引用是否真能 grounding 到被引 chunk。用 LLM-as-a-judge 或子串匹配自动验证。第 20 章有详细方法。
  • faithfulness:生成答案的每个事实声明是否都能在 context 里找到依据。RAGAs、TruLens 等开源框架实现了这一指标(RAGAs repo)。
  • refusal_rate:模型回答"我不知道"的比例。过度稳健场景下该值会异常高。

这 12 个指标构成一套 RAG 系统的最小可观测性集——少于这个规模的系统是在"摸黑运行"。生产系统应该把它们做成 Grafana 看板,每个指标都有 SLO 和告警阈值。

flowchart TB
    classDef miss fill:#fee,stroke:#c00
    classDef err fill:#ffd,stroke:#c80
    classDef ctx fill:#def,stroke:#08c
    classDef gen fill:#fef,stroke:#80c
    subgraph miss["找不到"]
        M1["recall@20"]:::miss
        M2["empty_retrieval_rate"]:::miss
        M3["recall_by_segment"]:::miss
    end
    subgraph err["找错"]
        E1["MRR@10"]:::err
        E2["top-1 swap rate"]:::err
        E3["stale_doc_rate"]:::err
    end
    subgraph ctx["塞不下"]
        C1["context_utilization"]:::ctx
        C2["truncation_rate"]:::ctx
        C3["middle_evidence_rate"]:::ctx
    end
    subgraph gen["答不准"]
        G1["citation_grounding"]:::gen
        G2["faithfulness"]:::gen
        G3["refusal_rate"]:::gen
    end
    M1 & M2 & M3 -.不达标.-> ALERT[告警]
    E1 & E2 & E3 -.不达标.-> ALERT
    C1 & C2 & C3 -.不达标.-> ALERT
    G1 & G2 & G3 -.不达标.-> ALERT

3.11 五个真实事故:从失败模式到 postmortem

前 10 节把失败模式抽象化——四大家族、级联、归因方法。但真实生产事故很少是"纯粹某一类"——它们交织着上游依赖、运维疏忽、组织盲点。这节用五个贴近现实的生产事故、展示失败模式在真实情境下的样子、以及每个事故带走的教训。这些不是虚构故事——是 2024-2026 年各团队反复踩过的坑的综合。

事故 1:Embedding 版本静默漂移

时间线

  • T+0:OpenAI 微调 text-embedding-ada-002、没公告
  • T+3 天:Sentry 告警 "用户反馈答不对" 数量 +50%
  • T+5 天:工程团队查 recall@10、从 0.95 降到 0.78——以为是数据问题
  • T+7 天:比对向量、发现新 embedding 和旧索引空间不一致
  • T+10 天:全量重 embed(已有 200 万 chunk)、成本 $2000、恢复

根因:厂商静默升级、老向量和新 query 的空间不兼容

属于哪个家族:找错(§3.4)——召回偏移、但系统"看不出有问题"

教训

  • Embedding 版本要 pin、监控 API 响应的 model field
  • Recall 要每日自动对 gold set 跑、不等用户投诉
  • §9.13 embedding 迁移工程的完整 checklist

事故 2:权限 cache 没清导致离职员工数据泄漏

时间线

  • T+0:员工 A 提出离职、HR 发 offboarding 事件、IAM 禁用账号
  • T+15 分钟:员工 A 的老 query 被别的员工从浏览器历史链接打开、内容在 cache 里还能返回
  • T+1 天:员工 B 发现能看到员工 A 之前查过的薪资数据、报给合规

根因:Query 级响应 cache 的 key 只含 hash(query)、没含 user_id——跨用户命中

属于哪个家族:不属于四大家族、是合规事故——但触发原因和失败模式的"级联"相关

教训

  • Cache key 必含 user_id(§7.9 权限传播链)
  • 离职触发要传播到所有 cache 层(不只是 IAM)
  • Red team 测试要包含"跨用户 cache 污染"场景(§20.17)

事故 3:新模型幻觉爆发

时间线

  • T+0:Claude Sonnet 升级到新版、工程团队为 API 兼容性欣喜
  • T+1 天:用户满意度指标下降 10%、faithfulness 从 0.88 降到 0.72
  • T+2 天:抽检 badcase、发现新模型在证据不足时更"自信地编造"
  • T+3 天:回滚到旧版模型、恢复

根因:新模型训练目标变了、对 "I don't know" 的倾向降低——在 RAG 场景表现为幻觉

属于哪个家族:答不准(§3.6)——证据对、生成错

教训

  • 新模型升级前要 shadow 跑 gold set + faithfulness 检查
  • Prompt 里显式 "证据不足时拒答" 的约束(§17.7)
  • 模型升级要走 A/B、不要直接 100% 切换

事故 4:向量库内存 OOM 全挂

时间线

  • T+0:批量上传 100 万新文档、离线索引 pipeline 并行跑 8 个 worker
  • T+20 分钟:Qdrant 单实例内存爆(新 chunk + HNSW 索引 overhead 超预估)
  • T+25 分钟:OS 杀 Qdrant 进程、RAG 服务 5xx
  • T+30 分钟:restart + fallback 到 BM25 only、延迟高但可用
  • T+2 小时:分片 + 扩容 + 重建、完全恢复

根因:容量规划没考虑 HNSW 的 30% overhead、也没考虑批量 ingest 期间的内存峰值

属于哪个家族:延迟失败(§3.7)升级为可用性失败——系统层面故障

教训

  • 容量规划要按峰值(含 ingest + 查询 + overhead)
  • 关键服务要有 fallback(§2.12)、OOM 时不致全崩
  • 大批量 ingest 要限流、不能无脑并行

事故 5:LLM API 限流扩散成熔断

时间线

  • T+0:某大客户新上线推广、QPS 翻 5 倍
  • T+10 分钟:Anthropic rate limit 返回 429、重试队列积压
  • T+15 分钟:重试风暴放大压力、整个集群被打崩
  • T+20 分钟:应用层 circuit breaker 终于触发、切 fallback
  • T+45 分钟:申请扩额度、恢复主路径

根因

  1. 没有客户端 rate limiter、直接依赖厂商的 429
  2. 重试策略没用指数退避、风暴放大
  3. Circuit breaker 阈值设得太宽、触发太晚

属于哪个家族:延迟失败(§3.7)——长尾被限流放大

教训

  • 客户端自己限流(§21.13)、不等被 429
  • 重试用 exponential backoff + jitter
  • Circuit breaker 阈值要基于容量规划、不要拍脑袋
  • 大客户上线要有 runbook:预扩容、预通知厂商、灰度 ramp up

五个事故的共性教训

flowchart TB
    classDef lesson fill:#def,stroke:#06c
    T1[依赖静默升级]:::lesson
    T2[Cache 跨用户]:::lesson
    T3[模型升级]:::lesson
    T4[容量规划不足]:::lesson
    T5[级联放大]:::lesson
    T1 & T2 & T3 & T4 & T5 --> L[共性]
    L --> L1[上游变化要主动监控 不等出事]
    L --> L2[Fallback 是上线基础设施 不是以后补]
    L --> L3[容量规划按峰值 × 安全系数]
    L --> L4[事故是工程 + 组织综合]

共性 1:上游静默变化是最大杀手。Embedding、LLM、向量库每季度都可能升级——主动监控、不是事故了才查。

共性 2:Fallback 是救命稻草。事故 4 和 5 没有 fallback 就是全崩——有了也只是降级到 BM25 / cache、但用户还能用。

共性 3:事故是组织信号。权限 cache 泄漏反映"cache 设计没和合规对齐"、限流扩散反映"容量规划没跨团队协作"——纯技术修复治标不治本。

共性 4:Postmortem 要落地到 action item。每个事故后问三件事:同类事故怎么预防?哪些监控能早期发现?fallback 怎么加固?只写文档不改代码 = 下次还中。

Postmortem 文档的标准结构

生产事故必产出 postmortem(§22.15)、建议结构:

## 事故:[简要标题]

### Executive Summary
- 影响范围、持续时间、业务影响

### Timeline
- 分钟级事件记录(从探测到恢复)

### Root Cause
- 五问到底、不是 XXX 坏了 是为什么 XXX 坏了

### What Went Well
- 哪些机制起作用了(告警准、playbook 有用等)

### What Went Wrong
- 哪些机制失灵或慢了

### Action Items
- P0 立即修、P1 一月内、P2 季度
- 每项有 owner 和 deadline

### Detection
- 下次这类事故多快能发现?

这个模板是 Google SRE 的 postmortem 标准——被全行业引用不是没道理。

事故文化:对系统不对人

Postmortem 的一个关键文化规则:blame-free。不追究是谁写的 bug、不 label "人为错误"——追究系统为什么允许 bug 发生。原因:

  • 追责导致事故被隐藏、小事积累成大事故
  • 每个工程师都会写 bug、关键是系统是否容错
  • 长期文化:安全 > 个人责任

事故是团队学习的最好机会——错过学习等于白经历一次事故。

3.12 失败的在线探测与预测:从被动响应到主动预防

§3.11 的五个事故都是事后复盘——事情发生了才处理。但更好的状态是事前发现:在用户感知问题之前、系统自己察觉异常并止损。这节讲三类主动机制——canary query、异常检测、预测性信号——让 RAG 从"救火式运维"变成"预防性运营"。

被动 vs 主动响应

flowchart LR
    classDef bad fill:#fdd,stroke:#c00
    classDef ok fill:#dfd,stroke:#080
    P1[被动]:::bad
    P1 --> P11[用户报错]
    P11 --> P12[定位 + 修复]
    P12 --> P13[用户已受影响 小时-天]:::bad
    P2[主动]:::ok
    P2 --> P21[系统自检]
    P21 --> P22[异常告警]
    P22 --> P23[自动止损或人工干预]
    P23 --> P24[用户 0 感知 分钟级]:::ok

被动响应:MTTR 几小时到几天、用户信任受损。主动响应:MTTR 几分钟、多数用户感知不到。差距十倍级。

Canary query:持续健康检查

Canary query 是一组精心设计的"探针查询"——定期自动跑、看系统回应是否正常。类似数据库的 health check ping:

@scheduled(every="5min")
async def run_canary_queries():
    canary_set = [
        {"query": "企业版 SSO 价格", "expected_docs": ["doc-1"], "expected_facts": ["20000"]},
        {"query": "专业版是否支持 LDAP", "expected_docs": ["doc-2"], "expected_facts": ["不支持"]},
        # ...
    ]
    for canary in canary_set:
        result = await rag.query(canary["query"])
        
        # 检查检索命中
        if not set(canary["expected_docs"]) <= set(result.retrieved_ids):
            alert(f"Canary failed: {canary['query']} missed docs")
        
        # 检查答案包含关键事实
        for fact in canary["expected_facts"]:
            if fact not in result.answer:
                alert(f"Canary failed: {canary['query']} missing fact: {fact}")

Canary set 维护 20-100 条——覆盖核心业务场景、每 5 分钟跑一次、出问题立即告警。

Anomaly detection on trace

除了 canary、还可以对生产流量做异常检测:

  • retrieval_empty_rate:返回 0 条候选的比例、正常 < 0.5%、突增 > 2% 告警
  • avg_context_length:送 LLM 的 context 长度均值、突降说明 rerank 过严或 chunk 丢失
  • citation_failure_rate:答案标了引用但验证失败的比例
  • user_feedback_dislike_rate:thumbs-down 比例

这些指标不只看当前值、还要看变化率——短时间内的异常波动比绝对值重要。

预测性信号

有些信号在失败发生前就出现——预测性监控能提前警告:

早期信号 预测的后续失败 预警时间
Embedding 服务偶发高延迟 即将 overload、整体失败 几分钟
向量库 memory 使用率升 可能 OOM 崩溃 几十分钟
Cache 命中率持续降 prompt cache 被打破、成本即将涨 几小时
Gold set recall 小幅下降 可能 embedding drift、即将崩 几天
LLM 429 error 增加 Rate limit 即将扩散、雪崩 几分钟

把这些"慢变化"信号进告警——比等硬故障快得多。

主动止损

探测到异常、不只告警、还要自动止损

@on_anomaly("vector_db_memory_high")
async def auto_mitigate_memory():
    # 触发:向量库内存 > 85%
    actions = [
        # 1. 降 ingest 速率
        await ingest_queue.throttle(50),  # 降到 50%
        # 2. 通知 on-call
        await page_oncall("vector DB memory high"),
        # 3. 主动 GC / compaction
        await vector_db.trigger_compaction(),
    ]
    await execute_in_parallel(actions)

@on_anomaly("retrieval_empty_spike")
async def auto_mitigate_empty():
    # 触发:空召回率 > 2%
    # 切降级路径:BM25 only
    await config.set("use_dense", False, ttl=300)
    await alert_team("dense retrieval failing, switched to BM25 only")

自动化的止损让响应时间从分钟级到秒级——救了关键业务时段。

Chaos engineering:主动注入故障

Netflix 风格——主动破坏来验证系统韧性:

  • 定期 kill 随机 pod、看自动恢复
  • 模拟 LLM API 超时、看 fallback 是否工作
  • 注入 corrupted embedding、看是否被检测
# chaos-schedule.yaml
- name: kill-random-rerank-pod
  schedule: daily at 03:00  # 非业务时段
  action: kubectl delete pod rerank-$(random)
  
- name: simulate-llm-timeout
  schedule: weekly
  action: proxy-inject-delay anthropic-api 30s

Chaos engineering 是高阶实践——有前提:基础可观测性到位、降级路径齐备。没这些先做好、chaos 只会制造事故。

组织文化:鼓励提前报 issue

技术机制之外、文化决定主动性:

  • Bug bash:团队定期集体找 bug、找到的奖励
  • Pre-mortem:上线前先开 "假设这个 feature 挂了、为什么"、提前想到故障模式
  • Near-miss reporting:小事故 / 险情也要报、不等到大事故
  • 无责文化:报问题的不被责怪、只讨论如何避免

工程文化的成熟度决定主动机制能否执行——技术先进但文化躲避责任的团队、主动机制是空的。

探测和预测的覆盖

理想状态:

  • Canary query:覆盖 80% 核心业务场景
  • Anomaly detection:覆盖 20+ 核心指标
  • Predictive signals:5-10 个关键早期信号
  • Auto-mitigation:覆盖 3-5 个最常见故障

做到这些、团队能把事故率降 50-80%——从"每周都救火"到"每月一两次小问题"。

投入成本

建这套机制的前期投入:

  • Canary set 构造 + pipeline:2-3 人周
  • Anomaly detection 规则:每规则 1-2 人天、20 条就是 30-40 人天
  • Auto-mitigation:每个故障场景 1-2 人周
  • 合计:3-6 人月的前期投入

收益:

  • 事故率降 50%+
  • MTTR 降 5-10×
  • 用户信任度显著提升

典型回报周期:3-6 月——短期内看到价值。

反模式

  • 只被动响应:等用户报错、MTTR 长
  • 告警过多:几百条告警、团队麻木、真问题被淹没
  • 探测了不处理:检测到异常、只进日志、没人看
  • Auto-mitigation 太激进:过度自动化反而引入新问题
  • 没 runbook 配合:探测说 "something broken"、on-call 不知道怎么办

从 3.11 postmortem 到 3.12 proactive 的演进

§3.11 的五个事故复盘都是"事后"——这节的主动机制是"事前"。两者配合:

  • 每次事后复盘、问 "这个事故有没有预测信号?canary 能不能覆盖?"
  • Action item 里加 "建新 canary"、"加新 anomaly rule"
  • 持续把 reactive 学到的东西变成 proactive 能力

Postmortem 是填坑、canary 是建护栏——两者都是成熟系统的必备。

3.13 失败的 error budget:SRE 视角的量化管理

前面从技术角度讨论失败——有哪些类、怎么归因、怎么响应。SRE 有一套更量化的视角:把「可接受的失败率」变成一个数字,然后像管预算一样管它。

flowchart LR
    classDef budget fill:#fed,stroke:#c60
    classDef status fill:#dfd,stroke:#080
    S[SLO 目标 99.9% 可用]:::budget
    S --> B[Error budget: 0.1%<br/>即月度 43 分钟允许错]:::budget
    B --> U{当月已消耗}
    U -->|< 50%| G[绿:可发布、可实验]:::status
    U -->|50-90%| Y[黄:谨慎]:::status
    U -->|> 90%| R[红:冻结发布、修稳定性]:::status

逻辑很简单:SLO 是目标(比如 99.9% 成功率),error budget 就是 1 - SLO(0.1%)即允许的失败量,实际失败消耗这份预算。没用完可以冒险做新功能,用完了就冻结发布专修稳定性。

多维 SLO 与加权消耗

RAG 比普通 Web 服务复杂,可用性达标不代表答案质量达标,所以要分维度各管一份预算:

维度 SLO 示例 Error budget / 月
可用性 99.9% 43 分钟 down
P99 延迟 < 3s 1% requests 超
答对率 > 85% 15% 允许错
Faithfulness > 0.85 15% 允许低
合规(引用正确率) > 99% 1% 允许

消耗要实时跟踪,团队 dashboard 上直接显示「本月还剩多少」:

# 累计本月的失败
class ErrorBudgetTracker:
    def __init__(self, slo_target):
        self.slo = slo_target  # 0.999
        self.budget = 1 - slo_target  # 0.001

    def record_request(self, success):
        self.total_requests += 1
        if not success:
            self.failures += 1

    def budget_consumed_pct(self):
        actual_error_rate = self.failures / self.total_requests
        return min(actual_error_rate / self.budget, 1.0)  # 0 - 100%

    def is_healthy(self):
        return self.budget_consumed_pct() < 0.9

但不是所有失败都该等价计数。按严重度加权,预算才反映真实风险:

失败类型 单次「耗费」(错误次数) 原因
答错(答不准) 1 单次错
塞不下(截断) 0.5 部分对
找不到 1 完全错
延迟超时 根据用户体验、0.3-1 看是否降级用
错答 high-stakes(合规场景) 10-100 加权多倍

合规场景答错一次按几十上百次计——这条加权本身就是在告诉团队哪里不能省事。

预算见底时做什么

四档行动是这套机制的落点,让「该不该发」由数据回答而不是靠人拍板:

  • 消耗 < 50%(绿):正常节奏,可发新功能、可跑实验
  • 50–80%(黄):谨慎,新发布加一道 review,A/B 流量调低
  • 80–95%(红):冻结非必要发布,专注修稳定性
  • > 95%(严重):只允许修 bug,停掉一切新功能

配一张每天都会看的面板:

2026-04 月度 Error Budget 状态

可用性 (SLO 99.9%)
  消耗: [████████░░] 80%
  预警:接近红线

P99 延迟 (SLO < 3s)
  消耗: [████░░░░░░] 40%
  健康

Faithfulness (SLO > 0.85)
  消耗: [██████░░░░] 60%
  健康

综合判断: 🟡 谨慎发布

这也顺带解决了 roadmap 的优先级问题:预算充足就多做功能,预算紧张就多做稳定性——不靠拍脑袋,也不靠谁的功能听起来更性感。

SLO 怎么定:分层、谈判、演进

先分清三个容易混的概念:SLI 是具体指标(P99 延迟、错误率),SLO 是内部目标(P99 < 3s 在 99% 的时间成立),SLA 是对外承诺、违反要赔偿。对外的 SLA 通常比内部 SLO 宽一档,留出缓冲。

SLO 不该由工程单方面定,它是一场三方谈判:业务方希望越高越好,工程方知道每提一位数成本陡增,合规方则坚持某些错一次都不能有。典型的收敛过程是——业务想要 99.99%,工程算出来每年要 50 万美元;工程提 99.9%,每年 15 万;最后定成 99.9% 打底、付费用户的关键路径 99.99%。分层本身就是答案。

分层也避免了「所有东西都要 99.9%」的过度工程:核心路径(登录到主查询)严格,次要路径(帮助中心)宽松,内部运维工具干脆不设 SLO、best effort 即可。

SLO 还要随时间调整,每年 Q1 review 一次:历史三年实际做到 99.95% 就把目标提上去;定得太严导致团队长期疲于奔命就降下来;业务重要性上升了就收紧。

往上一层看,SLO 是工程目标,§22.17 的 KPI 是业务目标,两者要挂钩——SLO 达不成,业务 KPI 大概率跟着受影响,所以设计 SLO 时要参考业务 KPI,不能独立定。

落地条件与反模式

这套机制能不能真正生效,取决于组织有没有四样东西:决策基于数字而非直觉的习惯、预算见红时真的冻结发布的自律(而不是「这次特殊」)、业务与工程之间的互信、以及愿意为长期收益接受短期牺牲的视角。缺了这些,error budget 就只是墙上挂的一个数。

反模式对应着每一处走样:SLO 定得太松,永远满足、预算从不触发,变成装饰;定得太严,长期超预算、团队疲惫;预算红了照发不误,整套体系随之失信;不分严重度地把每件小事都记进预算,数字失真;以及三年前定的 SLO 至今没改,业务早已不是那个业务。

坚持一两年的团队会看到几个变化:发布节奏平稳而不是大起大落,稳定性和功能开发形成平衡,事故率持续下降,跨团队沟通因为有数据而少了互相推责。

3.14 跨专栏关联:失败模式是工程系统的通用语言

失败家族式思考不是 RAG 独有。《Tokio 源码深度解析》第 14 章讨论 tokio::select! 的公平性时,也是从"饥饿""忙等""竞争条件"三类失败模式切入,再讲机制。《vLLM 推理内核深度解析》第 8 章分析 KV Cache 失效时列出 "分块浪费、换页抖动、伪共享、锁粒度" 四种失败——每一种对应一类机制优化。好的工程书都把失败模式前置——因为读者要先理解"为什么难"才有耐心看"怎么解"。

这条认识论原则也可以用在读者自己的项目里:当你接手一个陌生系统(或写自己的新系统)时,先花一天时间枚举该系统的失败家族,再动手优化。比一头扎进代码改 bug 省时间。

3.15 本章小结

  1. RAG 的失败是系统性的,不是单点 bug。
  2. 四大失败家族:找不到、找错、塞不下、答不准——分别对应检索、排序、打包、生成四条链路。
  3. 每类家族下有 5 个典型子模式,合计 20+ 种常见失败姿态——生产 RAG 的监控指标应覆盖每一种。
  4. 失败会级联放大,且最危险的是失败被流畅文本掩盖、未被标记。
  5. 归因靠拆阶段、比基线、查证据流,而不是靠看原始日志。

下一章讨论生产级 RAG 架构——离线索引、在线检索与反馈闭环——看一套系统是如何从架构层面规避本章讨论的失败模式的。