RAG Pipeline 主链路
Written: 2026.06第 10 章跟敲代码:上一讲:QAService 核心编排codealong/chapters/ch10_rag_pipeline。 这部分代码是本章跟敲版,用来先跑通核心闭环;完整项目源码仍以本讲后文标注的qa_core/、scripts/等路径为准。
下一讲:Prompt 工程与 Profile 系统
1. 本讲目标
- 理解 RAG Pipeline 的 8 个 Stage(0-7)事件生成模型
- 掌握 FAQ 快速路径和 FAQ 标准直出的区别
- 理解上下文构建中的筛选、去重、截断策略
- 理解答案引用增强的实现
本讲边界 本讲关注一次在线问答的完整主流程:问题如何进入 Pipeline、如何检索、如何构建上下文、如何流式返回。Prompt 选择和模板细节在本讲只作为“生成阶段的一部分”理解,完整展开放到第 11 讲。
2. 前置知识 — Pipeline 设计模式
2.1 Pipeline vs Chain
Chain(链):固定的步骤序列,A → B → C → D,没有分支。 Pipeline(管道):有分支、有快慢路径的流程。每一步可以提前结束(如 FAQ 命中时跳过文档检索),也可以根据上一步的结果调整下一步的参数。 本项目的 RAG 流程是 Pipeline 而非 Chain:2.2 Pipeline 的模块化拆分
项目将 Pipeline 拆分为多个职责单一的文件:3. 8 个 Stage 主流程
3.1 Stage 0-7 可视化总览
3.2 完整流程代码(简化)
3.3 Stage 1:查询路由
这是在线问答进入检索准备之前的低成本路由层。它统一处理三类结果:| route | intent | 含义 |
|---|---|---|
direct_answer | GREETING / HUMAN_SERVICE / OUT_OF_SCOPE | 问候、转人工、越界、场景/source 边界,直接返回 |
faq_exact | FAQ_QUERY | FAQ 标准问题精确命中,直接返回标准答案 |
retrieval | 暂不确定 | 路由不了,进入检索准备 |
route=faq_exact,同时携带 intent=FAQ_QUERY。
你好、转人工、彩票怎么买 这类问题不应该先进入 FAQ 快速路径,而应该在这一阶段直接收口。
3.4 FAQ 精确命中为什么放在路由层
FAQ 精确命中依赖知识库内容、版本、tenant、source_filter 和标准问题文本,它不是“用户意图类型”。所以更准确的表达是:查询路由层可以产出route=faq_exact,并把 intent 标记为 FAQ_QUERY。
3.5 _exact_faq_answer() — 精确匹配实现
- 减少首 token 延迟。标准 FAQ 的精确命中不需要经过历史加载、检索类意图识别、改写、检索计划等步骤。
- FAQ 快速路径仍然访问 Milvus(带版本和数据隔离过滤),不是本地缓存。
- 它在同一个
decide_route()中排在 direct_answer 之后,避免问候、转人工、越界问题先触发知识库查询。
- 还没做意图识别,不知道这是 FAQ_QUERY 还是 KNOWLEDGE_QUERY
- 如果是知识咨询但 FAQ 相似分数高,可能误答。所以只允许用户问题和 FAQ 标准问题完全一致时才直出。
3.6 FAQ 标准直出 vs FAQ 精确路由
这是两个容易混淆的概念:| FAQ 精确路由(Stage 1) | FAQ 标准直出(Stage 3) | |
|---|---|---|
| 时机 | decide_route() 中,检索准备之前 | 检索准备之后 |
| route / intent | route=faq_exact,intent=FAQ_QUERY | route=retrieval 后识别出 intent=FAQ_QUERY |
| 匹配方式 | 仅精确匹配 | 精确匹配 + 相似分数阈值 |
| 阈值 | ∞(只精确) | 动态(0.62~0.86) |
| 适用 | 短标准问答 | 已确认 FAQ_QUERY 意图 |
| 风险 | 低(只精确) | 中(相似分数可能误命中) |
4. 上下文构建
4.1 select_context_docs() 的筛选策略
4.2 build_context() 的格式化输出
5. 信息不足处理
5.1 什么情况判定为信息不足
prepare_answer 在 steps.py 中定义,其内部的信息不足判定委托给 _build_answer_context:
_build_answer_context 负责实际的上下文筛选和命中类型判定:
5.2 信息不足的答案
6. 答案引用增强
6.1 什么是引用增强
LLM 生成的答案可能引用上下文中的信息,但不会自动标注”这个信息来自哪个文档”。引用增强在 LLM 生成完答案后,检查答案是否提到了上下文中的关键信息,如果提到了就补充来源标注。7. 性能追踪
7.1 阶段计时
end 事件:
8. 流式事件协议 — 前后端如何协作
8.1 事件驱动的问答模型
一次 RAG 问答不是”前端发请求 → 等 5 秒 → 收到完整答案”。实际的用户体验是:8.2 五种事件类型
8.3 每种事件的字段结构
start 事件 — 请求已被接收:message 显示为一个动态更新的状态栏或加载提示。
token 事件 — 流式答案的片段:
8.4 前端如何消费事件
8.5 后端如何推进生成器
关键问题:QAService.stream_query() 是同步生成器(它内部顺序执行意图识别、Milvus 检索、本地 rerank 和 LLM 流式调用),但 WebSocket 路由是异步函数。如果直接调用 next(iterator),事件循环会被阻塞。
解决方案:asyncio.to_thread 将同步生成器的推进放到独立线程:
QAService.stream_query() 是一个同步生成器——它内部顺序执行意图识别、Milvus 检索(gRPC 阻塞调用)、本地 Rerank(CPU 密集计算)、LLM 流式调用(HTTP 阻塞读取)。如果把这段逻辑直接放在主线程的事件循环中调用 next(iterator),整个事件循环会在每次推进生成器时被阻塞,导致其他 WebSocket 连接、HTTP 请求全部卡住。
解决方案:asyncio.to_thread 作为桥梁。 主线程通过 asyncio.to_thread 把同步生成器的推进操作丢给线程池中的工作线程,自己立即返回并继续处理事件循环中的其他任务。工作线程推进完成后,结果通过 Future 传回主线程,主线程再 await websocket.send_json(event) 发给浏览器。
图中两条线程的分工:
| 职责 | 主线程(事件循环) | 工作线程 |
|---|---|---|
| WebSocket 收发 | ✅ 接收用户消息、发送事件 | ❌ |
| 意图识别 | ❌ | ✅ 同步调用 |
| Milvus 检索 | ❌ | ✅ gRPC 阻塞调用 |
| 本地 Rerank | ❌ | ✅ CPU 密集计算 |
| LLM 流式调用 | ❌ | ✅ HTTP 阻塞读取 |
| 后台摘要刷新 | ✅ 调度(不阻塞响应) | ❌ |
_next_stream_event(stream) 捕获这个值并通过 asyncio.to_thread 的返回值传回;主线程拿到事件后立即 send_json 给浏览器。这个过程对用户透明——浏览器看到的是连续的 status → token... → end 事件流。
为什么不在 LLM 流式阶段回到主线程? LLM 的 llm.stream() 本身返回一个迭代器,每次迭代都是阻塞的 HTTP 读取操作。如果回到主线程逐 token 读取,同样会阻塞事件循环。所以整个生成器——从意图识别到最后一个 token——全部留在工作线程中执行。
8.6 事件协议的设计原则
- 类型安全:每个事件都有
type字段,前端用switch分派处理,不靠字段存在与否判断 - 诊断信息附带:
end事件携带完整的 retrieval 诊断信息,前端可以用 JS 渲染到页面上,帮助用户理解”系统为什么这样回答” - 错误不崩溃:异常转为
error事件,不抛到 WebSocket 路由。用户看到错误提示后可以继续下一轮提问 - 历史写入在最后:
end事件之后才写 MySQL 历史,确保历史记录的是完整答案(含引用增强后的来源)
9. 本讲实践闭环
| 项目 | 内容 |
|---|---|
| 本讲类型 | 系统集成 |
| 实践产物 | Stage 0-7 RAG Pipeline、上下文构建、引用增强、流式事件协议 |
| 是否进入最终项目 | 是 |
| 验收方式 | 发起一次知识问答,观察 start/status/token/end 与引用来源 |
| 后续落点 | 第 11 讲完善 Prompt Profile,第 19 讲通过 Trace 观察阶段耗时 |
9.1 本讲从 0 到 1 实现闭环
本讲是在线问答的主干实现。实现完成后,相关代码结构应该是下面这张图:9.1.1 :定义流式事件协议
目标:Pipeline 不直接返回字符串,而是持续产出浏览器能理解的事件。 来源:真实代码逻辑压缩版,对应qa_core/pipeline/events.py、qa_core/pipeline/runtime.py 与 qa_core/pipeline/rag.py。
9.1.2 :串起意图、计划和检索
目标:把第 5-8 讲的模块接进同一条主流程。 来源:真实代码逻辑压缩版,对应qa_core/pipeline/rag.py 和 qa_core/pipeline/steps.py。
debug_retrieval() 只用于检索诊断,故意从 prepare_retrieval() 开始跑半链路,方便观察检索类意图、source 推断、按需改写和 FAQ/Doc 召回;线上问答入口仍然是 stream_query(),并且一定先经过 decide_route()。
9.1.3 :实现 FAQ 快速路径和文档 RAG 分支
来源:真实代码逻辑压缩版,对应qa_core/pipeline/rag.py::_search_and_generate() 与 qa_core/pipeline/retrieval_steps.py。
9.1.4 :构建上下文并流式生成
来源:真实代码逻辑压缩版,对应qa_core/pipeline/rag.py、qa_core/pipeline/steps.py、qa_core/pipeline/context.py、qa_core/pipeline/citations.py。
9.1.5 :保存历史并记录 Trace
来源:真实代码逻辑压缩版,对应qa_core/pipeline/rag.py::_finish_with_single_answer() 和 Stage 7。
9.1.6 :验收完整链路
验收方式: 来源:命令行验收,对应scripts/api_e2e_smoke.py。
| 验证项 | 输入场景 | 期望结果 |
|---|---|---|
| 事件协议 | 任意知识问答 | 能看到 start/status/token/end |
| FAQ 快速路径 | 高置信 FAQ | 可直出答案并带来源 |
| 文档 RAG | 长文档类问题 | 检索上下文后生成答案 |
| 信息不足 | 知识库无依据 | 返回 insufficient_context,不编造 |
| 引用增强 | 有上下文答案 | 答案包含来源引用 |
| 历史保存 | 连续对话 | 后续追问可读取历史 |
- 能看到
start/status/token/end事件。 - FAQ 快速路径、文档 RAG、信息不足至少各有可解释分支。
- 最终答案带引用来源。
- 右侧诊断或 Trace 能看到命中路径、阶段耗时和 top score。
10. 重点掌握
| 优先级 | 内容 | 原因 |
|---|---|---|
| ★★★ 必会 | Stage 0-7 主流程:创建上下文 → 查询路由 → 检索准备 → FAQ 检索 → 文档检索 → 上下文构建 → LLM 流式生成/引用增强 → 保存历史 | RAG Pipeline 的完整骨架 |
| ★★★ 必会 | route=faq_exact vs intent=FAQ_QUERY vs FAQ 标准直出的区别 | route 是系统处理方式,intent 是用户意图,二者不要混淆 |
| ★★★ 必会 | 上下文构建(select_context_docs)的筛选策略:FAQ 前 2 条 → 分数过滤 → 去重 → 表格行优先 → 三重预算约束(条数/单条长度/总长度) | 决定 LLM 看到的上下文质量 |
| ★★★ 必会 | 流式事件协议的五种事件类型(start/status/token/end/error)及前后端协作方式 | 理解浏览器如何实时展示 RAG 进度 |
| ★★ 理解 | 信息不足(insufficient_context)时明确告知用户,不让 LLM 即兴发挥 | 避免幻觉的重要安全机制 |
| ★★ 理解 | 引用增强(enforce_answer_citations):LLM 答案已有 [N] 则不重复,否则末尾补充前 3 个来源 | 保证答案可溯源 |
| ★★ 理解 | 阶段计时(RAGQueryContext.stage)追踪每个阶段耗时 | 性能优化的数据基础 |
| ★★ 理解 | asyncio.to_thread 桥接同步 Generator 和异步 WebSocket | Python 异步编程的关键模式 |
| ★ 了解 | Pipeline 的模块化拆分:rag.py / runtime.py / steps.py / retrieval_steps.py / context.py / events.py / citations.py | 了解文件职责划分 |
11. 本讲小结
- Pipeline > Chain:RAG 是有分支、有快慢路径的管道,不是固定步骤的链
- 查询路由统一处理 direct_answer、faq_exact、retrieval,避免拆成两套“意图识别”
- FAQ 精确命中是
route=faq_exact,不是新的 intent;它命中时仍携带intent=FAQ_QUERY - 上下文构建依次执行:FAQ 补充 → 分数过滤 → 去重 → 优先表格 → 截断 → 格式化
- 信息不足时明确告知用户,而不是让 LLM 在没有资料的情况下生成幻觉
- 引用增强在 LLM 生成的答案后补充来源标注
- 阶段计时追踪每个阶段的耗时,帮助定位性能瓶颈

