拆完 Dify RAG 源码后的思考
Dify RAG Pipeline 源码分析
我是一个非科班(光电专业)转 AI 的大三在校生,之前自己用 FAISS + BM25 + RRF + BGE-Reranker 搭过一套 RAG 系统。最近花了两天时间把 Dify 开源项目的
api/core/rag/目录从头拆了一遍,想看看工业级的 RAG Pipeline 跟我自己写的到底差在哪里。这篇文章是我的拆解笔记和反思,是一个学生在说"原来还可以这样做"。
为什么选 Dify
选 Dify 有三个原因。第一,它是目前 GitHub 上 star 数最高的 LLM 应用开发平台之一,RAG 是它的核心能力。第二,我自己做过 RAG 项目,有实战基础,读源码不会完全懵。第三,我目标方向是 AI Agent 开发,Dify 不仅有 RAG 还有完整的 Agent 框架,后续准备继续拆它的 workflow 模块。先从熟悉的 RAG 入手,建立对整个代码库的手感。
整体架构:两条独立的链路
Dify 的 RAG 拆开来看,其实就是两条完全独立的链路,由不同的上层调用方编排。
离线索引链路——当我们在 Dify 里上传一份文档后,后台发生的事情是:先从 PDF/Word 等格式提取纯文本,然后做文本清洗(去 HTML 标签、多余空白、URL),接着递归分片,再经过索引处理器做父子分段编排,最后调 embedding API 向量化,分别写入向量库和关键词索引。整条链路由 Celery 异步任务编排,我们在 UI 上看到的"处理中"状态就是这个任务在跑。
在线检索链路——用户提问时,dataset_retrieval.py 同步编排整个流程:先通过路由选择查哪个知识库(如果关联了多个的话),然后用 ThreadPool 并发执行向量检索和全文检索,两路结果去重后送入 Rerank 做融合排序,最后拼接上下文交给 LLM 生成回答。
rag/ 目录下的模块自己不知道执行顺序,它们只是一个工具箱。这个设计理念很清晰——每个模块做好自己的事,编排逻辑交给上层。
逐模块拆解:五个关键设计决策
1. 分片:递归降级策略 + 父子分段
Dify 的分片核心是 RecursiveCharacterTextSplitter,策略是递归降级:先尝试按双换行 \n\n 分割,如果某段还是太长就降级用单换行 \n,还不行就用空格,最后兜底按单个字符切。这样就保证了尽可能在自然段落边界切分。
但真正让我觉得"原来还可以这样"的是父子分段。我的 RAG 2.0 所有 chunk 都是平级的,实际使用中总会遇到一个矛盾:chunk 切太小,检索精准但上下文不够,LLM 回答不完整;chunk 切太大,上下文够了但检索不精准。Dify 的解决方案是调两次 splitter——第一次按大粒度(比如 1024 字符)切出父块,第二次对每个父块按小粒度(比如 512 字符)切出子块,子块通过 children 属性挂在父块下面。检索时匹配小的子块保证精准度,返回时带上整个父块保证上下文完整。
核心就三行:
document_nodes = splitter.split_documents([document]) # 切父块
child_nodes = self._split_child_nodes(document_node, ...) # 切子块
document_node.children = child_nodes # 建立关联
这个思路叫 Small-to-Big,论文里见过,但看到工业级代码真正实现后体感完全不同。
2. 检索:ThreadPool 并发 + 快速失败
混合检索时,向量检索和全文检索的 if 判断是并列的,不是 elif。两个任务同时丢进 ThreadPoolExecutor,谁先跑完先处理谁(as_completed),不用互相等待。
工程上有个细节让我印象深刻:超时 300 秒,任何一路检索报错,立刻取消所有其他任务,快速失败。
for future in concurrent.futures.as_completed(futures, timeout=300):
if future.exception():
for f in futures: f.cancel()
break
我的 RAG 2.0 虽然也用了 FAISS 和 BM25 两路检索,但老实说,错误处理几乎没做。看到这段代码才意识到,工业级代码和练手项目的差距往往不在算法,而是在一些工程细节当中。
3. Rerank:为什么 Dify 选 TF-IDF 而不是 BM25
这是我拆源码最大的"意外发现"。Dify 的加权融合策略 weight_rerank 里,关键词打分用的是 TF-IDF + 余弦相似度,而不是更常见的 BM25。
原因其实很朴素:归一化。TF-IDF 经过余弦相似度计算后,分数天然在 [0, 1] 之间,跟向量检索的余弦相似度尺度一致,可以直接做加权求和(0.7 × 向量得分 + 0.3 × 关键词得分)。如果用 BM25,分数范围不固定,没法直接跟向量得分做加权,还得额外做归一化,多一步处理且不够稳定。
相比之下,我的 RAG 2.0 用的 RRF 融合就绕开了这个问题——RRF 只看排名不看分数(1/(k + rank)),所以对分数尺度天然不敏感。但代价是丢失了分数的绝对信息。
Dify 同时还支持另一种策略:直接调外部 Rerank 模型(Cohere、BGE 等),用 cross-encoder 对每个 query-document pair 做交互式打分。Cross-encoder 比双塔模型更准,因为 query 和 document 是一起输入模型的,能看到两者的交互关系,而不是各自编码后再算余弦。
4. 去重:双保险设计
Dify 在两个地方做了去重:索引时每个 chunk 生成 doc_hash(内容哈希)做标识,检索后 rerank 前 _deduplicate_documents() 再做一次基于 doc_id 的去重。
按理说第一次就够了,为什么还要做第二次?因为混合检索中,向量检索和全文检索可能命中同一个 chunk。第一次去重是索引级别的防重复写入,第二次是查询级别的防重复送入 rerank。双保险,确保同一个 chunk 不会被重复打分从而影响排序公平性。
5. Embedding 缓存:用内容哈希省 API 钱
cached_embedding.py 的设计很实用:不是每次都调 API 重新算 embedding,而是先用内容哈希查缓存,命中就直接返回,没命中才调 API 然后存缓存。同一个 chunk 内容没变,为什么每次都花钱?跟我在 Auto-Tweet-Agent 里用 SHA1 做标题去重是同一个思路——内容没变就不重复处理。
跟我的 RAG 2.0 对比
拆完源码后,我整理了一个对比:
| 维度 | Dify RAG | 我的 RAG 2.0 |
|---|---|---|
| 分片 | 递归降级 + 父子分段 | 固定长度分片,chunk 平级 |
| 向量库 | 适配器模式,支持 30+ 种 | 只用 FAISS IndexFlatIP |
| 检索 | ThreadPool 并发,错误处理完善 | 串行执行,基本没做错误处理 |
| 融合 | TF-IDF 余弦加权 / Rerank 模型 | RRF(k=60) + BGE-Reranker |
| 去重 | 索引级 + 查询级双保险 | RRF 天然处理了部分去重 |
| 缓存 | embedding 缓存(内容哈希) | 没有缓存 |
差距主要在工程成熟度而不是算法。我的 RRF + BGE-Reranker 在算法层面其实不比 Dify 差,但 Dify 在错误处理、缓存、适配器抽象、去重保障这些工程细节上远比我考虑得周全。
三个值得借鉴的设计
父子分段解决了检索精度和上下文长度的矛盾。这个思路我准备在下一次迭代中引入——实现成本不高,就是调两次 splitter 加一个 children 字段,但能显著改善"检索到了但回答不完整"的问题。
Embedding 缓存用内容哈希做 key 省 API 费用。对于频繁更新知识库的场景(比如增量索引),大部分 chunk 的内容其实没变,有缓存的话能省下大量重复调用。
快速失败在并发检索中尤其重要。任何一路超时或报错就立刻取消其他,避免用户干等。这种防御性编程的思维是我之前欠缺的。
最后
这是我"Dify 源码拆解"系列的第一篇,后续准备继续拆 api/core/workflow/ 下的 Agent 模块。如果你也在学 RAG 或者 Agent 开发,希望这篇笔记能给你一些参考。当然,这些并不是什么权威解读,就只是一个在校生的学习记录hh。
如果你对 RAG 的设计取舍有其他见解,欢迎在评论区交流。
顺带一提,目前在寻找 AI Agent 方向的实习机会,如果你的团队在做相关方向,欢迎交流。