RAG 工程与检索系统设计

第2章 从一次提问读懂 RAG 全链路

作者 杨艺韬 · 12,754 字 · 发布于 · 更新于

"A retrieval system is not a function call. It is a pipeline of decisions."

本章要点

  • 一次 RAG 请求可以拆成八个阶段:接收问题、构造查询、权限上下文、多路召回、重排序、证据组织、上下文打包、生成与校验
  • 每个阶段都要保留结构化中间结果,否则系统无法调试、评估和复盘
  • 召回链路追求覆盖,排序链路追求精度,上下文链路追求可用证据密度
  • RAG 的在线链路必须和离线索引链路通过稳定的数据契约连接
  • 生产系统的关键不是"最后回答了什么",而是"答案如何由证据一步步推导出来"

2.1 从一个问题开始

本章用一个具体问题贯穿整条链路:

"新版企业版套餐里,私有化部署是否包含 SSO?如果客户已经买了旧版专业版,升级时怎么收费?"

这不是一个适合直接丢给大模型的问题。它包含多个隐含条件:

  • "新版企业版套餐" 指的是哪个版本的定价文档。
  • "私有化部署" 可能在产品规格、交付手册、合同模板里都有描述。
  • "SSO" 可能写作 SAML、OIDC、单点登录、企业身份源。
  • "旧版专业版" 涉及历史套餐映射。
  • "升级时怎么收费" 可能出现在销售 FAQ、价格表、商务审批规则里。

如果你只做一次向量搜索,系统可能召回一份包含 "SSO" 的登录文档,却漏掉套餐价格表;也可能召回旧版专业版 FAQ,却没有看到新版企业版规格;还可能把"私有化部署支持 LDAP"误读成"包含 SSO"。

这个问题需要的不是一个搜索动作,而是一条证据流水线。

sequenceDiagram
    participant U as 用户
    participant A as 应用层
    participant Q as 查询理解
    participant P as 权限上下文
    participant R as 召回器
    participant S as 排序器
    participant C as 上下文构造器
    participant L as 大模型
    participant O as 观测与评估
    U->>A: 提问
    A->>Q: 原始问题 + 会话上下文
    Q->>P: 结构化查询
    P->>R: 查询 + 租户/角色/时间范围
    R->>S: 多路候选证据
    S->>C: 排序后的证据列表
    C->>L: Prompt + 证据包
    L->>A: 答案 + 引用
    A->>O: 全链路日志
    A-->>U: 可追溯答案

这张图里有两个容易被低估的角色。

第一个是 查询理解。用户的问题往往不是检索系统想要的形态。检索系统需要实体、同义词、时间范围、任务类型和候选子问题。直接用原问题做 embedding,等于把所有查询理解压力都交给向量模型。

第二个是 观测与评估。RAG 系统必须保存每一步的中间结果:改写后的查询是什么、召回了哪些候选、哪些被权限过滤、reranker 为什么把某段排第一、最终 prompt 里到底包含哪些证据。没有这条日志,线上答错时你只能猜。

2.2 阶段一:接收问题,不要急着检索

在线链路的入口通常收到的不只是用户输入的一句话,还包括会话、用户、租户、产品上下文和调用场景。

{
  "question": "新版企业版套餐里,私有化部署是否包含 SSO?如果客户已经买了旧版专业版,升级时怎么收费?",
  "user": {
    "id": "u_123",
    "tenant": "acme",
    "roles": ["sales", "enterprise"]
  },
  "conversation": {
    "session_id": "s_456",
    "previous_topic": "2026 pricing migration"
  },
  "runtime": {
    "locale": "zh-CN",
    "now": "2026-04-24"
  }
}

这些字段会影响后续决策:

  • roles 决定能不能看内部销售折扣规则。
  • tenant 决定能不能检索某个客户的合同条款。
  • previous_topic 帮助解析"新版"和"旧版"。
  • now 影响时间过滤,避免旧政策压过新政策。

一个常见错误,是把 RAG 入口设计成 retrieve(question: string)。这个接口太窄,导致后续系统只能从字符串里猜上下文。生产级接口至少应该接收:

字段 用途
原始问题 保留用户真实表达,用于回答和审计
会话摘要 处理追问、省略和指代
用户身份 权限过滤和个性化
租户/空间 多租户隔离
当前时间 时间敏感知识排序
任务类型 问答、总结、对比、排障、代码定位
期望输出 是否需要引用、表格、步骤、JSON

RAG 的第一步不是 embedding,而是把一次请求的上下文边界收清楚。

2.3 阶段二:查询理解,把一句话拆成可检索任务

用户的问题通常混合了多个子问题。本章的例子至少包含两个:

  1. 新版企业版套餐的私有化部署是否包含 SSO。
  2. 旧版专业版客户升级到新版企业版如何收费。

如果把这两个问题混成一个向量,召回结果会被平均化。更好的做法是把它们拆成结构化查询:

{
  "intent": "policy_lookup",
  "entities": {
    "new_plan": "企业版",
    "old_plan": "专业版",
    "deployment": "私有化部署",
    "feature": ["SSO", "SAML", "OIDC", "单点登录"],
    "topic": "升级收费"
  },
  "sub_queries": [
    {
      "id": "q1",
      "query": "新版 企业版 私有化部署 SSO SAML OIDC 单点登录 是否包含",
      "expected_doc_types": ["pricing", "product_spec", "enterprise_delivery"]
    },
    {
      "id": "q2",
      "query": "旧版 专业版 升级 新版 企业版 收费 迁移 价格 规则",
      "expected_doc_types": ["pricing", "sales_faq", "migration_policy"]
    }
  ],
  "time_preference": "latest",
  "answer_style": "with_citations"
}

这里的查询理解可以由规则、轻量模型或大模型完成。不要一开始就默认必须用大模型。很多场景里,规则足够稳定:

  • 如果用户输入包含错误码,保留原样并走关键词召回。
  • 如果输入包含代码符号,保留大小写和命名边界。
  • 如果问题包含"最新、现在、当前",添加时间优先信号。
  • 如果问题包含"对比、区别、差异",拆成多个对象分别检索。

大模型适合处理更复杂的语义改写,比如省略、指代、多轮追问和跨语言同义词。但大模型改写也要有约束:它可以扩展同义词,不能凭空添加实体;它可以拆子问题,不能改变用户意图。

查询理解阶段的输出应该被记录下来。很多 RAG 错误不是召回器的问题,而是查询改写把问题带偏了。比如把"旧版专业版"改写成"专业服务版",后续所有召回都会错。

2.4 阶段三:权限上下文,先设边界再找证据

权限是 RAG 和普通搜索最大的区别之一。

普通搜索可以把所有候选都找出来再展示;RAG 不能这样做。因为候选一旦进入大模型上下文,就已经被模型读取。即使最终答案不引用,也可能在生成中泄露。

权限过滤有三种常见位置:

flowchart LR
    A[查询] --> B[检索前过滤]
    B --> C[索引召回]
    C --> D[检索后过滤]
    D --> E[上下文打包]
    E --> F[生成后校验]
    B -.-> B1[按租户/空间/密级缩小候选集]
    D -.-> D1[按文档 ACL 精确过滤]
    F -.-> F1[检查引用和答案是否越权]

检索前过滤 的优点是安全边界清晰、候选规模小;缺点是过滤条件必须能被索引支持。如果你用一个共享向量索引,却没有把 tenant_idvisibilitydoc_type 做成可过滤字段,就只能在召回后过滤,容易出现 top-k 被过滤光的问题。

检索后过滤 的优点是灵活,可以调用权限服务做复杂判断;缺点是浪费召回名额。假设 top-20 里有 18 条用户无权访问,过滤后只剩 2 条,正确证据可能排在原始第 50 名,根本没有被取出。

生成后校验 只能作为最后防线,不能作为主要权限机制。它可以检查答案引用是否都属于可见文档,但不能保证模型没有吸收过无权上下文。

生产系统通常三者都要有:

阶段 负责内容
检索前 租户、知识库、空间、密级这类粗粒度过滤
检索后 文档 ACL、用户组、时间窗口这类精确过滤
生成后 引用完整性、答案是否包含无来源敏感内容

权限不是第 7 章才出现的问题。从在线链路第一个请求对象开始,权限上下文就必须存在。

2.5 阶段四:多路召回,不要让一种相似度决定一切

本章问题里同时有语义概念和精确词:

  • "私有化部署" 是语义概念,可能写成 on-prem、私有部署、专有化交付。
  • "SSO" 是缩写,可能写成 SAML、OIDC、单点登录。
  • "旧版专业版" 是产品版本名,精确匹配很重要。
  • "升级收费" 可能出现在价格表、迁移政策、销售 FAQ。

单一路线很难覆盖这些情况。向量召回擅长语义相似,但可能错过精确符号;BM25 擅长关键词匹配,但不理解同义表达;结构化过滤能保证文档类型和时间范围,但不能判断语义相关性。

更可靠的召回通常是多路的:

graph TD
    Q[结构化查询] --> V[向量召回]
    Q --> B[BM25/关键词召回]
    Q --> M[元数据召回]
    Q --> G[图谱/关系召回]
    V --> U[候选合并]
    B --> U
    M --> U
    G --> U
    U --> D[去重与归一化]
    D --> S[重排序]

候选合并不是简单拼列表。你需要处理:

  • 同一个 chunk 被多路召回,如何合并分数。
  • 同一文档多个相邻 chunk 命中,是否提升整段权重。
  • BM25 分数和向量相似度量纲不同,如何归一化。
  • 某一路召回为空,是否触发降级策略。
  • 候选数量过多,如何控制 rerank 成本。

一个实用做法是先保留每路召回的独立分数,再在后续排序阶段统一处理:

{
  "chunk_id": "c_789",
  "doc_id": "pricing_2026_enterprise",
  "text": "企业版私有化部署包含 SAML/OIDC 单点登录...",
  "scores": {
    "dense": 0.83,
    "bm25": 12.4,
    "metadata_boost": 1.2
  },
  "matched_queries": ["q1"],
  "metadata": {
    "doc_type": "pricing",
    "version": "2026.04",
    "effective_date": "2026-04-01"
  }
}

不要过早把这些分数压成一个数字。调试时你需要知道某个候选为什么出现:是语义相似、关键词命中、时间加权,还是图谱关系带出来的。

2.6 阶段五:重排序,把候选变成证据

召回阶段的目标是"别漏",重排序阶段的目标是"别乱"。

多路召回后,候选列表可能有几十到几百个 chunk。它们大多和问题有一点关系,但不是都能作为答案证据。重排序要判断的是:这个 chunk 对当前问题是否有直接回答价值。

对本章问题,候选可以分成四类:

候选类型 例子 排序倾向
直接证据 "企业版私有化部署包含 SAML/OIDC SSO" 应排前
条件证据 "旧版专业版升级按剩余合同金额折抵" 应排前
背景材料 "SSO 配置步骤" 可保留但不优先
噪声材料 "登录页支持企业 Logo" 应排后

重排序可以用 cross-encoder reranker、LLM 打分、规则加权,或组合方案。无论用哪种,都要注意一个边界:reranker 评估的是"query 与 passage 的相关性",不等于"passage 的事实优先级"。

比如旧版文档和新版文档都高度相关,但新版应该优先。这需要 metadata 参与排序:

flowchart TD
    A[候选 chunk] --> B[语义相关性]
    A --> C[时间有效性]
    A --> D[文档权威性]
    A --> E[权限与租户匹配]
    A --> F[子问题覆盖]
    B --> G[综合排序]
    C --> G
    D --> G
    E --> G
    F --> G

一个成熟排序器通常不是单一模型,而是一组信号:

  • 语义相关性:候选是否回答当前子问题。
  • 关键词覆盖:关键实体是否出现。
  • 文档权威性:正式协议高于评论、草稿、历史讨论。
  • 时间有效性:最新版本高于旧版本,但历史问题例外。
  • 结构位置:标题、表格、FAQ 答案位置通常比正文旁支更重要。
  • 子问题覆盖:一个候选覆盖 q1,另一个覆盖 q2,不能只取 q1 的前几条。

排序输出也应该保留解释字段。哪怕只是内部日志,也能帮助排查:

{
  "chunk_id": "c_789",
  "rank": 1,
  "rerank_score": 0.91,
  "reasons": ["direct_answer", "latest_version", "official_pricing_doc"],
  "covers": ["q1"]
}

这类结构化信息后续还会用于上下文打包:覆盖不同子问题的证据都要进入 prompt,而不是只按全局分数截断。

2.7 阶段六:证据组织,chunk 不是最终单位

RAG 系统离线索引时通常以 chunk 为单位,但在线回答时不应该机械地以 chunk 为单位消费证据。

原因很简单:chunk 是索引单位,不一定是理解单位。

一个价格表可能被切成多个 chunk:

  • chunk A:企业版功能列表。
  • chunk B:私有化部署说明。
  • chunk C:SSO 说明。
  • chunk D:升级计费规则。

如果检索返回 B、C、D,直接按三个片段拼 prompt,模型可能不知道它们都属于同一份 2026 价格政策,也可能看不到表格列头。证据组织阶段要把 chunk 还原成更适合阅读的结构。

常见操作包括:

  • 相邻合并:同一文档连续 chunk 命中时合并,保留标题层级。
  • 窗口扩展:命中 chunk 前后各取一段,补齐上下文。
  • 表格还原:把单元格与表头、行头一起提供。
  • 子问题分组:把 q1 证据和 q2 证据分开,避免模型混淆。
  • 冲突标注:旧版和新版材料同时出现时标明版本和生效时间。

组织后的证据包可以长这样:

{
  "evidence_groups": [
    {
      "sub_query": "q1",
      "question": "企业版私有化部署是否包含 SSO",
      "evidence": [
        {
          "source": "2026 企业版价格与功能表",
          "doc_id": "pricing_2026_enterprise",
          "version": "2026.04",
          "quote": "企业版私有化部署包含 SAML/OIDC 单点登录...",
          "location": "功能矩阵 / 身份认证"
        }
      ]
    },
    {
      "sub_query": "q2",
      "question": "旧版专业版升级如何收费",
      "evidence": [
        {
          "source": "旧套餐迁移 FAQ",
          "doc_id": "migration_faq_2026",
          "version": "2026.04",
          "quote": "旧版专业版客户升级企业版时,按剩余合同金额折抵...",
          "location": "FAQ / 计费规则"
        }
      ]
    }
  ]
}

这一步把"候选文本"变成"可用证据"。没有它,模型拿到的只是碎片。

2.8 阶段七:上下文打包,在 token 预算内保留证据密度

上下文打包是 RAG 系统最容易被低估的环节。

很多实现直接把 top-k chunk 用分隔符拼起来:

参考资料:
1. ...
2. ...
3. ...

请回答用户问题。

这对简单问答可以工作,但复杂问题会出问题。一个好的上下文包至少要包含:

  • 原始问题和改写后的子问题。
  • 每组证据对应哪个子问题。
  • 每段证据的来源、版本、时间和位置。
  • 冲突材料的优先级说明。
  • 明确的回答约束:只基于证据,不足则说明不足。

更结构化的 prompt 片段可能是:

用户问题:
新版企业版套餐里,私有化部署是否包含 SSO?如果客户已经买了旧版专业版,升级时怎么收费?

请分别回答两个子问题,并为每个结论标注引用编号。

证据 A(回答子问题 q1)
来源:2026 企业版价格与功能表,版本 2026.04,位置:功能矩阵 / 身份认证
内容:企业版私有化部署包含 SAML/OIDC 单点登录;LDAP 同步作为可选交付项。

证据 B(回答子问题 q2)
来源:旧套餐迁移 FAQ,版本 2026.04,位置:FAQ / 计费规则
内容:旧版专业版客户升级企业版时,按剩余合同金额折抵;新增模块按企业版当前报价补差。

约束:
- 不要使用证据外的价格或折扣信息。
- 如果结论依赖销售审批规则,请说明材料不足。

上下文打包的关键指标不是 token 数越少越好,而是 单位 token 的证据密度。低价值材料会挤占高价值材料,让模型在长上下文里迷路。

第 16 章会专门讨论上下文打包策略,包括最大边际相关性、证据分组、摘要压缩、引用保真和长上下文模型下的排序衰减。本章只需要建立一个原则:检索结果不能原样塞给模型,必须被组织成模型可执行的证据包。

2.9 阶段八:生成、引用与校验

到了生成阶段,大模型的任务不是自由发挥,而是基于证据完成受约束的综合。

对本章问题,一个理想答案应该类似:

新版企业版的私有化部署包含 SSO,具体支持 SAML/OIDC 单点登录;LDAP 同步是可选交付项,不应混同为默认包含。旧版专业版客户升级企业版时,通常按剩余合同金额折抵,再对新增模块按企业版当前报价补差。现有材料没有给出特殊折扣或审批例外,如果涉及大客户折扣,需要查询销售审批规则。

这个答案有三个特点:

  1. 分别回答了两个子问题。
  2. 把默认包含和可选交付区分开。
  3. 对证据不足的部分没有编造。

生成后还需要校验。最基本的校验包括:

  • 答案中的关键结论是否都有引用。
  • 引用是否来自本次上下文中的证据。
  • 引用文档是否对用户可见。
  • 答案是否引入证据外的价格、日期、人名、承诺。
  • 多个证据冲突时是否按版本、权威性处理。

校验可以由规则、模型或人工介入完成。高风险系统里,校验失败不应该把答案直接返回给用户,而应该降级:

flowchart TD
    A[模型生成答案] --> B{引用完整?}
    B -->|否| C[要求模型补引用或缩短答案]
    B -->|是| D{是否包含证据外关键事实?}
    D -->|是| E[删除无证据结论或标记材料不足]
    D -->|否| F{权限检查通过?}
    F -->|否| G[拦截并记录安全事件]
    F -->|是| H[返回用户]

引用不是为了好看,而是为了让答案进入可审计状态。没有引用的 RAG,很难和普通聊天区分开。

2.10 日志与反馈:让每次回答变成训练样本

RAG 请求结束后,系统应该保存完整链路日志。最少包括:

类别 字段
请求 原始问题、用户、租户、时间、会话 ID
查询理解 子问题、改写查询、实体、任务类型
召回 每路召回结果、分数、耗时
权限 过滤前后数量、过滤原因
排序 rerank 分数、最终排名、覆盖的子问题
上下文 进入 prompt 的证据 ID、token 数、截断原因
生成 模型、参数、输出、引用
校验 是否通过、失败原因、降级策略
反馈 用户点赞/点踩、追问、人工标注

这些日志有三个用途。

第一,调试单次错误。用户投诉某个答案错了,你可以回放链路,判断是找不到、找错、塞不下还是答不准。

第二,构建评估集。被点踩的问题、人工修正的问题、频繁追问的问题,都是高价值评估样本。RAG 的评估集不应该凭空编,而应该来自真实流量。

第三,驱动知识治理。如果很多问题都找不到答案,可能不是检索差,而是知识库没有对应文档;如果某份旧文档经常被召回导致错误,就应该下线或标记过期;如果某类问题总是需要人工补充,说明文档结构需要改。

这也是 RAG 与传统 FAQ 的区别:RAG 系统不是写完就完,它应该在使用中暴露知识库的缺口。

2.11 在线链路与离线链路的数据契约

本章一直在讲在线请求,但在线链路能做什么,取决于离线索引时保存了什么。

如果离线阶段只存了 textembedding,在线阶段就无法按版本排序、无法显示页码、无法过滤权限、无法合并相邻 chunk、无法判断文档类型。很多 RAG 系统后期难以演进,根因是最初的索引 schema 太贫瘠。

一个更合理的 chunk schema 至少包含:

{
  "chunk_id": "c_789",
  "doc_id": "pricing_2026_enterprise",
  "text": "企业版私有化部署包含 SAML/OIDC 单点登录...",
  "embedding": [0.012, -0.084],
  "metadata": {
    "tenant_id": "global",
    "space_id": "sales_enablement",
    "doc_type": "pricing",
    "title": "2026 企业版价格与功能表",
    "section_path": ["功能矩阵", "身份认证"],
    "version": "2026.04",
    "effective_date": "2026-04-01",
    "source_url": "https://...",
    "acl": ["role:sales", "role:enterprise"],
    "prev_chunk_id": "c_788",
    "next_chunk_id": "c_790"
  }
}

这里每个字段都服务在线链路:

  • doc_type 用于文档权威性排序。
  • section_path 用于上下文组织和引用展示。
  • versioneffective_date 用于新旧政策判断。
  • acl 用于权限过滤。
  • prev_chunk_idnext_chunk_id 用于窗口扩展和相邻合并。
  • source_url 用于答案引用。

离线和在线之间的契约一旦设计好,后续系统才有迭代空间。否则每加一个功能都要重建索引,甚至重新解析全部文档。

2.12 各阶段的降级与 fallback:链路必须有退路

前 11 节描述了 RAG 的正常流水线。真实生产里、每一个阶段都可能在任何一刻失败——Embedding 服务超时、向量库连接池耗尽、rerank GPU OOM、LLM 返回 429。一个没有 fallback 的 RAG 链路等于脆弱的串联系统:一环断、整条崩。成熟的 RAG 设计里、每个阶段都有降级路径、确保用户拿到可用的答案而非 5xx 错误。

降级的基本原则

flowchart LR
    classDef ok fill:#dfd,stroke:#080
    classDef degrade fill:#fed,stroke:#c60
    classDef fail fill:#fdd,stroke:#c00
    Q[用户请求] --> P1[查询理解]:::ok
    P1 --> P2[权限过滤]:::ok
    P2 --> P3[多路召回]:::ok
    P3 --> P4[Rerank]:::ok
    P4 --> P5[上下文打包]:::ok
    P5 --> P6[LLM 生成]:::ok
    P6 --> OK[正常答案]:::ok
    P1 -.失败.-> D1[原 query 直通]:::degrade
    P3 -.失败.-> D3[只走可用路]:::degrade
    P4 -.失败.-> D4[RRF 分数直接排]:::degrade
    P5 -.OOM.-> D5[截断低分 chunk]:::degrade
    P6 -.超时.-> D6[小模型 或 缓存]:::degrade
    D1 & D3 & D4 & D5 & D6 --> DEGRADE[降级答案 + warning]:::degrade
    D6 -.全挂.-> FB[fallback 静态回复]:::fail

三条原则:

  • 部分可用优于全部不可用:能返回"不完美但有用"的答案、就不要给 500
  • 降级要可观测:每次降级记一次、不要静默
  • 降级不能悄悄跨越安全边界:权限过滤绝不能因为"降级"被跳过

各阶段的具体降级

查询理解失败:LLM 改写超时、或解析出错。降级:直接用原 query 进入下游。代价是 recall 可能略降、但不阻塞链路。

权限过滤失败:权限服务不可达。绝不降级——直接 5xx 给用户"暂时无法服务"。权限是安全底线、不能因故障放水。

多路召回失败

  • Dense 召回超时 → 只用 BM25 的结果
  • BM25 失败 → 只用 dense
  • 两路都失败 → 看是否能用 metadata filter 精确召回兜底、否则走 query cache
  • 全部失败 → fallback 到静态"抱歉暂时无法回答"

Rerank 失败:用融合后的 RRF 分数直接排序、跳过 cross-encoder。recall 还在(召回没变)、只是精度降一档、远比"不答"强。

上下文打包 OOM:context 总长度超限。按 rerank 分数降序、截到 token 预算内。最关键的证据留着、末尾弱候选丢掉。

LLM 生成失败

  • 主模型超时 → 重试一次(短 timeout)
  • 仍失败 → 切 fallback 模型(Sonnet → Haiku)
  • Fallback 也失败 → 返回"基于检索到的证据(不生成自然语言)"——把 top-3 chunk 原文给用户看
  • 最坏 → 静态回复"系统繁忙、稍后再试"、带 request_id 供事后查

熔断器(circuit breaker)模式

单次失败能重试、持续失败要熔断——防止雪崩:

  • 某服务 1 分钟内错误率 > 50% → 熔断 30 秒、新请求直接走降级
  • 30 秒后半开状态、放一小部分流量探活
  • 探活成功率 > 80% → 关闭熔断、恢复正常
  • 仍失败 → 继续熔断

关键服务(LLM、向量库、Embedding)都要有熔断器。没有熔断、一个慢服务会拖垮整个链路的并发池。

用户可见 vs 内部降级

不是所有降级都要告诉用户:

  • 透明降级:LLM fallback 到小模型、答案质量略降——不明示,静默执行(但内部有 log)
  • 轻提示:权限过滤后候选太少、只能基于 2 条证据回答——答案加 "本次基于有限资料"
  • 明示降级:全链路都挂、走静态回复——明确告诉用户 "系统繁忙"

用户看到太多"降级"标记反而怀疑系统整体稳定性。内部观测一定要全、外部提示要有选择

Fallback 链的编排

复杂降级需要有序执行。一个典型 LLM 生成的 fallback 链:

async def generate_with_fallback(prompt, context):
    for attempt in [
        (sonnet, timeout=3),           # 首选
        (sonnet, timeout=8),           # 重试更长 timeout
        (haiku, timeout=5),            # 降模型
        (cached_similar_answer, None), # 查 cache 里类似答案
        (static_fallback, None),       # 最后静态回复
    ]:
        try:
            result = await try_generate(attempt, prompt, context)
            if result:
                return result
        except Exception as e:
            log_fallback(attempt, e)
    raise SystemDegraded("all fallbacks exhausted")

每一步都 log、便于事后分析哪级 fallback 被触发最多。

降级率的观测

生产 RAG 看板必备:

  • fallback_rate_by_stage:每个阶段的降级触发率、理想 < 1%
  • full_pipeline_success_rate:完整正常流程的比例、理想 > 95%
  • user_visible_degradation:用户看到降级标记的比例
  • fallback_answer_quality:降级路径的答案质量(可以用 mini gold set 跑)

任一指标持续超标——说明上游服务稳定性不够、或降级策略本身有问题。降级不是"平时不看、出事再说"——是常态运营的一部分。

常见反模式

  • 没有 fallback:一旦失败就 500、用户体验差
  • 降级逻辑藏在 try-except:没 log、没指标、事后查不到为什么
  • 权限也降级:故障时放水——合规事故
  • fallback 链太长:5 级 fallback 每级 3 秒、累计 15 秒才给用户兜底——不如早放弃
  • cache 当万能 fallback:命中率低、冷启动场景没数据

降级设计是 RAG 可用性的工程基础——每加一个新阶段就问"这个阶段挂了怎么办"。答案不能是"阻塞等它恢复"。

2.13 并行化与流水线优化:挤出每一毫秒

前面 12 节把 RAG lifecycle 按八个阶段串讲、看起来是一条顺序流水线。事实上多数阶段有并行机会——默认串行跑就是浪费延迟。生产 RAG 的 P50 延迟 2 秒以内基本靠并行化、不靠单点优化。这节梳理每个阶段的并行化空间、以及识别关键路径的方法。

默认串行 vs 并行后的 lifecycle

flowchart TB
    classDef seq fill:#fdd,stroke:#c00
    classDef par fill:#dfd,stroke:#080
    subgraph SEQ[默认串行 合计 2500ms]
        direction LR
        S1[QU 300]:::seq --> S2[权限 50]:::seq --> S3[召回 200]:::seq --> S4[Rerank 400]:::seq --> S5[Pack 50]:::seq --> S6[LLM 1500]:::seq
    end
    subgraph PAR[并行优化 TTFT 1000-1500ms]
        direction LR
        P1[QU 权限 并行 300]:::par --> P2[三路召回 并行 80]:::par --> P3[Rerank 400]:::par --> P4[LLM 流式]:::par
    end

两者对用户的感受差一倍——不是单点优化能做到的、是整条链路的并发设计。

各阶段的并行机会

查询理解 + 权限上下文:两者都只依赖原始请求、互不依赖——可以并行。Python 里用 asyncio.gather

qu_task = rewrite_query(raw_query, conv_context)
perm_task = resolve_user_context(user_id, tenant_id)
qu_result, perm_result = await asyncio.gather(qu_task, perm_task)

300ms + 50ms 串行 = 350ms、并行 = 300ms(用较慢的那个)。

多路召回:dense / BM25 / metadata 三路互不依赖、必须并行。串行会拖到每路延迟相加、并行取最慢那路:

dense_task = dense_retrieve(query)
bm25_task = bm25_retrieve(query)
meta_task = metadata_filter(query)
dense, bm25, meta = await asyncio.gather(dense_task, bm25_task, meta_task)

召回从 150-200ms 压到 80-100ms。

Rerank + Context pack:rerank 必须在召回完成后、但可以边 rerank 边初步组织证据——当 rerank 打完分、pack 只等最后几毫秒就能完成。这种 pipeline 对长 rerank 特别值。

LLM 流式生成:不等完整答案——第一个 token 出来就开始流、用户感受的是 TTFT、不是总延迟。1500ms 总生成 + 500ms TTFT、用户感觉"快"。

关键路径识别

并行化的核心是找关键路径(critical path)——决定整体延迟的那条链。方法:

  1. 给每个阶段的 P50/P99 延迟打 trace 字段
  2. 画 dependency graph
  3. 用 longest path 算法找关键路径
  4. 优化关键路径的节点、其他非关键节点可以牺牲一点

典型发现:LLM 生成 + rerank 占总延迟 70-80%——优化这两个最值、其他非关键路径的优化 ROI 低。

推测执行(speculative execution)

进阶并行——某些后续阶段提前启动、失败就废

  • 提前暖 LLM:query 刚进来、context 还没齐、先把 system prompt 送到 LLM 建立 cache、完整 prompt 来时续上——省 100-200ms
  • 提前召回:rewrite 还没完、先用原 query 跑一次召回、rewrite 完再补——命中 cache 时白赚
  • 并行多模型:同一 prompt 同时发给两个 LLM、取快的——成本翻倍但 p99 降半

推测执行浪费少量资源换延迟——对延迟敏感场景(对话 TTFT < 500ms)值得。

流式的依赖解耦

LLM 流式生成时、后续阶段也能流式启动

  • Grounding 验证:每 token 出来就验证、不等完整答案
  • Citation 解析:看到 [doc- 就解析、不等完整
  • UI 渲染:流式传给前端、边出边显示

这种"pipeline parallelism"让TTFT 成为用户感知的延迟、而不是总延迟。生产 RAG 几乎都要实现流式。

依赖的显式管理

并行化靠显式声明依赖——用 Python asyncio / Rust async 的 task graph、或专门的 workflow 引擎(Prefect / Temporal):

graph = Graph()
graph.add_node("qu", rewrite_query, deps=["raw_query"])
graph.add_node("perm", resolve_perm, deps=["user_ctx"])
graph.add_node("dense", dense_retrieve, deps=["qu", "perm"])
graph.add_node("bm25", bm25_retrieve, deps=["qu", "perm"])
graph.add_node("rerank", rerank, deps=["dense", "bm25"])
graph.add_node("generate", llm_gen, deps=["rerank"])

result = await graph.execute()

这种显式 graph 让并行机会一目了然、新同学改代码也不会误改成串行。

并行化的反模式

  • 盲目 await:每个 await 都串行、没用 gather
  • 隐式依赖错误:声明了并行但代码里悄悄依赖了、出奇怪 bug
  • 并行但资源不够:一股脑并行 10 个 LLM 调用、rate limit 爆炸
  • 忘了 cancel:某并行任务失败、其他任务不取消、浪费算力

并行化是谨慎工程——只在明确独立的阶段并行、加 timeout 和 cancel、永远测延迟分布而非均值。

量化改进的衡量

优化前后对比要看:

  • P50 / P99 TTFT:主要指标
  • P99 端到端:长尾
  • CPU / GPU 峰值使用率:并行多的时候可能资源瓶颈
  • LLM token 使用量:推测执行可能多费 token

没有对比衡量、优化可能只是把延迟从 A 阶段挪到 B 阶段、总量不变。

2.14 多轮对话的 lifecycle 变形

前面 13 节讲的都是单轮 RAG——一次请求一个完整答案。真实生产里大量 RAG 嵌在多轮对话里:客服聊天、代码助手、Agent 任务。多轮不是"单轮重复 N 次"——它引入了跨请求状态指代解析上下文累积等新问题。单轮 lifecycle 的某些阶段需要调整、还要加新阶段。这节把多轮的 lifecycle 变形讲清楚。

单轮和多轮的结构差异

flowchart TB
    classDef single fill:#dfd,stroke:#080
    classDef multi fill:#fed,stroke:#c60
    subgraph SINGLE[单轮 lifecycle]
        direction LR
        Q1[query] --> QU1[QU]:::single --> R1[检索]:::single --> G1[生成]:::single
    end
    subgraph MULTI[多轮 lifecycle]
        direction LR
        Q2[query] --> SESS[加载 session]:::multi --> DIS[指代解析]:::multi --> QU2[QU]:::multi --> R2[检索]:::multi --> G2[生成]:::multi --> UPD[更新 session]:::multi
    end

多轮版增加了四个步骤:加载 session / 指代解析 / 上下文合并 / 更新 session。每个都对检索质量有结构性影响。

Session state 的管理

Session 是多轮的核心——保存跨轮信息:

{
    "session_id": "sess-abc",
    "user_id": "u-123",
    "created_at": "2026-04-25T10:00",
    "turns": [
        {"role": "user", "content": "企业版 SSO 怎么配置", "ts": "10:00"},
        {"role": "assistant", "content": "配置步骤是...", "refs": ["doc-1"], "ts": "10:01"},
        {"role": "user", "content": "那价格呢", "ts": "10:02"},  # 当前轮
    ],
    "extracted_entities": ["企业版", "SSO"],
    "active_topic": "企业版 SSO",
    "summary_so_far": "用户在了解企业版 SSO 的配置和定价"
}

存储选择:

  • Redis:快、适合短会话(< 1 小时)
  • Postgres:持久化、适合跨天 session
  • Vector DB:如果要对历史对话做语义检索

关键:session 数据和用户数据必须隔离(session 是临时、per-request;用户 profile 是持久)。

指代解析的位置

"那价格呢" 里的 "那" 指什么?必须在 QU 之前解析清楚:

def resolve_coreference(current_query, session):
    if not has_reference_words(current_query):  # 没"那""它""上面说的"
        return current_query

    # 用 LLM 或规则解析
    prompt = f"""
对话历史:{session.turns[-5:]}
当前用户问题:{current_query}
改写成独立问题、不依赖历史。
    """
    resolved = llm_rewrite(prompt)
    return resolved  # "企业版 SSO 的价格"

位置放在 QU 之前——QU 才能拿到独立 query 做扩展 / 拆解。放错位置(比如放在检索后)、QU 就已经基于歧义 query 跑偏了。

上下文累积的 compacting

对话越长、session 里累积越多——但不是所有都要喂 LLM。三层 compacting:

  • 最近 N 轮原文(通常 3-5 轮):完整保留、保证近期细节
  • 中期摘要(N 到 2N 轮):LLM 压缩成 200 token 摘要
  • 早期标签(更老的):只保留 "讨论过 SSO""涉及企业版" 这类标签
flowchart LR
    classDef raw fill:#fed,stroke:#c60
    classDef sum fill:#def,stroke:#06c
    classDef tag fill:#efe,stroke:#080
    H[第 1-10 轮]:::tag --> H1[标签: 讨论企业版 SSO]
    M[第 11-15 轮]:::sum --> M1[摘要: 200 字]
    L[第 16-20 轮]:::raw --> L1[完整原文]
    H1 & M1 & L1 --> CTX[当前 context]
    CTX --> LLM[送 LLM]

这让 session 可以跑 100+ 轮仍然不爆 context——近事完整、远事只保关键脉络。

多轮的权限特殊性

权限在多轮更复杂——session 里的旧内容可能在权限变更后成"违禁"

  • 第 1 轮用户问"企业版 SSO"、拿到答案 A(时当时有权)
  • 用户权限降级(不再有 SSO 访问权)
  • 第 3 轮用户问"刚才说的那个怎么配"、session 里还有答案 A

怎么办?

  • 严格模式:权限变更后、session 全清。简单但用户体验差
  • 回溯过滤:session 每次加载重新过权限、无权内容标记为"[已屏蔽]"
  • 透明模式:告诉用户"之前的答案因权限变更不再适用"

金融 / 医疗场景选严格模式、一般场景选回溯过滤。

多轮的缓存差异

单轮 cache key 常是 hash(query)——多轮不够:

  • 同 query "那价格呢" 在两个不同 session 下含义完全不同
  • Cache key 必须含 session_idresolved_query

正确做法:cache 最终解析后的 query + 证据、而不是原始输入。

多轮的 fallback 特殊性

单轮 fallback(§2.12)处理当前请求失败。多轮里还要处理:

  • 历史污染:早期某轮给了错答案、后续 session 里引用这个错答案
  • 级联错误:第 5 轮错、第 6 轮基于第 5 轮的信息继续错、第 7 轮更错

多轮 fallback 策略:

  • 错误标记:某轮确认错了、session 里标注、后续不再引用
  • session 截断:严重错误时清空 session、重新开始
  • Turn-level rollback:让用户撤销最近几轮、从中间点继续

多轮评估的联动

评估单轮看 recall / faithfulness——多轮加看:

  • Session coherence:整个会话逻辑连贯吗
  • Fact retention:早期说过的事实、后期正确引用吗
  • Reference resolution accuracy:指代解析对吗

§20.16 多轮评估专门讲——这里强调:多轮 lifecycle 的每个变形阶段、都对应一个新的失败模式和评估维度

长 session 的架构挑战

Session 长到 50+ 轮时、架构压力增加:

  • Context token 累积:即使 compacting、长 session 的 context 仍比短 session 长 3-5 倍
  • Session 状态大小:几 MB 级
  • Memory 的联动(ch18):长 session 应沉淀到 long-term memory

架构上:session 是短期(小时-天)、memory 是长期(月-年)。两层衔接的规则要清楚——哪些 session 信息值得升级到 memory。

Agent 场景的 lifecycle

多轮是"用户和系统交替"——Agent 场景是"LLM 内部自主多步":

单轮 Agent: user 问 → LLM 决定调 RAG → 答

多步 Agent: user 问
  → LLM 决定第 1 次 RAG (query A)
  → 看结果不够
  → LLM 决定第 2 次 RAG (query B)  
  → 看结果够了
  → 综合答

每次 RAG 都是一次完整 lifecycle——和多轮对话的 session 叠加、复杂度再升。§18 memory 和 ch19 Agentic RAG 展开这部分。

多轮 lifecycle 的实现复杂度

相比单轮、多轮实现增加:

  • Session 存储 + 并发控制(同 session 并发请求要排队)
  • 指代解析服务
  • Context compacting 的后台 job
  • 多轮 eval 的 gold set + 评估框架

典型工程成本:从单轮升到多轮、整个 RAG 系统代码量 +40-60%、运维复杂度翻倍。不是小升级——确认业务真需要多轮再上

单轮优先、多轮按需

一个经常被忽视的真相:很多场景不需要多轮——

  • API 形式的 RAG(开发者调用):每次独立 query
  • 搜索框式产品:用户重新输入
  • 短 FAQ 场景:一问一答就够

强制上多轮反而:延迟升、成本升、出 bug。先做单轮、看业务是否真需要再上多轮——避免过度工程。

2.15 端到端 trace 的设计:让每次请求都可观测

前面多次提到 trace,把它当作 debug、归因、审计的前提。这一节讲它具体长什么样。一份好的 trace 同时服务六种用途,不需要为每个用途单独记一套:

flowchart TB
    classDef use fill:#def,stroke:#06c
    T[Trace]
    T --> T1[Debug 单次 badcase]:::use
    T --> T2[归因(§3.9)]:::use
    T --> T3[性能优化(找瓶颈)]:::use
    T --> T4[审计合规(§22.14)]:::use
    T --> T5[A/B 分析]:::use
    T --> T6[异常检测(§3.12)]:::use

trace 里该有什么

一条完整的 RAG 请求 trace:

{
  "trace_id": "tr-abc123",
  "request_id": "req-xyz",
  "user_id": "u-456",
  "tenant_id": "acme",
  "timestamp": "2026-04-25T10:00:00Z",
  "total_duration_ms": 1850,

  "stages": {
    "query_understanding": {
      "duration_ms": 280,
      "input": "企业版 SSO 怎么配",
      "output": {"rewritten": "...", "entities": [...]},
      "model": "haiku",
      "cost_usd": 0.0002
    },
    "retrieval": {
      "duration_ms": 95,
      "paths": {
        "dense": {"candidates": 50, "top_scores": [0.89, 0.84, ...]},
        "bm25": {"candidates": 50, "top_scores": [...]}
      },
      "fusion": "RRF",
      "final_top_k_ids": ["doc-1-c7", "doc-3-c2", ...]
    },
    "rerank": {
      "duration_ms": 320,
      "model": "bge-reranker-v2-m3",
      "top_5_after_rerank": [{"id": "doc-1-c7", "score": 0.94}, ...]
    },
    "generation": {
      "duration_ms": 1150,
      "model": "sonnet-4.6",
      "prompt_tokens": 3200,
      "completion_tokens": 520,
      "cost_usd": 0.0176,
      "prompt_cache_hit": true
    }
  },

  "response": {
    "answer": "企业版 SSO 的配置步骤...",
    "citations": ["doc-1-c7", "doc-3-c2"],
    "confidence": 0.87
  },

  "user_feedback": null  // 稍后异步填充
}

关键是每阶段都记齐输入输出、延迟、成本、模型版本——少任何一项,debug 时都要靠猜。

一个请求会穿过 gateway、query understanding、retrieval、rerank、LLM 多个服务,trace_id 必须全链路透传:

# Gateway 生成
trace_id = generate_trace_id()
request.headers["X-Trace-Id"] = trace_id

# 每个下游服务取出、带入自己的日志
@app.post("/retrieve")
async def retrieve(query, request):
    trace_id = request.headers["X-Trace-Id"]
    logger.bind(trace_id=trace_id).info("starting retrieval")
    # ...
    # 调下游时继续传
    async with httpx.AsyncClient() as client:
        await client.post("/rerank", headers={"X-Trace-Id": trace_id})

这件事 OpenTelemetry / Jaeger 已经标准化了,不要自己发明。每个阶段落成一个 span:

with tracer.start_span("query_understanding") as span:
    span.set_attribute("model", "haiku")
    span.set_attribute("input.query", query[:200])  # 截断防太长
    result = rewrite_query(query)
    span.set_attribute("output.rewritten", result.rewritten[:200])
    span.set_attribute("output.entities_count", len(result.entities))

记的时候有三条原则:不落敏感原文(query 和答案可能含 PII,截断或脱敏)、多记数值少记全文(token 数、分数、耗时)、把模型版本 / prompt 模板版本 / feature flag 状态都打成 tag。schema 本身也要带 schema_version 字段,RAG 迭代时格式会变,分析工具按版本兼容老 trace,不要静默改结构。

存储、采样与敏感数据

trace 数据量大,存储要分层:

存储 优点 缺点
Jaeger / Tempo 专业 tracing 系统、查询快 存储时效短(通常 7-30 天)
对象存储(S3)+ 索引 长期便宜 查询慢
ClickHouse 既能分析又能查单次 运维重
混合:热数据 Jaeger + 冷数据 S3 平衡 复杂

生产上推荐混合:近 7 天进 Jaeger 供快速 debug,长期归档进 S3 满足审计合规。

100% 采样成本太高,按价值分档即可:正常请求采 1–10%,出错的请求 100% 记,慢于 P95 的请求 100% 记(长尾问题全靠它),用户反馈差的请求也要 100%——注意反馈是异步回来的,那时要能反查到旧 trace。

敏感数据有硬约束:原始 query 全文、答案全文、用户个人信息都不能明文存。做法是截断(留前 100–200 字)、用 Presidio 一类工具 mask PII、需要去重或做 cache key 时存不可逆的 hash,合规审计要保留全文的走单独加密存储。

顺带区分一下 trace 和应用日志——两者互补,不是一回事:

维度 应用日志 Trace
粒度 每行日志 每请求完整
结构 自由文本 结构化 JSON
跨服务 每服务独立 全链路串联
查询 grep / ELK trace ID 查
保留 几天 月-年

从单次 debug 到聚合分析

有了 trace,debug 就能流程化:

def debug_request(trace_id):
    trace = trace_store.get(trace_id)

    # 1. 总览
    print(f"Total duration: {trace.total_duration_ms}ms")
    print(f"User satisfied: {trace.user_feedback}")

    # 2. 每阶段
    for stage_name, stage in trace.stages.items():
        print(f"[{stage_name}] {stage.duration_ms}ms")
        if stage.duration_ms > thresholds[stage_name]:
            print(f"  ⚠️ slower than expected")

    # 3. 输入输出
    print(f"Original query: {trace.stages.query_understanding.input}")
    print(f"Rewritten: {trace.stages.query_understanding.output.rewritten}")
    print(f"Retrieved IDs: {trace.stages.retrieval.final_top_k_ids}")
    print(f"Answer: {trace.response.answer[:200]}")

单次排查从「翻几小时日志」变成「看几分钟 trace」。而聚合起来,同一份数据还能统计各阶段平均延迟找瓶颈、在某阶段延迟或失败率异常时自动告警(§3.12)、对比两个 cohort 的指标差异做 A/B、把 cost_usd 字段按租户汇总直接出账单(§21.18)。

它的价值还不止工程内部。产品问「为什么这条 query 答错了」,给一个 trace_id 就能答;销售问某客户的使用模式,聚合 trace 能分析;合规问能不能重现三月的某次请求,trace 里找得到。没有 trace 时每个这类问题都是「要一周排查」,有 trace 时是「五分钟答复」。

落地节奏、成本与反模式

不用一开始就上完整方案,四步渐进,每步都有独立价值:MVP 阶段只记 request_id 加几个关键字段的普通日志;然后改成 JSON 结构化日志;接着用 OpenTelemetry 建 span、接 Jaeger;最后补齐完整 schema 和合规审计能力。

成本上,OpenTelemetry 集成 1–2 人周,trace 存储 1 人周,采样策略 3–5 人天,debug UI 1–2 人周,合规审计能力 1–2 人周,合计 1–2 人月起步。按事故排查和优化分析的节省算,两三次事件就回本。

五个反模式对应着上面每件事没做到位的样子:完全没有 trace(每次 debug 从头翻)、有 trace 但 ID 不透传(各服务日志关联不上)、把全文都塞进去(结构丢失、查询变慢)、不做采样(存储爆炸)、敏感数据明文(直接的合规事故)。

最后强调这层基础设施的位置:§3.12 的异常检测、§20.18 的 badcase 来源、§21.18 的成本归因、§22.14 的合规审计、§22.15 的事故响应,全部以 trace 为数据源。没有 trace,这些都做不成——所以它的优先级排在其他可观测性工作之前。

2.16 Lifecycle 的 A/B 实验工程:每一阶段都能对照迭代

前面讲了 lifecycle 的各个阶段,但每次改动怎么验证效果?offline eval 不够,A/B 才是真相。RAG 的 lifecycle 有 8 个阶段、每个阶段都可能 A/B,如果没有统一的实验平台,每次从头搭,工程成本高而且结果不可信。

flowchart TB
    classDef good fill:#dfd,stroke:#080
    classDef bad fill:#fdd,stroke:#c00
    W[没 A/B 的团队]:::bad
    W --> W1[拍脑袋改]:::bad
    W --> W2[不知效果]:::bad
    W --> W3[不敢上线]:::bad
    H[有 A/B 的团队]:::good
    H --> H1[数据驱动]:::good
    H --> H2[量化进步]:::good
    H --> H3[快速迭代]:::good

每个阶段可 A/B 的东西不同:

阶段 可 A/B 的东西
Query understanding rewrite prompt / 模型 / 策略
权限 过滤时机 / 下推方式
多路召回 增减路 / 每路 top_k
Rerank 模型版本 / top_k / 微调版本
证据组织 合并 / 扩展 / 去重 策略
Context packing 顺序 / 压缩 / 格式
Generation LLM 版本 / prompt / temperature
Citation 格式 / 粒度

成熟团队会同时跑 5–10 个实验,每个阶段都有在跑的。

实验设计:变量、样本量与时长

四条设计原则。单变量:一次只改一个变量,同时改两个出了问题不知道是谁导致的。样本量足够:由要检测的 effect size 决定,小改动需要大样本,大改动小样本就够。时长至少 1–2 周以排除 novelty effect,关键改动跑满 1 个月。分层随机化:按 user_id hash 分组,保证同一个用户始终看到同一实验组。

实验时长不是一刀切,按改动大小分四档:prompt 微调、top_k 变动 1 这类小改动,1 周、1 万样本;换 rerank 模型这类中改动,2 周、5 万样本;重构架构,1 个月以上、10 万样本起;换 LLM 厂商这种超大改动,需要几个月和大量监控。

指标:三层结构与主次关系

每次 A/B 至少要看三层里的几个指标,而不是盯着一个数字:L1 工程指标(P99 延迟、错误率、单请求成本)、L2 质量指标(faithfulness、answer relevance、有 gold set 时的 recall@k)、L3 业务指标(CTR、采纳率、满意度评分、复用率)。

这些指标在一次实验里的地位不同——primary 决定胜负,guardrail 是不许降的底线,diagnostics 帮助理解发生了什么:

experiment: new_rerank_v2
primary: answer_relevance
guardrails:
  - latency_p99 < 3s
  - error_rate < 1%
diagnostics:
  - recall@10
  - citation_grounding_rate
  - user_feedback

判定规则很简单:胜负看 primary,任一 guardrail 被突破就直接判负。

平台、统计与常见陷阱

实验平台要有五件东西:按 user_id 稳定分组的分桶、控制流量走向的 feature flag、能把每次请求关联到实验组的 metric 采集、算 effect size 和置信区间的统计引擎、以及实时看状态的 dashboard。选型上,小项目用开源轻量的 Growthbook,大项目上 LaunchDarkly(商用成熟)或 Statsig(新兴、快),简单场景自建也可以。

统计部分工程师不用精通,但要知道几个概念:样本量由 effect size、置信水平和 power 共同决定;p-value 判断结果是否显著(常用 p < 0.05);置信区间给出效果的范围;同时看多个 metric 时要做 Bonferroni 一类的多重比较修正。核心只有一条——别不看数据就下结论,计算交给工具。

想快速从多个候选里选优,可以用 multi-armed bandit 自动把流量导向表现好的版本:

# 简化版 Thompson sampling
for each request:
    version = sample_from_posterior(arms_performance)
    result = serve(version)
    update_posterior(version, result)

它适合「5 个不同 prompt 要快速决出一个」这类场景,但比 A/B 复杂、统计解读也更麻烦,谨慎用。

实验不是「跑完看数字」那么简单,四类问题要会认:sample ratio mismatch(SRM)——说好 50/50 实际流量偏了,多半是分桶有 bug;novelty effect——新版本头几天 CTR 高,之后回落;primacy effect——新老用户对新版本的感受不同;external shock——实验期间有营销活动或新闻事件干扰。

流程、成本与 RAG 特有的难处

成熟团队的实验流程是有 owner 和产出的六步,不是「改代码跑一下就完事」:

flowchart LR
    classDef step fill:#def,stroke:#06c
    S1[提议<br/>假设 + 指标]:::step --> S2[设计<br/>分组 + 样本量]:::step
    S2 --> S3[实施<br/>feature flag]:::step
    S3 --> S4[监控<br/>实时看]:::step
    S4 --> S5[结论<br/>统计分析]:::step
    S5 --> S6[决策<br/>上线 / 回滚 / 再试]:::step

配套的习惯是:任何改动默认走 A/B(除非明显不必要)、每个实验的假设与设计与结论都存档、失败的实验同样有价值(知道什么不行)、跨团队分享经验。没有这套习惯,A/B 就只是个别人的事,大多数改动仍然靠拍脑袋。

成本上,平台搭建 1–2 人周起步,每个实验的设计、实施和分析 1–3 人天,metric 采集与存储是长期开销。但对照另一边——上线一个变差的改动、影响 100% 用户——A/B 是省钱的方式。节奏上,每周启动 2–5 个新实验、每月结掉 10 个以上、每季度做几个大的;与其纠结把每个实验设计得完美,不如在保证单变量和样本量的前提下多跑几个。

A/B 也有明确的边界:生态和长期效应单次实验看不到,需要长期的 observational 或 longitudinal 分析;用户量少的 high-stakes 场景样本不够、很难做到统计显著;还有副作用问题,几个实验单独看都好、组合起来反而变差。

相比普通 Web A/B,RAG 还多几重难处:指标是多维的(质量、延迟、成本要同时看,比单纯 CTR 复杂);用户行为信号慢(好答案要几秒后才出,反馈滞后);「什么是好答案」本身就难量化;变量之间会传染(改 chunking 会影响 rerank,进而影响 generation)。所以 RAG 团队需要比普通 A/B 更耐心、也更严格

往上看,A/B 是实现 ch22 §22.17 那套 KPI 的手段——KPI 是目标(比如满意度 +5%),A/B 负责验证每次改动是否朝着这个目标走,两者合起来才是数据驱动的 roadmap。团队从「拍脑袋」转到「假设 + A/B」通常要 6 个月到 2 年,中间会有人觉得慢,但长期看质量提升快得多。

2.17 本章小结

一次 RAG 请求可以被看作一条证据流水线:

  1. 接收问题时收齐用户、租户、时间和会话上下文。
  2. 查询理解把自然语言问题拆成可检索的结构化任务。
  3. 权限上下文先定义候选边界,避免无权材料进入模型。
  4. 多路召回用不同机制提高覆盖率。
  5. 重排序把候选变成真正能回答问题的证据。
  6. 证据组织把 chunk 还原成文档结构和子问题结构。
  7. 上下文打包在 token 预算内最大化证据密度。
  8. 生成与校验保证答案基于证据、可引用、可审计。
  9. 日志与反馈把每次回答变成系统改进的材料。

下一章我们反过来看:当这条链路某个环节出错时,RAG 会以哪些方式失败。