Dify RAG Pipeline 源码拆解:工业级 RAG 的 5 个设计取舍
背景:我之前用 FAISS + BM25 + RRF(k=60) + BGE-Reranker 做过一套 RAG 知识库系统,最近花两天把 Dify 的
api/core/rag/目录拆了一遍。本文聚焦于设计决策和工程取舍。
架构概览
大家都知道 RAG 的流程是 chunk → embed → retrieve → rerank,但真正打开一个 10 万星项目的源码,才发现每一步都有值得琢磨的取舍。
Dify 的 RAG 分为两条独立链路,由不同的上层调用方编排:
离线索引(Celery 异步任务):提取纯文本 → 清洗 → 递归分片 → 索引处理(父子分段)→ embedding 向量化 → 写入向量库 + 关键词索引
在线检索(dataset_retrieval.py 同步编排):路由选库 → ThreadPool 并发执行向量/全文检索 → 去重 → Rerank 融合 → 拼接上下文 → LLM 回答
rag/ 目录下的模块只是工具箱,不知道自己的执行顺序。编排逻辑在上层——这是一个很干净的解耦设计。
取舍 1:分片策略 — 递归降级 + 父子分段
核心类 RecursiveCharacterTextSplitter,分隔符列表 ["\n\n", "\n", " ", ""] 依次降级。切不动就换更细粒度的分隔符,最后兜底按字符切。
亮点是父子分段(Small-to-Big):
document_nodes = splitter.split_documents([document]) # 父块:\n\n + 1024
child_nodes = self._split_child_nodes(document_node, ...) # 子块:\n + 512
document_node.children = child_nodes
检索时匹配子块(精准),返回时带父块(上下文完整)。解决了 chunk 粒度和上下文长度的经典矛盾。
跟我的对比:我的 RAG 所有 chunk 平级,经常遇到"检索到了但 LLM 回答不完整"的问题。父子分段实现成本不高,效果直观。
取舍 2:混合检索 — 并发执行 + 快速失败
_retrieve() 中向量检索和全文检索的 if 是并列的(不是 elif),两个任务同时提交到 ThreadPoolExecutor。
关键工程细节:
for future in concurrent.futures.as_completed(futures, timeout=300):
if future.exception():
for f in futures: f.cancel()
break
as_completed 谁先完成先处理,不互相等。任何一路报错立刻取消所有,快速失败。比较有意思的是 Dify 用线程池而不是 asyncio,可能是因为底层向量库 SDK 大多是同步的。
跟我的对比:我的两路检索是串行的,且几乎没有错误处理。
取舍 3:为什么用 TF-IDF 而不是 BM25
weight_rerank.py 的关键词打分链路:Jieba 分词 → TF-IDF 向量 → 余弦相似度 → [0, 1] 分数。
为什么不用更常见的 BM25?归一化问题。TF-IDF + 余弦天然输出 [0, 1] 分数,和向量检索的余弦相似度尺度一致,可以直接加权融合:
score = 0.7 * vector_cosine + 0.3 * tfidf_cosine
BM25 的分数范围不固定(取决于文档集合),做加权融合前需要额外归一化,且归一化方式(min-max / z-score)会影响稳定性,BM25 分数随文档集合变化而变化,新增文档后所有分数都会漂移。
跟我的 RRF 对比:RRF 用排名代替分数(1/(k + rank)),天然不需要归一化,但丢失了分数的绝对信息。Dify 选 TF-IDF 是在"需要原始分数信息"的前提下选了最容易对齐尺度的方案。
Dify 同时支持另一条路径:调外部 cross-encoder 模型(Cohere/BGE),对每个 query-doc pair 做交互式打分。精度更高但额外调 API,是精度和成本的取舍。
取舍 4:去重双保险
两处去重设计:
- 索引时:每个 chunk 计算
doc_hash(内容哈希),防止重复写入。 - 检索后 rerank 前:
_deduplicate_documents()基于doc_id去重,防止向量和全文检索命中同一个 chunk 后重复送入 rerank 影响排序。
只在混合检索时才走 rerank 融合——单路检索的结果已经按各自相似度排序,Dify 认为够用。这是一个精度和复杂度的取舍:加 cross-encoder reranker 效果更好,但单路检索加 rerank 的收益不够大,不值得额外开销。
取舍 5:Embedding 缓存
cached_embedding.py 用内容哈希做缓存 key:命中直接返回,没命中才调 API 再存缓存。在增量索引场景下(文档更新但大部分 chunk 没变),能省下大量 embedding API 调用。
设计模式一览
拆完整个 rag/ 目录,Dify 用到的设计模式很统一:
- 工厂模式:
vector_factory.py、rerank_factory.py、index_processor_factory.py——根据配置创建不同实现 - 适配器模式:
vdb/下 30+ 种向量库都实现vector_base.py的统一接口(增删改查),上层代码不关心具体用哪个 - 策略模式:rerank 的加权融合和模型重排是两个可互换的策略
跟我在 Auto-Tweet Agent 里用策略模式统一调度三个 LLM(DeepSeek/Claude/MiniMax)是同一个思路。
总结:差距在工程,不在算法
| 维度 | Dify RAG | 我的 RAG 2.0 |
|---|---|---|
| 分片 | 递归降级 + 父子分段 | 固定长度,chunk 平级 |
| 检索 | ThreadPool 并发 + 快速失败 | 串行,无错误处理 |
| 融合 | TF-IDF 余弦加权 / cross-encoder | RRF(k=60) + BGE-Reranker |
| 去重 | 索引级 + 查询级双保险 | RRF 天然部分去重 |
| 缓存 | embedding 内容哈希缓存 | 无缓存 |
| 向量库 | 适配器模式 30+ 种 | 仅 FAISS |
我的 RRF + BGE-Reranker 方案在算法层面并不弱,但工程成熟度差距明显。这次拆源码最大的收获不是学到了某个新算法,而是理解了工业级代码如何把"能跑"变成"能用"——错误处理、缓存、去重保障、抽象解耦,这些看不见的东西才是真正的护城河。
下一篇准备拆 Dify 的 workflow / Agent 模块,欢迎关注。
顺带一提,目前在寻找 AI Agent 方向的实习机会,如果你的团队在做相关方向,欢迎交流。