RAG 工程与检索系统设计

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

作者 杨艺韬 · 13,030 字

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

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

本章要点

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

2.1 从一个问题开始

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

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

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

如果你只做一次向量搜索,系统可能召回一份包含 "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"
  }
}

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

一个常见错误,是把 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 阶段四:多路召回,不要让一种相似度决定一切

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

单一路线很难覆盖这些情况。向量召回擅长语义相似,但可能错过精确符号;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_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

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

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

{
  "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:

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

常见操作包括:

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

{
  "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"
  }
}

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

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

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

三条原则:

各阶段的具体降级

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

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

多路召回失败

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

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

LLM 生成失败

熔断器(circuit breaker)模式

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

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

用户可见 vs 内部降级

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

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

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 看板必备:

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

常见反模式

降级设计是 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)

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

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

流式的依赖解耦

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

这种"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 让并行机会一目了然、新同学改代码也不会误改成串行。

并行化的反模式

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

量化改进的衡量

优化前后对比要看:

没有对比衡量、优化可能只是把延迟从 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 的配置和定价"
}

存储选择:

关键: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:

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 里的旧内容可能在权限变更后成"违禁"

怎么办?

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

多轮的缓存差异

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

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

多轮的 fallback 特殊性

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

多轮 fallback 策略:

多轮评估的联动

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

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

长 session 的架构挑战

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

架构上: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 的实现复杂度

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

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

单轮优先、多轮按需

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

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

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

前面章节多次提到 "trace"——作为 debug / 归因 / audit 的前提。但具体 trace 长什么样、怎么设计、存哪里——需要专门讲清楚。一个完整的 trace 系统让 RAG 从"盲盒"变成"玻璃盒"——每次请求的每个步骤都可追溯。这节给出 RAG trace 设计的实用指南。

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 同时满足这六种用途——不是为每个用途单独做记录。

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 复杂。

Trace ID 的传递

一个请求经过多个服务(gateway → QU → 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 的设计

每个阶段产生一个 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))

注意:

Trace 的存储

Trace 数据量大——需要专门存储:

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

生产推荐混合——近 7 天 Jaeger(快速 debug)+ 长期 S3(审计合规)。

采样策略

100% 采样成本高——生产常用采样

让 trace 存储可承担、同时关键 case 不丢。

从 trace 到 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]}")

单次 debug 从"翻日志几小时"变成"看 trace 几分钟"——效率天差地别。

Trace 的可观测性用法

除单次 debug、trace 是可观测性的数据源

一份 trace、多种用途——这是 investment 的价值。

敏感数据的处理

Trace 里不能存:

正确做法:

和应用日志的区别

Trace 和应用日志不是一回事:

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

两者互补——trace 做请求粒度分析、log 做细节排查。

Trace 的版本化

随着 RAG 迭代、trace schema 会变:

{
  "schema_version": "v3",  // trace 格式版本
  "stages": {...}
}

查询 / 分析工具按版本处理——兼容老 trace。变更时加版本号、不静默改。

实施成本

完整 trace 系统的工程投入:

总计 1-2 人月起步。但后续每次事故 debug、每次优化分析都受益——回本 2-3 次事件。

Trace 的组织意义

好 trace 不只是技术工具——是组织协作的基础

没 trace 时、每个问题都是"要一周 debug"——有 trace 时、"5 分钟答复"。这个差距决定团队响应速度。

从 0 到 1 的建议

小项目起步时、不用一开始就完美的 trace——渐进:

每个阶段有价值——不要等"完美方案"再开始。

反模式

和其他章节的配合

Trace 是 RAG 所有可观测性的共同数据源:

没有 trace、这些都做不成——所以 trace 是优先级最高的基础设施。

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

前面讲了 lifecycle 的各阶段——但每次改动怎么验证效果?RAG 的 lifecycle 有 8 个阶段、每阶段都可能 A/B——如果没有统一的实验平台、每次都从头搭、工程成本高且结果不可信。这节讲 RAG lifecycle 的 A/B 工程化——让实验成为团队的日常能力。

为什么 A/B 是 RAG 的核心能力

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 是 RAG 唯一可靠的改进验证方式——offline eval 不够、A/B 才是真相。

A/B 的各阶段维度

RAG lifecycle 的每个阶段都能 A/B:

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

一个成熟 RAG 团队同时跑 5-10 个实验——每个阶段都有实验。

实验设计原则

单变量原则:一次实验只改一个变量。两个变量同时改,出问题不知道哪个导致的。

样本量足够:看检测目标 effect size——小改动需要大样本、大改动小样本够。

实验时间:至少 1-2 周(排除 novelty effect)、关键改动 1 个月。

分层随机化:按 user_id hash 分组、保证同 user 看同一实验。

按改动大小分级

实验时长不是一刀切——匹配改动。

指标选择

RAG A/B 的关键指标分层:

L1 工程指标

L2 质量指标

L3 业务指标

每次 A/B 至少看三层的几个指标——不是只看一个。

指标的主次关系

典型配置:

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

胜负看 primary、任一 guardrail 突破就判负。

实验平台的基本要求

工具必备:

开源选择:

建议:小项目用 Growthbook、大项目上 LaunchDarkly / Statsig

实验的统计学

关键概念:

工程师不用精通统计——但要知道别不看数据就下结论。工具能帮做计算。

多变量实验 (MAB)

N 个版本同时跑、自动把流量导向好的——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 复杂、统计解读麻烦——谨慎用。

实验中的常见问题

每种问题有对应处理——实验不是"跑完看数字"那么简单。

实验的组织流程

成熟团队的流程:

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

每一步有 owner 和产出——不是"改代码跑就完事"。

实验的文化

让 A/B 成为团队习惯:

没这个文化——A/B 是个别人的事、大多数改动仍拍脑袋。

A/B 的 cost

A/B 不是免费:

但 A/B 的错误决策成本——上线一个变差的改动、影响 100% 用户——远大于实验成本。A/B 是省钱的方式

快速实验的哲学

好的 RAG 团队的 A/B 节奏:

数量 > 质量——跑多了自然有突破。纠结每个实验设计完美——不如多跑几个。

实验的局限

A/B 不是万能:

长期指标 / 罕见事件的 A/B 特别难——要用其他方法(longitudinal analysis)。

RAG 特有的 A/B 挑战

相对普通 Web A/B、RAG 有特殊:

RAG 团队要比普通 A/B 更耐心 + 更严格

从 A/B 到持续实验平台

进阶方向——Continuous experimentation

这种平台化的 RAG 开发 = 每个改动都是数据验证的——质量持续提升。

A/B 和 ch22 §22.17 KPI 的关系

ch22 §22.17 讲 KPI——A/B 是实现 KPI 改善的工具:

A/B 文化的转变

团队从"拍脑袋 + 盲改"转到"假设 + A/B"需要时间:

这个转变 6 月到 2 年——坚持很重要。中间可能有人觉得慢——但长期看、质量提升快得多。

2.17 本章小结

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

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

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