Dify 中级实验(11):高级 RAG 流水线——如何搭建多路检索与精排?
1. 业务场景
先讲一个我们实际遇到的场景。
一家做企业级软件的厂商,产品手册、API 文档、FAQ 加起来上千页。客户和一线客服每天都在问同样的问题:「如何配置 API 密钥?」「重置密码后 API 还能用吗?」——这些问题原本要人工翻文档回答,于是他们搭了一个知识库问答机器人。结果机器人上线后,客服反馈「还不如我自己翻文档」:问「API 密钥在哪配」,它答非所问;问个跨章节的综合问题,它只引用了一部分资料;最要命的是,有些回答看起来头头是道,实际是模型自己编的。
我们第一次接这类需求时,第一反应也是「换个更强的模型试试」。真正动手把链路拆开才发现——模型是无辜的,检索链路才是瓶颈:召回、去重、精排、裁剪,每一环欠一点优化,喂给模型的上下文就差一截,模型再强也答不对。
这不是个例。任何「文档量大、问题五花八门、回答必须可溯源」的场景都是这个模式:企业内部 SOP 查询、合规条款检索、产品支持门户、医疗法律知识问答……单条检索链路解决不了这些问题,真正要解决的是「怎么把检索质量拆开、逐环节优化」。
2. 场景痛点
这个场景的痛点,在这家厂商的客服团队身上体现得最直接:
- 单路检索召回不全:用户换个说法提问就搜不到。问「如何配置 API 密钥?」,检索走的是一套关键词匹配,而文档里写的是「API Key 申请流程」——语义对得上、字面对不上,相关文档根本没被召回,回答自然跑偏。
- 排序不准,捡了芝麻丢了西瓜:多路检索召回来的文档,相关的那篇排在后面,LLM 优先引用了不相关的段落,答案质量直接崩。客服为了确认一个答案,往往要自己再翻一遍原文。
- 回答不可信、不敢用:LLM 基于残缺的上下文自由发挥,编出来的答案客服不敢直接转述给客户,机器人的存在反而增加了核对成本。
- 上下文窗口撑爆:多路召回的文档全塞进提示词,一次回答消耗的 Token 成倍上涨,长文档场景甚至直接超出上下文限制,运行报错。
本质上,「答非所问、引用不可信」不是 LLM 的问题,而是检索链路每一环都欠优化——召回、去重、精排、裁剪、溯源,任何一环弱,答案质量就崩。
3. 方案:为什么是高级 RAG 流水线
Dify 工作流里可以用节点把「检索质量」拆成一条可逐环优化的流水线:Query 改写 → 三路并行检索 → 合并去重 → Rerank 精排 → 上下文窗口裁剪 → 带引用溯源回答。
选它的理由:
- 检索环节可独立调优:每条检索路、每个 top_k、每道去重逻辑都是独立节点,坏哪一环就单独修哪一环,不用推翻整条链路重来;
- 代码节点兜住 LLM 的不稳定:LLM 改写查询、输出 JSON 都不稳定,解析失败有代码节点兜底回退;合并去重、窗口裁剪这些确定性逻辑全部交给代码节点,稳定可控;
- 回答可溯源:最终生成环节强制 LLM「只基于文档回答 + 标注来源序号」,回答质量可核查、可追责。
这篇文章我们就用它搭一个「技术文档问答系统」:用户提问后,系统改写问题生成
3 个检索查询,从技术文档库和 FAQ 库并行检索,合并去重后精排、裁剪,最后
LLM 只基于文档生成回答并标注来源 [1][2]。
4. 整体架构
整条流水线的架构关系可以更直观地看成:
链路很清晰:入口收问题 → 改写扩召回 → 三路并行检索 → 合并去重精排 → 裁剪控窗口 → 带引用生成。合并去重和窗口裁剪是这条链路的两个关键闸门——前者保证 LLM 拿到的都是不重复的高分片段,后者保证上下文不撑爆。
5. 模块设计
5.1 开始节点
- data:
type: start
variables:
- label: 用户问题
required: true
type: text-input
variable: user_query5.2 Query 分析器(LLM)——变量必须三花括号
prompt_template:
- role: system
text: |
你是一个检索优化专家。分析用户问题,输出检索用查询。
用户问题:{{#start.user_query#}}
任务:
1. 识别核心实体和关键概念
2. 生成 3 个检索查询:查询1(精确版)、查询2(扩展版)、查询3(关键词版)
3. 输出纯 JSON(不要 Markdown 围栏,不要其他内容)
model:
completion_params: {max_tokens: 2000, temperature: 0.7}
name: deepseek-v4-flash
provider: langgenius/deepseek/deepseek5.3 解析检索查询(Code)
LLM 输出常带 ```json 围栏或多余文字,代码节点用正则提取第一个
{...} 并做兜底:
def main(llm_text: str, user_query: str) -> dict:
import json, re
text = (llm_text or "").strip()
queries = None
m = re.search(r'\{.*\}', text, re.DOTALL)
if m:
try:
data = json.loads(m.group(0))
qs = data.get("search_queries")
if isinstance(qs, list) and qs:
queries = [str(x) for x in qs]
except Exception:
queries = None
if not queries:
queries = [user_query, user_query, user_query] # 解析失败兜底
queries = (queries[:3] + [queries[0]] * 3)[:3]
return {"query1": queries[0], "query2": queries[1],
"query3": queries[2], "original_query": user_query}5.4 三路知识库检索
三路并行,query 分别取 query1/query2/query3;精确路
top_k=3、语义路 top_k=5、FAQ 路
top_k=3。output_retrieval_result 必须为
true,否则结果为空:
- data:
dataset_ids: [459c4981-6b76-43e7-b44f-c430f33ef035] # 技术文档库 ID
output_retrieval_result: true
query_variable_selector: [cd_parse, query1]
retrieval_mode: single
top_k: 3
type: knowledge-retrieval
id: kb_exact5.5 合并去重与窗口裁剪(Code)
合并节点做前 50 字符模糊去重、按分数降序、截取前 10
条,同时拼出带编号的 docs_text 供 LLM 引用:
def main(results_a: list, results_b: list, results_c: list) -> dict:
seen, merged = set(), []
for results, source in [(results_a, "tech_doc"), (results_b, "tech_doc_semantic"), (results_c, "faq")]:
for item in (results if isinstance(results, list) else []):
content = str(item.get("content", ""))
key = content[:50].strip() # 前 50 字符模糊去重
if key and key not in seen:
seen.add(key)
merged.append({"content": content, "title": item.get("title", ""),
"score": item.get("score", 0), "source": source})
merged.sort(key=lambda x: float(x.get("score", 0) or 0), reverse=True)
merged = merged[:10]
lines = []
for i, d in enumerate(merged):
lines.append("[{}] {} (来源: {}, 相关度: {})".format(i + 1, d["title"], d["source"], d.get("score", 0)))
lines.append(d["content"]); lines.append("---")
total_in = sum(len(x) for x in (results_a, results_b, results_c) if isinstance(x, list))
return {"merged_results": merged, "total_unique": len(merged),
"dedup_count": total_in - len(merged), "docs_text": "\n".join(lines)}窗口裁剪节点按 1.5 字符 ≈ 1
token(中文)估算累计长度,超过 max_chars=8000
就截断最后一条并打 truncated
标记,防止检索结果撑爆上下文。
5.6 生成带引用回答(LLM)
你是一个技术问答专家。根据提供的文档资料回答用户问题。
回答要求:
1. 仅基于提供的文档内容回答,不要自行编造
2. 每个关键信息末尾标注来源序号,如 [1][2]
3. 如果文档中没有相关信息,明确说"提供的文档中未找到相关信息"
参考资料:
{{#cd_window.selected_text#}}
用户问题:{{#start.user_query#}}
6. 运行验证
| 测试输入 | 预期 | 实测 |
|---|---|---|
| 如何配置 API 密钥? | 从技术文档回答,带 [1] 引用 | 命中 API 文档分段,引用标注正确 |
| 重置密码后 API 还能用吗? | 综合技术文档 + FAQ 两路来源 | 同时引用 [1][2],跨库信息综合 |
| 你们的公司食堂在几楼? | 明确回答「未找到相关信息」 | 无幻觉,如实说明 |
| 任意问题(观察统计) | total_unique ≤ 10 且 dedup_count ≥ 0 | 三路共 11 条去重后剩 8 条,去重生效 |
7. 实战坑
| 坑 | 现象 | 修复 |
|---|---|---|
| LLM 提示词用双花括号 {{var}} | 变量不替换,{{var}}
原样传给模型 |
节点内变量引用一律
{{#node.field#}} 三花括号 |
| Rerank 模型名写短名 | 知识库配置报「模型不兼容」 | reranking_provider_name
必须三段式全名 langgenius/xxx/xxx,且
reranking_enable + reranking_model
在知识库数据集层面配置 |
| 分段不完整 | 检索命中半截内容,回答断章取义 | 导入文档时用「自包含分段」,保证每段语义完整 |
| LLM 输出带 ```json 围栏 | json.loads 直接抛异常 |
代码节点正则提取第一个
{...},失败回退原问题 |
| 检索节点没开 output_retrieval_result | result 为空,合并全丢 | 该开关必须 true,结果才是 list[dict] |
其中「双花括号」这个坑值得展开看——它是 RAG 链路里最容易犯、又最隐蔽的错误。第一次搭这条流水线时,Query 分析器的提示词里写的是:
用户问题:{{user_query}} <!-- 双花括号,错误写法 -->
结果运行后模型收到的不是用户问题原文,而是字面量
{{user_query}}——改写出来的 3
个检索查询全部基于这个占位符文本,检索结果自然全错。最迷惑人的是:工作流不报错,链路正常跑完,只是答案质量莫名差。后来把变量引用改成三花括号直接引用后才恢复正常:
用户问题:{{#start.user_query#}} <!-- 三花括号,正确写法 -->
排查口诀:Dify 1.16 的 LLM 节点只替换
{{#节点id.字段#}}三花括号引用,{{变量名}}双花括号会原样传给模型——出现「模型回答里带着 {{xxx}} 占位符」或「答案质量莫名变差」时,先查提示词里的变量引用形态。
8. 实验文档及源码获取
- 实验文档(完整操作步骤):DIFY-12:高级RAG流水线.md
- 源码(可直接导入):dify102_12_高级RAG流水线.yml
文章聚焦核心配置与采坑点;实验的完整分步操作(节点搭建/参数表/调试指引)见实验文档原文。
- Dify 中级实验(01):参数提取器实战——如何从自然语言中提取结构化数据?
- Dify 中级实验(02):问题分类器——智能路由引擎如何四路分发?
- Dify 中级实验(03):模板转换实战——如何用零 Token 完成文本加工?
- Dify 中级实验(04):迭代进阶——如何批量处理数据并守住性能边界?
- Dify 中级实验(05):并行执行——如何让多路任务同时跑?
- Dify 中级实验(06):变量聚合——如何确定性合并多路分支结果?
- Dify 中级实验(07):子工作流——如何把公共逻辑做成可复用积木?
- Dify 中级实验(08):代码节点进阶——如何用标准库处理文件与数据?
- Dify 中级实验(09):HTTP 节点进阶——如何搞定认证、分页与错误重试?
- Dify 中级实验(10):知识库深度调优——如何科学评估检索质量?
- Dify 中级实验(11):高级 RAG 流水线——如何搭建多路检索与精排?
- Dify 中级实验(12):Agent 深度配置——如何让智能体自主调用工具?
- Dify 中级实验(13):多 Agent 协作——如何编排多个智能体分工干活?
- Dify 中级实验(14):对话变量与状态管理——如何让工作流记住多轮对话的状态?
- Dify 中级实验(15):条件分支高阶策略——多条件路由如何避免分支爆炸?
- Dify 中级实验(16):错误处理与降级——工作流如何有尊严地失败?
- Dify 中级实验(17):调试监控与性能优化——响应慢和 Token 超支如何定位?
- Dify 中级实验(18):插件开发入门——如何把工作流变成 Agent 可调用的工具?
- Dify 中级实验(19):综合实战——如何把 19 个实验串成一条生产级流水线?
- Dify 中级实验(20):综合实战——自动化报告生成流水线如何从数据到周报一步到位?