RAG 工程与检索系统设计
第4章 生产级 RAG 架构:离线索引、在线检索与反馈闭环
"An architecture is a set of decisions that survive every reorganization." — Michael Nygard, Release It!
本章要点
- 生产 RAG 由三条链路组成:离线索引(写)、在线检索(读)、反馈闭环(学)
- 三条链路时序解耦:离线以批/流形式预计算,在线响应单次请求,反馈以小时/天为周期回流
- 三条链路通过数据契约耦合:向量索引、chunk store、metadata schema、日志 schema 是四份必须稳定的契约
- 架构演进顺序:先跑通在线链路 → 做好离线一致性 → 最后打通反馈闭环
- 每条链路的 SLA 不同——离线讲吞吐和一致性、在线讲延迟和可用性、反馈讲准确性和时效性
4.1 为什么是三条链路
初学者画 RAG 架构图通常长这样:用户 → 检索器 → 大模型 → 用户。这张图完全忽略了知识是怎么进系统的、系统是怎么从错误里学习的。生产 RAG 的复杂度 80% 不在"检索 → 生成"这条显式链路上,而在两条隐式链路上:
- 离线索引:把外部知识(Wiki、合同、代码、工单)持续转成可检索结构
- 反馈闭环:把线上用户行为(点击、采纳、负反馈、显式评分)持续转化为离线优化的输入
三条链路合起来是一个闭环系统——在线依赖离线的输出、离线消费反馈的信号、反馈来自在线的行为。任何一条链路故障,另两条会在数天内退化。这也是为什么 Google/Bing/Amazon 的搜索架构都是三条链路并重而不是只谈检索——RAG 继承了搜索的这套架构 DNA。
flowchart LR
classDef offline fill:#def,stroke:#06c
classDef online fill:#fed,stroke:#c60
classDef loop fill:#efe,stroke:#080
subgraph off["离线索引链路"]
S1[数据源]:::offline
S2[解析/分块]:::offline
S3[Embedding]:::offline
S4[索引构建]:::offline
S5[索引发布]:::offline
S1 --> S2 --> S3 --> S4 --> S5
end
subgraph on["在线检索链路"]
O1[用户请求]:::online
O2[查询理解]:::online
O3[多路召回]:::online
O4[Rerank]:::online
O5[Context Pack]:::online
O6[生成]:::online
O1 --> O2 --> O3 --> O4 --> O5 --> O6
end
subgraph fb["反馈闭环"]
F1[用户行为日志]:::loop
F2[结构化 trace]:::loop
F3[标注/回归集]:::loop
F4[模型重训/参数调优]:::loop
F1 --> F2 --> F3 --> F4
end
S5 -.索引.-> O3
O6 --> F1
F4 -.更新.-> S3
F4 -.更新.-> O4
这张图是本章的指南针。后续每一节都在给这张图填细节——哪一块是哪条链路、接口如何定义、故障如何隔离。
4.2 离线索引链路:批、流、增量
离线索引的目标:把外部知识转成稳定的、可检索的结构。"稳定"意味着下游在线链路可以对结构做强假设;"可检索"意味着结构支持向量检索、关键词检索和元数据过滤。
三种触发方式
- 批(batch):每天/每周跑一次 Spark / Airflow job 把全量知识重建。适合月度变化的知识库(法规、产品规格)、小规模数据集(百万 chunk 以内)。优点是简单、强一致;缺点是时延大、资源浪费高。
- 流(streaming):Kafka → 消费者 → 索引。文档变更事件实时推进索引。适合高频变化、强时效性(新闻、工单、代码仓库)。优点是分钟级延迟;缺点是运维复杂、需要处理乱序/重复。
- 增量(incremental):批作为主链路 + 小批量增量补丁。适合绝大多数场景。比纯批更新及时、比纯流运维简单。第 8 章专门讨论增量索引。
选型决策树:知识库变化频率 > 每天 → 流或增量;< 每天 → 批;对外 SLA 要求分钟级新鲜度 → 流;可接受小时级 → 增量;可接受天级 → 批。
关键阶段的解耦
离线链路的五个阶段必须物理解耦——每阶段输出中间产物、可独立重跑:
- 解析层:原始文档 → 结构化文本 + metadata。产物:
parsed_doc.jsonl(每行一个文档的 {id, text, metadata})。 - 分块层:结构化文本 → chunk 列表。产物:
chunks.jsonl(每行一个 chunk 的 {doc_id, chunk_id, text, span, metadata})。 - Embedding 层:chunk 文本 → 向量。产物:
embeddings.parquet(chunk_id, vector, model_version)。 - 索引层:向量 + metadata → 索引文件。产物:向量库快照(HNSW 图、IVF 中心、Flat 数组)+ 倒排索引 + metadata DB。
- 发布层:索引文件 → 在线服务可加载的 artifact。产物:原子切换的版本化资源(S3/OSS/对象存储上的目录)。
每层产物持久化在对象存储里,失败时从最近成功的上一层恢复,不必整条重跑。这是数据管道里经典的分段可重入模式,和 Airflow/Prefect 的 DAG 执行模型完全对齐。
flowchart TB
classDef prod fill:#efe,stroke:#080
DOCS[(文档源)] --> PARSE[解析]
PARSE -.产物.-> P1[/parsed_doc.jsonl/]:::prod
P1 --> CHUNK[分块]
CHUNK -.产物.-> P2[/chunks.jsonl/]:::prod
P2 --> EMBED[Embedding]
EMBED -.产物.-> P3[/embeddings.parquet/]:::prod
P3 --> INDEX[索引构建]
INDEX -.产物.-> P4[/索引快照 v42/]:::prod
P4 --> PUBLISH[原子发布]
PUBLISH -.切换.-> ONLINE[在线服务]
为什么一定要原子切换
索引更新不能在原地改——在线服务正在读的同时写是数据竞争的温床。正确姿势:新版本索引在新路径上构建完成后,把服务的 "current version pointer" 原子地指向新路径。版本切换后保留旧版本至少 24 小时作为回滚兜底。
工程实现有三种常见形态:
- S3 符号链接:维护一个
index/current指向index/v42。服务启动时读current。换版本时改 pointer。 - 版本标签 + 热加载:服务定期轮询 "current_version" 这个键(Redis / etcd / S3 metadata),版本号变了就下载新索引热加载。
- 蓝绿副本:两套服务各挂一个版本,LB 层切流。资源成本 2 倍但回滚最干净。
中小规模选 S3 符号链接最简单;超大规模选蓝绿副本最稳;混合选轮询热加载最省资源。
版本号策略与回滚
索引版本号不是递增数字那么简单——需要携带够多信息让运维能快速定位问题:v{YYYYMMDD}-{commit_sha}-{embedding_model},例如 v20260424-a1b2c3d-bge-m3-v1.5。三个字段分别对应:构建日期、索引 pipeline 代码版本、Embedding 模型版本。任何一个字段变了都意味着索引语义变了,在线端必须相应调整(尤其是 Embedding 模型变更必须重建全部向量,不能增量追加)。
回滚窗口推荐保留 3-7 天。回滚不是简单把 pointer 指回去——需要检查新产生的日志、反馈是否已经假设新索引存在。在写回滚脚本时就把这些依赖列出来,避免回滚本身带来新 bug。
离线链路的性能瓶颈
大规模知识库的离线索引经常卡在三个地方:
- Embedding 吞吐:单张 A100 跑 bge-m3 大约 500-800 chunk/s(batch=32, seq_len=512)。100 万 chunk 的全量 Embedding 需要 20-30 分钟。bge-small-zh-v1.5 能到 3000 chunk/s,牺牲一点精度换吞吐。
- 向量索引构建:HNSW 索引 100 万向量(768 维)构建时间约 15-30 分钟,内存占用约 3-5 GB。IVF-PQ 构建时间约等,但内存只要十分之一——大规模选 IVF-PQ,小规模选 HNSW。第 10 章展开。
- I/O 传输:parquet 文件从对象存储下载到 GPU worker 的带宽。如果 worker 和存储不在同一 region,单纯传输可以吃掉 30% 的总时长。实操上把 Embedding pipeline 和对象存储放在同一 region 能立竿见影。
4.3 在线检索链路:延迟预算怎么花
在线链路的目标:把用户请求在 SLA 内转成带引用的答案。SLA 通常是 P99 延迟 < 2s(对话场景)或 < 5s(Agent 场景)。
sequenceDiagram
participant U as 用户
participant GW as Gateway
participant QU as 查询理解
participant R1 as Dense 召回
participant R2 as BM25 召回
participant RR as Rerank
participant LLM as 生成
U->>GW: 请求 (q, user_id, ctx)
GW->>QU: 拆/改/扩查询
par 并行召回
QU->>R1: 向量搜索 top-50
and
QU->>R2: BM25 搜索 top-50
end
R1-->>RR: 候选 50
R2-->>RR: 候选 50
RR->>RR: 融合 + cross-encoder 打分
RR-->>GW: top-5 带分数
GW->>LLM: prompt(ctx + top-5)
LLM-->>U: 流式输出答案
延迟预算分配
假设总 SLA 是 P99 = 2000ms,典型预算分配:
| 阶段 | 预算 | 说明 |
|---|---|---|
| 查询理解(含 rewrite/拆解) | 150-300ms | 小模型或本地规则 |
| 多路召回(并行) | 80-150ms | 向量库 + BM25 并行 |
| 融合 + Rerank(cross-encoder) | 200-400ms | 瓶颈往往在此 |
| Context pack | 20-50ms | 应该近乎零 |
| LLM 生成(含 TTFT) | 800-1500ms | 首字 + 流式续写 |
| 网关/日志/其他 | 100-200ms | 别轻视这块 |
两个易忽略的预算泄漏点:
- 串行化:两路召回顺序调用而非并行,50+50=100ms 变成 80+80=160ms。用
asyncio.gather/tokio::join!并行是基本功。 - Rerank 批处理:cross-encoder 一次处理 50 个 (query, chunk) 对比 50 次单条调用快 5-10 倍(GPU 有效批大小)。
可用性设计
在线链路的所有调用都要有降级预案:
- 向量库不可用 → 只走 BM25
- Rerank 服务超时 → 用融合分数直接排 top-5
- LLM 超时 → 返回 "基于以下文档:[链接列表]",让用户自查
降级链路必须提前演练,不能等生产故障时才想。这和第 22 章讨论的产品化工程细节相关。
缓存:延迟和成本的共同敌人兼朋友
生产 RAG 的三层缓存各有作用:
- 查询结果缓存(Redis,TTL 5-60 分钟):相同 query 在短时间内命中直接返回答案。缓存 key 是
hash(query + user_context + knowledge_version)——user_context 让个性化不被污染、knowledge_version 让索引更新时缓存自然失效。命中率和问题集中度强相关:客服场景可能 30%+,通用问答可能只有 2-5%。 - Embedding 缓存(Redis,永久):相同文本的 embedding 复用。查询端的命中率取决于查询分布集中度;离线端的命中率在增量索引场景很高(只有少量 chunk 真的变了)。
- Rerank 缓存(Redis,TTL 小时级):
(query, chunk_id) → score的小表。同一个用户的多轮对话里常见——用户追问时会重复命中上一轮的候选 chunk。
缓存命中带来的延迟降低通常在 100-500ms 量级。但缓存失效策略极其关键——错误的 TTL 会让用户拿到过期信息。策略:任何涉及"最新"的查询不走缓存(用 query classifier 识别)、任何用户反馈过的 query 清除缓存、任何知识库有更新的场景强制失效。
并行化的工程细节
并行召回看起来容易但坑点多。Python 侧:
asyncio.gather时要注意异常传播——任一路失败默认整体失败。生产代码用return_exceptions=True单独处理每路。- 向量库和 BM25 通常是不同后端(Qdrant vs Elasticsearch),连接池要分开管理。
- Rerank 如果调用外部 API(如 Cohere Rerank),要准备本地 fallback。
Rust/Go 等语言侧同理——tokio::join! / errgroup.Go 都需要精细处理部分成功场景。这和《Tokio 源码深度解析》第 14 章讨论的 select! 公平性是同一类问题。
4.4 反馈闭环:从日志到训练信号
反馈闭环是 RAG 三条链路里最常被忽略、却最决定长期质量的一条。没有反馈闭环的 RAG 系统在上线第 3 个月开始质量衰减——知识库更新了但检索器不适配、用户行为变了但 rerank 权重不更新、新的失败模式出现了但没人发现。
反馈信号有四种
- 隐式交互:点击、停留时长、是否采纳答案、是否转人工。量大、噪声高、需要大量样本才能用。
- 显式评分:赞/踩、1-5 星评分、"这个答案是否解决了你的问题"。量小、信号强、容易被老用户偏见扭曲。
- 人工标注:专家给 gold label、做错误归因。质量最高、成本最高、适合回归集。
- LLM-as-a-judge:用大模型对比回答 vs 证据自动打分。量大、中等质量、适合规模化监控。
生产系统应该四种信号同时收集。每种信号用不同的方式消费:隐式信号用于 rerank 权重微调、显式信号用于 prompt 迭代、人工标注用于模型训练、LLM judge 用于持续监控。
flowchart LR
classDef src fill:#fed,stroke:#a60
classDef use fill:#def,stroke:#06a
L1[隐式交互]:::src --> U1[Rerank 微调]:::use
L2[显式评分]:::src --> U2[Prompt 迭代]:::use
L3[人工标注]:::src --> U3[Embedding/Rerank 训练]:::use
L4[LLM-as-judge]:::src --> U4[监控/回归测试]:::use
U1 & U2 & U3 & U4 -.上线.-> DEPLOY[新版本]
DEPLOY -.产生新行为.-> L1
从 trace 到训练集:数据工程
反馈闭环的关键是把线上 trace 转成结构化训练集。第 3 章给的 trace schema 是每个请求的完整记录。从 trace 到训练集需要几步处理:
- 去重:相同 query 在一天内命中多次,只保留一条
- 标注:用户反馈或 LLM judge 打标
- 采样:按问题类型、时间段、用户分层采样,避免头部用户主导训练信号
- 负样本构造:仅有正例的训练集会过拟合——要从未命中的候选里构造难负例(hard negatives)
产出两类数据集:
- Rerank 训练集:
(query, positive_chunk, negative_chunks[])——用于 fine-tune cross-encoder - RAG 回归集:
(query, gold_chunks[], expected_answer, acceptance_criteria)——用于每次迭代的质量门禁
第 20 章 RAG 评估会详细展开回归集的构造和维护。
反馈延迟的工程权衡
反馈闭环的时效性有一个本质张力:信号质量和时效成反比。
- 实时信号(毫秒-秒):用户是否立刻 regenerate、是否关闭会话。噪声极大但即时。只适合做异常检测(点击率突降 = 上游出问题)。
- 短期信号(分钟-小时):会话结束后的显式反馈、转人工率、后续问题的话题转移。质量中等,适合粗粒度监控。
- 长期信号(天-周):用户留存、NPS、标注团队的深度 review。质量最高,适合模型训练样本。
生产 RAG 通常三种时效都用——实时信号驱动告警、短期信号驱动仪表板、长期信号驱动模型迭代。不要指望用长期信号做实时告警(太慢)、也不要用实时信号做训练样本(太嘈)。每种信号服务不同的决策层。
反馈数据的隐私和合规
反馈闭环里流动着用户请求的完整内容——这是合规敏感区。
- 用户请求可能含 PII:姓名、手机、身份证、工单号。trace 落盘前必须脱敏,或用能区分"原文"和"脱敏副本"的双存储方案(原文短 TTL、脱敏版长 TTL)。
- 企业客户的查询本身是商业机密——跨租户的 trace 共享严格禁止。多租户 RAG 系统要保证 trace、回归集、训练数据不串租户。
- GDPR 等法规下用户有被遗忘权——反馈数据存储要支持按 user_id 批量删除。设计 schema 时就加上 user_id 索引。
这些不是架构细节,是架构底线。缺失任何一条都可能导致重大合规事故。
4.5 三条链路的数据契约
三条链路之间通过稳定的数据结构耦合——任何一份契约改了,上下游都要跟着改。生产系统的技术债主要就是契约改动的成本。
四份核心契约:
契约为什么比架构图更重要
架构图画得再漂亮、具体代码到底怎么写才不坏,取决于数据契约。同一套架构图,三个工程师做出来可能是三套互不兼容的系统——就是因为没写下来的契约不同。生产 RAG 系统上线半年后,真正稳定的不是代码、也不是模型,而是这几份契约。每次契约破坏都会引发大规模 migration(索引重建、回归集失效、日志解析崩溃),成本远高于一次模型迭代。
这一节讨论的四份契约是经验值——实际项目可能要在此基础上增减。但核心原则不变:凡是跨链路共享的数据结构都必须在代码仓里以明确的 schema 声明(pydantic、protobuf、jsonschema 均可),任何变更都 PR review。不要靠文档约定——文档会过时,schema 不会。
chunk_id 方案
唯一稳定的 chunk 标识。推荐方案:{doc_id}#{chunk_index}#{content_hash}。
doc_id:文档稳定 ID(UUID 或业务 ID)chunk_index:文档内 chunk 序号content_hash:chunk 文本的 SHA256 前 8 位
content_hash 让 "文档 ID 不变但内容改了" 的场景能被检测——旧 chunk_id 和新 chunk_id 不同,在线系统可以识别到"缓存失效"。
embedding 向量空间
embedding 的 model_version 必须在向量旁边存储。混用不同模型版本的向量会导致检索质量灾难性下降。查询端也必须用同一个 model_version 产生查询向量。版本切换要配合索引重建,不能只切其中一端。
metadata schema
每个 chunk 的 metadata 是一份半结构化字典。推荐字段:doc_id, chunk_id, source_type, publish_date, author, access_level, language, section_path。新字段必须向后兼容——增加字段不能破坏旧查询。
日志 schema
trace schema(第 3 章给过)是离线/在线/反馈三条链路共享的"对话语言"。所有链路读写 trace 都按同一 schema。schema 升级要做 major 版本号、双写、逐步迁移。
4.6 演进顺序:先跑通哪条链路
从零搭一个 RAG 系统,三条链路不要同时做。建议的演进顺序:
- MVP:在线链路跑通。用
langchain/llamaindex快速串起来,离线用一次性脚本生成索引,反馈先人工看 badcase。目标:内部可用、拿到真实流量。耗时 1-2 周。 - 离线稳定化。把离线脚本改造成可重入、可回滚、带版本控制的 pipeline。目标:知识库能每天/每周安全更新。耗时 2-4 周。
- 反馈闭环建设。加入日志采集、回归集、LLM judge 监控。目标:线上质量可观测、可对比、可定位。耗时 4-8 周。
- 优化迭代。按第 3 章的归因工作流优化各阶段。每次迭代用反馈闭环验证。目标:答对率、延迟、成本同时改善。持续进行。
阶段 1 做完就可以内部试用、拿真实反馈;阶段 2 做完才能对外开放;阶段 3 做完才有能力长期维护;阶段 4 永远持续。跳过阶段 2 直接开放对外是常见陷阱——知识库更新一次出 bug 没法回滚。
每个阶段的"验收标准"
每个阶段声明可量化的 done 标准,避免"大概做完了"心态:
阶段 1 MVP 验收:内部 5-10 人连续使用一周,平均每人提 10 个问题以上。收集 50+ 真实 badcase 归入第 3 章的失败家族。此时答对率能做到 50-65% 就可以进入阶段 2。
阶段 2 离线稳定化验收:连续 4 周周度更新知识库零事故。索引重建 pipeline 能在工作时间完成、失败时有明确告警、回滚脚本演练过一次实战(从 v42 回到 v41 后在线服务正常)。
阶段 3 反馈闭环验收:12 个最小监控指标(第 3 章 §3.10 列出)全部上看板、每个都有 SLO 和告警阈值、每周 review 一次。回归集覆盖 20+ 问题类型、每次版本迭代自动跑回归。
阶段 4 持续优化:每月做一次归因分析、按最大失败家族投优化资源、每季度基准 review 一次整体架构是否还合适当前规模。
没有量化验收就没有客观的"完成"概念——团队会在各阶段之间反复回退,项目进度预估失真。
组织形态对架构的影响
Conway's Law 在 RAG 上同样适用。三条链路如果归属三个独立团队(数据团队做离线、应用团队做在线、算法团队做反馈),架构会自然演化成三个松耦合的服务。如果一个团队通吃,系统会更紧耦合但也更易出现"谁都能改核心表"的失控。推荐:
- 小型项目(< 5 人):一个团队三条链路,但代码里严格按链路分层
- 中型项目(5-15 人):一个核心团队 + 专门的数据工程角色负责离线 pipeline
- 大型项目(15+ 人):三团队结构,但架构师角色跨团队守护数据契约
不管哪种组织形态,数据契约的所有权要 explicit——某一份契约必须明确"谁说了算",避免跨团队修改时无人负责。
4.7 三条链路各自的 SLA
三条链路的 SLA 指标完全不同——设计阶段不清晰,运维时会很痛苦。
离线索引链路:
| SLA 维度 | 典型值 | 说明 |
|---|---|---|
| 数据新鲜度 | 知识库更新后 ≤ 24h 生效 | 批模式目标;流模式可压到 5min |
| 索引重建吞吐 | 100 万 chunk / 30 分钟 | 单 GPU worker 可达 |
| 版本一致性 | 100% | 索引 + metadata + embedding 同版本 |
| 回滚 RTO | < 5 分钟 | pointer 切回 + 缓存失效 |
在线检索链路:
| SLA 维度 | 典型值 | 说明 |
|---|---|---|
| P99 端到端延迟 | < 2000ms | 对话场景 |
| TTFT 首字延迟 | < 800ms | 流式体验关键 |
| 可用性 | ≥ 99.5% | 月度;降级算可用 |
| 答对率 | ≥ 85% | 按场景;回归集 + A/B 测量 |
反馈闭环:
| SLA 维度 | 典型值 | 说明 |
|---|---|---|
| trace 落盘延迟 | < 1 分钟 | 日志管道吞吐 |
| 看板指标更新 | 5-15 分钟 | 实时性 vs 聚合成本 |
| 回归集迭代 | 每周 | 新 badcase 入回归集 |
| 模型微调周期 | 月度 | 视训练数据量和业务 |
三组 SLA 共同定义了"生产可用 RAG"的含义——缺任何一组都意味着系统在某维度失控。
4.8 三链路协同的一次典型事故
真实场景比抽象架构图更说明问题:
- 症状:周一早上业务反馈"系统全乱答"。答对率看板从 85% 跌到 40%
- 初步定位:看第 3 章结构化 trace,发现 retrieval 阶段 score 分布完全变了,top-5 几乎全是历史悠久的旧文档
- 根因追溯:周日夜里离线 pipeline 自动跑了全量索引重建。构建时用了新版 Embedding 模型(bge-m3-v1.6,上周更新的依赖),但在线服务的查询端还在用旧 bge-m3-v1.5 算查询向量
- 修复:回滚索引到上一版(v20260421)、同时把在线服务的 embedding 客户端也回退到 v1.5。45 分钟后答对率恢复
- 复盘:Embedding 模型版本没写进任何一份数据契约。加以下监控:索引 metadata 必须包含
embedding_model_version;在线查询编码器版本要与索引中的版本做启动时一致性检查;不一致直接拒绝服务
这类事故几乎所有 RAG 团队都会遇到一次。每次都是在提醒数据契约的重要性——§4.5 讨论的四份契约不是空头理论,是被这类事故反复验证出来的。
4.9 技术栈选型:不要造轮子
最后讨论一下三条链路的技术栈选型。这里给的是 2026 年初的主流组合——每个都有成熟替代品,但这份清单可以作为选型起点。
离线索引链路:
- 调度:Airflow(成熟)/ Prefect(现代 DX)/ Dagster(类型化 pipeline,类似 dbt 思路)。千级任务以下 Airflow 够用;复杂依赖图选 Prefect 或 Dagster
- 解析:unstructured.io(通用 PDF/HTML/DOCX)/ marker(PDF 专精)/ pypdf / BeautifulSoup(HTML)
- Embedding 推理:bge / text-embedding-3 / jina-embeddings / OpenAI / Voyage。自托管选 bge-m3,成本敏感选 OpenAI text-embedding-3-small
- 向量库:Qdrant / Milvus / pgvector / Weaviate / Pinecone。自托管选 Qdrant(轻量、好用)或 Milvus(大规模);快速起步选 pgvector;云上选 Pinecone
在线检索链路:
- Web 框架:FastAPI(Python 主流)/ axum(Rust 选项,见同系列《Axum 设计与实现》)/ Gin / Actix
- 检索编排:LlamaIndex / Haystack / 自写。快速起步用 LlamaIndex,生产化后多数团队自写
- Rerank:Cohere Rerank API / BGE Reranker 自托管 / Jina Reranker / LLM-as-reranker
- LLM:Claude / GPT / Gemini / Qwen / DeepSeek。企业场景注意数据合规要求
反馈闭环:
- trace:OpenTelemetry + Jaeger / Tempo
- 日志:ELK / Loki / ClickHouse
- 看板:Grafana + Prometheus
- 回归集:自维护 JSONL + CI 集成
- LLM-as-judge:ragas / trulens / 自写 prompt
选型原则:每个位置都用社区验证过的主流组件,不要在基础设施上追求差异化——差异化留给产品和数据。用 10 家以上公司在用的工具比用 1 家在用的工具故障率低一个数量级。
4.10 RAG 的部署模式:灰度、蓝绿、shadow 的具体实现
前面 9 节讲了 RAG 的架构——三条链路 + 数据契约 + SLA。但这些讨论都是"稳态运营"的视角。真实生产里系统每周都在变化:新 prompt 模板上线、新 embedding 模型试用、新 rerank 参数、新向量库版本、新 chunk 策略。发布策略决定每次变化的安全性。RAG 的部署模式和普通 Web 服务有关键差异——一条链路同时涉及有状态(索引、cache)和无状态(检索逻辑、LLM 调用)——发布策略要区别对待。
RAG 部署的独特挑战
flowchart TB
classDef stateless fill:#dfd,stroke:#080
classDef stateful fill:#fed,stroke:#c60
C[RAG 组件]
C --> A[应用层<br/>无状态]:::stateless
C --> B[向量索引<br/>有状态]:::stateful
C --> D[LLM 调用<br/>外部 API]:::stateless
C --> E[Embedding 模型<br/>有状态小文件]:::stateful
A --> A1[标准滚动发布即可]
B --> B1[蓝绿索引 必不可少]:::stateful
D --> D1[A/B + canary]
E --> E1[和索引联动 一起切]
普通 Web 服务滚动重启就能发布——RAG 里索引不能滚动重启(加载时间长、数据不能丢)、所以必须蓝绿。这是 RAG 部署的最大区别。
蓝绿索引的具体实现
第 8 章 §8.6 提过周期性全量重建 + 原子切换——那是蓝绿索引的一个场景。更一般化:
- 蓝索引(当前服务):收全部流量、应用配置指向它
- 绿索引(待发布):后台建起来、shadow 流量验证
- 切换:改应用配置(通常一个 feature flag 或 config 值)、流量立即指向绿
- 观察期:保留蓝索引 N 天(48-72 小时常见)、出问题立即切回
- 清理:观察期过后下线蓝索引
# 应用端配置
ACTIVE_INDEX = os.environ.get("ACTIVE_INDEX", "blue") # blue / green
def search(query):
return vector_db.search(collection=f"chunks_{ACTIVE_INDEX}", ...)
切换只是改环境变量 + 重启 app / 调用 control plane API——秒级生效。
Canary 发布:模型和 prompt 的小流量上线
蓝绿适合整体切换、canary 适合小流量试水。主要用于:
- 新 LLM 模型版本:Sonnet 4.6 → 4.7——先给 5% 流量
- 新 prompt 模板:改过的 system prompt——先给 5%
- 新 rerank 模型:替换 rerank——先给 10%
def generate(query, context, user_id):
# 基于 user_id hash 决定走哪条路径、保证同一用户稳定
bucket = hash(user_id) % 100
if bucket < 5 and feature_flag("new_llm_canary"):
return new_model.generate(query, context)
return old_model.generate(query, context)
Canary 观察:
- 先 5% × 1 小时——看错误率
- 10% × 24 小时——看质量指标
- 50% × 3 天——看业务指标
- 100%——稳态
任一阶段指标异常 → 回滚到上一级流量。
Shadow 流量:无用户影响的真实验证
Canary 有用户流量风险——哪怕 5% 也可能误伤。更保守的 shadow:
- 用户请求同时发给 生产路径 和 shadow 路径
- 生产路径的结果返回给用户
- Shadow 路径的结果只记录、不返回
- 后台对比两路的 recall / faithfulness / latency
async def retrieve(query):
# 主路径(返回给用户)
primary_result = await production_retrieve(query)
# Shadow(异步、用户无感)
asyncio.create_task(shadow_retrieve(query, primary_result))
return primary_result
async def shadow_retrieve(query, primary_result):
shadow_result = await new_retrieve_path(query)
log_diff(query, primary_result, shadow_result)
Shadow 持续 1-2 周、积累几万条对比——确认新路径稳定后再升级到 canary。
Feature flag:发布的核心工具
现代 RAG 项目几乎每个变更都应该带 feature flag:
if feature_flag("use_new_rerank", user_id=user_id):
rerank_model = new_rerank
else:
rerank_model = old_rerank
Feature flag 让发布变得可回滚——出问题不用发代码、改 flag 秒级生效。工具:LaunchDarkly、Growthbook、自建。
组件粒度的 flag:
- LLM 模型
- Embedding 模型
- Rerank 模型
- Rewrite 策略
- Prompt 模板
- Chunk 策略
每一维度都能独立切换、便于精确归因("这次改动是不是 rerank 导致的")。
发布策略的组合
完整的 RAG 组件发布路径:
flowchart LR
classDef stage fill:#def,stroke:#06c
S1[Dev 验证]:::stage --> S2[Shadow 1-2 周]:::stage
S2 --> S3[Canary 5%]:::stage
S3 --> S4[Canary 10%]:::stage
S4 --> S5[50%]:::stage
S5 --> S6[100%]:::stage
S6 --> S7[观察期 1 周]:::stage
S7 --> S8[清理旧版本]:::stage
每一步都有明确进入条件:
- Shadow → Canary:shadow 指标稳定 1 周
- Canary 5% → 10%:错误率不涨
- Canary 10% → 50%:业务指标不降
- 50% → 100%:观察 72 小时无异常
跳步的代价是事故风险——不要为"快"牺牲流程。
回滚演练
发布不是完整流程、回滚才是。每个发布都要伴随回滚预案:
- 能不能一键回滚?(改 feature flag)
- 回滚后数据一致吗?(索引有没有脏数据)
- 回滚时长多少?(分钟级还是小时级)
每季度做一次回滚演练——故意触发一个假回滚、计时、看流程是否顺畅。没演练的回滚在真事故时永远不如预期。
发布的观测面板
每次发布时特别要盯的看板:
- 流量分布:canary 流量比例符合预期吗
- 错误率对比:canary vs 稳定版本的 error rate 差异
- 延迟对比:p99 是否稳定
- 业务指标:点击率 / 采纳率 / 满意度
- Feature flag 状态:每个 flag 的开关实时显示
专门的 "release dashboard" 在发布当天集中看——比翻常规 Grafana 效率高。
发布的组织协调
复杂 RAG 项目、不是工程一个团队的事:
- 产品:设定发布门槛(什么指标达标可以全量)
- 工程:执行发布、写 runbook
- 运维:监控、值班 on-call
- 数据:分析业务指标变化、判断 A/B
- 合规(如果涉及):check 权限 / 审计要求
每次大发布前开一次 kickoff、对齐节奏和责任——防止各做各的、谁都不知道整体状态。
发布的反模式
- 没有 feature flag:每次改动要发代码才能切——回滚慢、心理负担大
- 一次性 100% 上线:没有 canary、事故面积最大
- 不做 shadow 验证:新路径没经过真实流量检验
- 发布文档只写"怎么发"不写"怎么回滚":出事慌
- shadow 跑太短就升级 canary:shadow 阶段的价值没充分利用
- 回滚路径没演练:真需要回滚时发现脚本坏了
和事故响应的衔接
发布不顺 → 事故。ch22 §22.15 的事故响应 playbook 和发布策略紧密配合:
- 发布过程中异常 → 立即触发 playbook
- Playbook 的 "回滚步骤" 指向的就是 feature flag 或蓝绿切换
- 发布变更进 audit log、事故 postmortem 能直接溯源
发布策略是事故预防、事故响应是发布补救——两者必须衔接。
4.11 三链路的资源隔离与独立扩缩容
前 10 节讲了三链路的职责和数据契约,还有一个常被忽视的架构决策:它们该不该共享基础设施。答案是计算资源要隔离,否则一条链路出问题会拖垮另外两条。
flowchart TB
classDef bad fill:#fdd,stroke:#c00
classDef ok fill:#dfd,stroke:#080
subgraph SHARED[资源共享]
direction LR
C1[离线 ingest<br/>CPU 100%]:::bad
C2[在线检索<br/>P99 飙升]:::bad
C3[反馈 batch<br/>延迟入队]:::bad
end
subgraph ISOLATED[资源隔离]
direction LR
I1[离线 ingest pool]:::ok
I2[在线检索 pool]:::ok
I3[反馈 batch pool]:::ok
end
三个真实事故足以说明代价:某租户上传百万文档,ingest worker 吃光集群 CPU,在线 RAG 的 P99 从 2 秒涨到 15 秒;反馈 trace 处理异常引发对象存储写入风暴,把向量库的日常查询一起拖慢;embedding 服务用同一个 GPU pool 同时跑离线和在线,离线的大批量任务把在线延迟打到秒级。共同根因都是资源共享却没有优先级和 quota。
会这样是因为三条链路的资源画像本就不同:
| 链路 | CPU | 内存 | 磁盘 IO | 网络 | 特征 |
|---|---|---|---|---|---|
| 离线索引 | 高 | 中 | 高(读写文件) | 中 | 吞吐型、可容忍延迟 |
| 在线检索 | 中 | 高(索引缓存) | 低 | 高(QPS) | 延迟型、低吞吐 |
| 反馈 batch | 低 | 低 | 中 | 中 | 定时批处理 |
三种隔离方案,以及什么该共享
物理隔离最强——每条链路独立集群或 VM group,完全隔离但要重复一整套基础设施,只在大规模生产或合规敏感时才值。
逻辑隔离是多数中大型项目的落点——同一个 K8s 集群、不同 namespace 和 node pool,用 taint 和 toleration 把 pod 钉在对应的池子上:
# 离线 pool 的 node
taints:
- key: workload
value: offline
effect: NoSchedule
# 离线 pod
tolerations:
- key: workload
value: offline
Quota 隔离最弱也最省——所有 pod 跑在一起,靠 resource quota 和 LimitRange 限制各链路用量,邻居效应仍在,但起步阶段够用。多数项目从这一档起步,规模上来后升到逻辑隔离。
要注意隔离的是计算资源,数据和监控恰恰应该共享:向量库和元数据 DB 只有一份(靠读写分离区分在线离线)、对象存储里的原始文档各链路都要读、Prometheus / Grafana 要覆盖全链路才看得出跨链路影响。
隔离之后各链路的扩缩容也该独立,因为触发条件不同——离线按 ingest 队列深度扩、在线按 QPS 或 CPU 利用率扩、反馈 batch 干脆按时间调度不做在线扩缩:
# 在线检索 HPA
online-rag-hpa:
metrics:
- queue_qps > 80% capacity
min: 5
max: 100
# 离线索引 HPA
ingest-worker-hpa:
metrics:
- kafka_lag > 10000
min: 1
max: 50
多租户会让这件事变复杂:§11.12 讲的向量库多租户叠加三链路,理论上是 N × 3 个池子,维护成本会爆炸。可行的收敛办法是按体量分档——占总量不到 5% 的小租户共用一个池,超过 20% 的大租户独立池,付费 tier 高的租户在资源紧张时优先保障。
分多少、怎么省、怎么验证有效
计算资源的典型分配比例是这样:
| 链路 | 占比 |
|---|---|
| 在线检索 | 50-60% |
| 离线索引 | 30-40% |
| 反馈闭环 | 5-10% |
| 通用(监控 / 日志 / 网关) | 5-10% |
大幅偏离就值得查一下——比如离线占到 70%,可能是数据量确实太大,也可能是 ingest pipeline 效率有问题。
成本上,按每个池的特性选实例类型能省 30–50%:在线检索延迟敏感、必须留足 buffer 不能压;离线索引是吞吐型,用 spot / preemptible 实例便宜 60–80%;反馈 batch 完全可以用 spot 并放到夜间;监控和网关维持稳定的中等配置即可。
更进一步是跨链路动态转移——非高峰时把 20% 的资源匀给离线加速 ingest,高峰时全力保在线、离线减速或暂停,凌晨让反馈 batch 用满一小时。K8s 的 Priority Class 支持这类抢占式调度。
隔离做没做到位,要靠四个指标验证:跨链路干扰率(某条链路出问题时其他链路的指标是否波动,理想是接近 0)、每个池的资源利用率(找 hot spot)、离线与反馈的队列深度(排太长说明容量不足)、以及分池统计的 SLA 达成率。
按阶段推进,别一步到位
完全隔离成本高,最小可用的隔离其实只有一条:先把在线检索拿出来独立成池,离线和反馈都对延迟不敏感,可以先共享。这一步就能避免大约八成的资源干扰事故。
之后按业务规模推进即可:前三周 MVP 阶段所有东西跑一起;一到三个月做 quota 级的在线隔离;三到十二个月把在线和离线的 node pool 分开;一年以后视规模再考虑完整的物理隔离。这个节奏让复杂度跟着业务走,既不过度设计,也不至于欠账。
4.12 架构文档化与 ADR:为什么这么决定的
RAG 系统跑了几个月、团队扩充、新人加入,就会开始出现同一类问题:为什么选 Qdrant 不是 Milvus?为什么 chunk size 是 400?为什么 rerank 用 bge-reranker 不是 Cohere?知道原因的人走了、文档没有,新人只能靠考古,决策只好凭猜测重做一遍。架构决策记录(ADR)就是针对这件事的。
flowchart TB
classDef bad fill:#fdd,stroke:#c00
classDef ok fill:#dfd,stroke:#080
N[没 ADR]:::bad
N --> N1[新人靠考古]:::bad
N --> N2[老问题反复讨论]:::bad
N --> N3[决策者走了上下文丢]:::bad
Y[有 ADR]:::ok
Y --> Y1[决策背景可查]:::ok
Y --> Y2[新人快速了解]:::ok
Y --> Y3[Review 时有依据]:::ok
代码的历史能从 git 里看,决策的历史只能靠 ADR。
一份 ADR 该写什么
ADR 是一个决策一个文档,每份只有一页:
# ADR-005: 选择 Qdrant 作为主向量库
## Status
Accepted (2026-01-15)
## Context
我们需要选一个向量库支撑 RAG 的检索层、规模预计 100-1000 万向量。
候选:
- Qdrant: Rust 原生、单机性能好、API 清晰
- Milvus: 云原生、亿级规模、生态活跃但重
- pgvector: 已有 Postgres 栈、简单但性能中等
- Pinecone: 全托管、省运维但贵 + 厂商锁定
## Decision
选 Qdrant。
## Consequences
### 正面
- 单机性能好、延迟 <10ms
- 运维简单、一个二进制
- 开源可控、无厂商锁定
### 负面
- 超过千万级时可能要换 Milvus
- 生态比 Milvus 弱
## Alternatives Considered
- Milvus:规模超我们当前需求、overhead 不值
- pgvector:性能不够
- Pinecone:厂商锁定 + 成本高
## References
- 压测报告:[link]
- 竞品对比:[link]
- 会议记录:[link]
写好一份 ADR 的关键全在三个段落。Context 比 Decision 重要——「选 Qdrant」是没法复盘的,「在 X 约束下考虑了 Y 候选、基于 Z 数据选了 Qdrant」才能在将来重新评估时用得上。Alternatives considered 让 ADR 有历史价值,将来有人想再提同一个替代方案时不必从头论证。Consequences 要诚实写负面,把接受了什么 trade-off 记下来——ADR 不是 PR 文。
不是每个小决策都值得 ADR,有分歧或影响面大的才写。RAG 项目里典型的几类是:选型类(向量库、embedding、LLM、rerank、分词器)、架构类(三链路划分、多租户架构、部署模式)、参数类(chunk size、top_k、rerank threshold,只对有争议的写)、流程类(发布流程、事故响应、数据治理)、安全类(权限模型、PII 处理、加密策略)。
流程、状态与组织方式
写完不等于生效,要走流程:提议者起草并发给相关团队,相关方 review(通常给一周),重大决策再开一次面对面的讨论会处理分歧,最后由 team lead 或架构师批准、状态置为 Accepted。每份新 ADR 都要发到团队让所有人知晓,而不是躺在某个文件夹里。
ADR 有自己的生命周期:Proposed(讨论中)、Accepted(执行中)、Deprecated(不再执行但保留)、Superseded by ADR-XXX(被新的取代)。老 ADR 永远不删——保留历史正是 ADR 的核心价值。
文件按编号组织,便于在讨论和代码里引用:
docs/adrs/
README.md # 索引
0001-use-python.md
0002-three-pipelines.md
0003-use-qdrant.md
0004-use-bge-m3.md
0005-chunk-size-400.md
...
再把 ADR 和代码双向挂上,magic number 就有了出处:
# See ADR-0005: chunk size 400 for bge-m3 sweet spot
CHUNK_SIZE = 400
这个环节的反模式也很固定:只写「我们选 X」而没有 context;写了不 review,变成作者一言堂;写完没人知道,新人根本不晓得有这份东西;决策改了却不更新,老 ADR 和现实对不上;以及走向另一个极端——每个小参数都写 ADR,噪声淹没信号。
起步与文化
新团队从小步开始就行:第 1 周建好 docs/adrs 目录,随便挑一个已经做过的决定写第一份;第 1 个月补齐 5–10 份主要决策;第 3 个月做到新决策自觉写;第 6 个月它就成了习惯。不要一上来就要求把历史全部补齐。
工具不重要,用 Michael Nygard 那套公开模板、adr-tools 这类 CLI 自动编号、Markdown 加 git PR 流程、需要对外展示时用 VitePress / Docusaurus 渲染成集合——内容才是核心。
ADR 和 postmortem 是一对:前者是事前决策的文档、用来防错,后者是事后事故的文档、用来修错,成熟团队两样都有。经脱敏的 ADR 还能对外用——社区贡献、招聘材料、技术分享、给企业客户展示技术深度,是团队工程水平的无形资产。
团队不做 ADR 的理由通常经不起推敲:「没时间」——一份 1–2 小时;「代码一眼看懂」——代码只反映行为,不反映意图;「写了没人看」——那是缺索引、缺 onboarding 指向;「总在变,写了也过时」——ADR 记的就是「当时为什么」,变了写新的、标 Superseded 即可。真实原因往往是组织对文档化不重视,这要从文化层面改。
十年老项目最典型的痛点就是「为什么这样设计没人知道」,最后所有改动都变成「我不敢动、怕出事」。有 ADR 文化的项目相反:新人一个月内能理解核心决策,改动前 review 老 ADR 就清楚影响面,迭代速度不随时间衰减。
最后说明一下本专栏和 ADR 的关系:本专栏讨论的每个决策(chunk size、rerank、hybrid)都是 ADR 级别的决策,但本专栏给的是一般化的决策框架,你团队的 ADR 要在此之上补进自己的数据和 context——两者互补,不能相互替代。
4.13 跨专栏关联:批 + 流 + 闭环是大数据架构的通用语言
RAG 的三链路架构并非 RAG 独有。
- 大数据 Lambda 架构(Nathan Marz, 2011)把分析系统拆成 batch layer + speed layer + serving layer,是三链路的早期原型。
- MLOps 里的"离线训练 + 在线推理 + 线上反馈"三循环是同构。MLFlow/Kubeflow 的 pipeline 抽象和 RAG 离线链路直接对应。
- 推荐系统的召回 + 粗排 + 精排 + 反馈闭环和 RAG 的召回 + rerank + 反馈闭环同构到可以互相迁移。实际上做搜索推荐出身的工程师看 RAG 会觉得"换了套词汇讲老故事"。
理解这层同构的好处:搜索/推荐领域 20 年积累的工程经验(特征工程、A/B 测试、online learning、冷启动)都可以迁移到 RAG。第 10 章讨论向量索引时会回到这层关联——HNSW/IVF 等索引结构在搜索系统里早就成熟,RAG 只是把它们放进了 LLM 工作流。
4.14 本章小结
- 生产 RAG 由离线索引、在线检索、反馈闭环三条链路组成,三者时序解耦但数据耦合。
- 离线链路追求吞吐和一致性——分段可重入、原子发布、版本化索引。
- 在线链路追求延迟和可用性——并行召回、rerank 批处理、每阶段降级预案。
- 反馈闭环追求时效和信号质量——四种信号源互补、trace 转训练集是数据工程重点。
- 三条链路间通过 chunk_id / embedding 向量空间 / metadata schema / 日志 schema 四份数据契约耦合。
- 演进顺序:先跑通在线 → 离线稳定化 → 反馈闭环 → 优化迭代。跳步必然踩坑。
下一章起进入第二部分"知识进入系统"——从文档解析开始,沿离线链路逐层拆解。第 5 章讲如何把 PDF、HTML、Markdown、代码仓库变成可索引的结构化文本。