先说结论:RAG项目“看着能跑,一上量就废”的真相,往往不在大模型,而在检索链路。
做自研RAG的人,早晚会对“开源项目”产生一种复杂情绪。一方面知道有那么多现成产品可以参考,另一方面又觉得LangChain太散、LlamaIndex太绕、RAGFlow太重、Dify又什么都想做——到底该抄谁?我花了大概三周时间,把六款主流开源RAG产品从头到尾翻了一遍:LangChain、LlamaIndex、Haystack、RAGFlow、Dify、QAnything。目的很直接:从这些项目里逆向工程出一套能用于自研的可复用蓝图。这篇文章就是那三周的复盘,包括我拆了什么、学到了什么、最后沉淀成什么结构,以及按这套蓝图落地时真实踩过的坑。
1. 选型标准:为什么是这六款,以及“逆向工程”到底在逆什么
1.1 我挑产品时定的四个参考条件
在开始拆解之前,我先给自己定了几个标准,不然很容易变成“哪个火就拆哪个”,最后什么也没沉淀下来。我的标准是:
- 覆盖不同架构流派。不能六款全是同一类框架,要能覆盖编排型、组件型、平台型和检索重型的不同路线。
- 有可阅读的源码。闭源产品只能观察行为,没法真正理解内部设计。
- 有足够的社区讨论和文档。不然遇到问题没人帮你确认理解对不对。
- 在真实生产环境被验证过。不能是只有demo的玩具项目。
基于这四点,最终选定了六款:
| 产品 | 定位 | 我拆它的理由 |
|---|---|---|
| LangChain/LangGraph | 编排框架 | 生态最大,绝大多数人入门RAG的第一站,值得看它是怎么把流程“串”起来的 |
| LlamaIndex | 索引框架 | 把“索引”这件事做到极致,数据模型设计很有参考价值 |
| Haystack | 组件化框架 | deepset出品,Pipeline抽象非常干净,适合学习模块边界划分 |
| RAGFlow | 知识库平台 | Infinity团队出品,凭文档深度解析杀出重围,面对PDF表格极有价值 |
| Dify | LLMOps平台 | 知识库与编排深度结合,最有“产品感”,适合学习人机交互边界 |
| QAnything | 检索开源项目 | 网易有道出品,两阶段检索策略极其务实,工程参考价值高 |
这里得解释一下“逆向工程”这个词。我说的不是反编译或者破解闭源软件,而是指:基于开源代码和公开架构文档,通过阅读源码、分析模块依赖、梳理数据流,反向推导出产品的设计逻辑和取舍理由,再提炼成适合自己项目的方法论。开源项目的源代码本来就可以看,所以这是一件合法而且效率极高的事,重点在于你怎么从海量代码里抽出真正可复用的那部分。
1.2 拆解之后最大的一个认知转变
拆完这六款产品,我脑海里原有的“RAG = 向量数据库 + 大模型”这个公式被彻底推翻了。市面上一大半自研RAG项目的失败,根本不在于生成侧,而在于检索侧;检索侧的失败,又往往是从文档解析和分块这两个“最前端的脏活”开始的。
举个最典型的例子:一份简历PDF里有表格、页眉页脚、多栏内容,大多数项目直接按PDF页数切块,结果表格被切得七零八落,检索时根本召不回有效信息。你后面大模型再强也没用,因为喂给它的上下文里压根没有正确答案。开源产品之所以看着复杂,不是因为它们喜欢堆代码,而是因为它们真的在和这些“脏问题”搏斗。
2. 逐家拆解:六款产品各自的核心设计和给我的启发
2.1 LangChain/LangGraph:编排层的极致,但也是最容易“用乱”的
LangChain做到了什么程度?它把加载、切分、向量化、检索、提示词组织这些环节全部抽象成了组件,然后用Chain或LangGraph的状态图把它们串起来。我从它身上学到的最有价值的东西有两块:
第一是文档加载器的抽象方式。LangChain定义了BaseLoader接口,统一的load()方法,接PDF、接网页、接数据库都在这个接口下做实现。自研时我用同样的思路抽象了一个load(source) -> List[Document],后面接什么数据源都往里加实现类就行,业务层完全不用改。
第二是索引去重机制。LangChain的Indexing API引入了RecordManager,通过内容Hash来判断文档是否已经入库,避免重复索引。这个思路对自研太重要了——你不可能每次跑任务都把全部文档重新入库一遍。
但LangChain的问题也很明显:组件颗粒度太细,自由度太高,导致同样的需求有一百种写法。用来做原型验证确实爽,但自研时把它的“链式编排”整个搬过来,项目会很快失控。所以我的结论是:学它的组件抽象,别学它的流程组织。
2.2 LlamaIndex:索引模型的设计值得逐行研究
LlamaIndex给我的启发集中在数据模型上。它定义了很清晰的层级:Document(原始文档)→ Node(切分后的最小检索单元),每个Node自带独立的元数据。这个设计让我意识到:检索的最小单元不应该是一个半路切出来的“字符串碎片”,而应该是一个有身份、有属性、可追踪的数据实体。
LlamaIndex把索引分成很多种类:VectorStoreIndex(向量索引)、SummaryIndex(摘要索引)、KeywordTableIndex(关键词表)、DocumentTreeIndex(文档树)等等。我在自研时不可能也不需要全抄,但这个“不同场景用不同索引结构”的思路非常值得保留。比如处理一个短问答场景,用向量索引就够了;处理一份长报告需要做全局概括时,摘要索引反而更合适。
它还有一个很经典的层级:Retriever + PostProcessor(检索器 + 后处理器)。检索负责召回,后处理器负责过滤、重排、去重、压缩。自研的时候我把这个层级完全借鉴了过来,后面所有检索策略的调整都在这两层里做,不会动到检索内核。
2.3 Haystack:组件化做得最干净,Pipeline边界值得细品
Haystack 2.x的核心是Pipeline。所有的能力——读取文档、切分、嵌入、检索、生成——都是独立的组件,然后用一套明确的管道定义把它们组合起来。我看到它支持用YAML文件定义整条检索流程的时候,确实眼前一亮。这意味着什么?意味着流程是数据,不是代码。
这对自研的启发很大:你在做系统重构或者对接不同模型供应商时,不需要改代码,只需要改管道配置文件。我在自己的蓝图里保留了类似的思路,但做了一个简化:核心检索流程用代码串好,但“用的什么Embedding模型、走什么检索策略、召回几条”这些参数全部配置化。为什么简化?因为完全YAML化需要一套解释器,那是另一个大工程,自研阶段没必要。
Haystack另一个值得学习的是它对DocumentStore的抽象。它定义了统一的文档存储接口,你在本地用内存、Elasticsearch、Qdrant还是PostgreSQL向量插件都可以,上层不用改。自研时我给自己的存储层也定了类似的接口:上层只调add、delete、query,底层存储随便换。
2.4 RAGFlow:文档解析才是RAG真正的护城河
拆RAGFlow是这次最受冲击的一次。它把几乎所有精力都花在了“把非结构化文档变成结构化数据”这件事上。它用DeepDoc做版面分析、OCR识别、表格结构还原,然后基于版面结构做切分,最后还能在回答时展示引用来源。
有人说RAGFlow是“重数据派”的代表,我非常认同。同样是处理一份带表格的PDF,普通项目切出来的Chunk是乱的,RAGFlow切出来的是有层级结构的块,甚至能把表格还原成Markdown格式。这一步的差距,直接决定了后面检索和生成的准确率。
我从RAGFlow里学到的核心思路有三条:
- 解析文档之前,先做版面分析,区分标题、正文、表格、页眉页脚。
- 切分不按固定页数切,而是按文档结构切。
- 回答必须带引用来源,让用户能核查,也让bad case回填有据可依。
不过RAGFlow重也重在这套解析体系上,部署和学习成本不低。自研的时候,我不可能一上来就自己做一套DeepDoc,但可以先接入成熟的解析引擎,同时在架构上留好解析器接口,后续再替换。
2.5 Dify:产品层的RAG体验设计,调参比改代码更值钱
Dify本质上是一个LLMOps平台,它的RAG能力被封装在“知识库”这个功能模块里。我拆Dify更多是带着产品视角拆的:它怎么设计分段模式、怎么设计检索模式、怎么让非技术人员也能做召回测试。
Dify的分段模式有“经济模式”和“高质量模式”之分,我印象很深。经济模式是简单按长度切,高质量模式是识别标题层级来做结构切分。这种设计让我意识到:RAG系统不能只有一套切分策略,应该根据文档类型和业务需要,允许用户选不同模式。
它的检索模式也分了几种:向量检索、全文检索、混合检索。Dify的混合检索是把向量召回和关键词召回的结果做融合。这个设计让“调参数”替代了“改代码”,产品力一下子不一样了。
拆Dify给我最大的启发,不是某一个具体技术,而是:RAG系统最终是给人用的,你需要一套面向使用者的配置界面、召回测试机制和效果评估流程。很多自研项目死掉,不是因为技术不行,是因为没有让使用者自己验证和改进的手段。
2.6 QAnything:两阶段检索的工程范本
QAnything是有道开源的项目。它的核心检索策略很直接:先向量召回粗筛,再用Reranker精排。听起来不复杂,但工程上的干净程度非常值得学习。
两阶段检索的思路大概是这样:
- Embedding模型把问题和文档块都转成向量,用向量相似度召回top-K。
- 召回的结果不直接进大模型,而是先经过一个Reranker(通常是一个跨编码器模型)做精排,把真正跟问题相关的内容排到前面。
- 根据精排结果截断上下文,再交给大模型生成。
为什么非要这一步?因为Embedding检索的相似度只是“大致相关”,它会把主题相似但没解决具体问题的段落排在前面。而Reranker是基于问题和段落逐对打分,相关性判断要精确得多。代价是速度慢,但因为只对召回的那几十条做精排,速度完全可接受。
我从QAnything学到的就是这条**“粗召回+精重排”的标准流水线**。自研RAG想要效果好,这条链路几乎不能省。
2.7 六款产品放在一起对比,共性自然浮出来
拆完之后,我把六款产品的架构共性列了一张表:
| 核心关注点 | LangChain | LlamaIndex | Haystack | RAGFlow | Dify | QAnything |
|---|---|---|---|---|---|---|
| 文档加载 | 有抽象 | 有抽象 | 有抽象 | 强解析 | 较强 | 基础 |
| 切分策略 | 基础 | 多样 | 基础 | 结构切分 | 多模式 | 基础 |
| 索引设计 | 弱 | 强 | 中 | 中 | 中 | 中 |
| 多路召回 | 支持 | 支持 | 支持 | 有限 | 混合检索 | 两阶段 |
| 重排器 | 插件化 | 插件化 | 插件化 | 有 | 有 | 核心 |
| 评测回流 | 弱 | 弱 | 弱 | 弱 | 有 | 弱 |
| 适合借鉴 | 组件抽象 | 数据模型 | 模块边界 | 解析链路 | 产品化 | 检索流水线 |
六家产品,没有任何一家在全部环节都强。但把它们叠加起来看,一套自研RAG该有的核心模块,全部都出现了——这就是我想要的逆向结果。
3. 共性倒推:自研RAG必须保留的六个核心模块
3.1 数据接入与解析层
所有产品第一阶段做的事都一样:把外部数据变成内部统一的Document对象。差别在于,做得好的项目(RAGFlow)在这个阶段会花大量功夫做格式识别、表格还原、去噪,而不是简单把文件读出来就完事。
自研复盘里,我把解析层单独拎出来,定了三个收口标准:
- 支持多格式:PDF、Word、Markdown、TXT、HTML,未来可能要加图片和音频。
- 统一文档模型:解析完统一输出带页码、来源、版面类型的结构化文档。
- 错误透明:解析失败的文档要有日志,不能静默丢弃。
3.2 切分策略层
“怎么把文档切成小块”决定了检索的下限。常见的策略有:按固定长度切、按标题结构切、按语义边界切、父子切分(父块用于上下文,子块用于检索)。六款产品里没有哪款只用固定长度,至少都是“固定长度+重叠+结构感知”的组合。
我在蓝图里定的原则是:优先按结构切,结构不清晰时按长度切并保留重叠。文档解析阶段如果已经拿到了标题层级,就用层级做切分边界;纯文本流文档才退回到固定窗口。
3.3 索引与存储层
核心存储是向量数据库,但自研时不要忽略元数据存储和全文索引。六款产品都支持在向量化之外保留原始文本和元数据。因为向量检索做不到精确命中(比如查一个精确的文档编号),这时候需要关键词检索来兜底。
蓝图里的存储分成三层:
- 向量库:存Embedding,负责语义召回。
- 全文索引:存关键词倒排,负责精确匹配和BM25召回。
- 元数据库:存文档、Chunk、切分策略的对应关系,方便溯源和删除。
3.4 检索与重排层
这一层我从QAnything和LlamaIndex里各取了一部分。整体链路设计成:多路召回 → 融合 → 重排 → 结果输出。多路召回合可以是“向量召回+关键词召回+元数据过滤召回”同时跑,然后按分数融合;重排则用独立的Reranker模型对候选做二次精排。
这里有个容易被忽视的设计要点:检索输入的Query,在召回前一般要做改写和扩展。用户问“文档里说的那个项目最后延期了没”,直接拿这句话去向量检索不会太好,因为“那个项目”没有实体指向。成熟做法是先让大模型把问题改写成更明确的短问题(比如提取出项目名),再去检索。这一步在六款产品里,有的放在检索器内部,有的放在工作流里,但确实都存在类似设计。
3.5 提示词编排与生成层
生成层不是简单把检索结果拼接起来扔给大模型。几点经验是拆完Dify和LangChain之后才确认的:
- 上下文要控制数量,不能用“把top-20全塞进去”这种策略,一般top-5到top-10,超过之后token暴涨、答案质量反而下降。
- 引用来源不可省。回答带
[1]、[2]来源编号,用户才能核查,后续也才能定位bad case。 - 系统提示词里要明确“只基于给定上下文回答,没有相关内容就直说不知道”。这是防止大模型胡编的最后一道闸。
3.6 评测与反馈回流层
自研项目中,这个模块常常被直接砍掉。但拆Dify让我坚定了一个判断:没有评测体系的RAG系统,等于闭着眼睛开车。你根本不知道哪次改动让效果变好还是变坏。
评测层至少要包含:一批标注过的测试集(问题和标准答案)、一批检索评测指标(召回率、命中率、MRR)、一批生成评测指标(忠实度、回答含金量)。同时要把线上的bad case日志存下来,定期回填进测试集。
4. 自研蓝图的落地结构:模块边界、数据模型和接口设计
4.1 整体结构
把六款产品的共性抽完之后,我整理出的自研蓝图是一个管道式结构,但每个模块相对独立:
- 入口:统一了所有文档源的接入接口。
- 解析器:按文档类型分发,输出结构化Document。
- 切分器:按策略把Document切成多个Chunk。
- 索引器:负责Chunk入库,同时生成元数据索引。
- 检索器:支持多路召回、融合、重排。
- 生成器:以检索结果为上下文,调用大模型生成回答。
- 评测/回流:负责记录日志、bad case、评测指标。
这条管道看起来跟LangChain很像,但最大的区别是:每个模块的接口极简,模块之间不共享状态,只传标准化的数据结构。这样任何一个模块都可以独立替换或升级。
4.2 数据模型定义
自研时我定义了三个核心数据类(用Python伪代码表示):
@dataclass class Document: doc_id: str # 唯一ID source: str # 来源(文件路径/URL/数据库名) format: str # 文件格式(pdf/docx/md) content: str # 解析后的纯文本内容 structure: dict # 版面结构信息(标题层级、段落、表格) meta: dict # 业务元数据(所属部门、日期、权限等) @dataclass class Chunk: chunk_id: str # 唯一ID doc_id: str # 所属文档 content: str # 切分后的文本块 seq_in_doc: int # 在文档中的顺序 heading_path: str # 标题路径,如"3.2 索引与存储层" meta: dict # 继承自文档元数据 + 切分信息 @dataclass class RetrievalResult: chunk: Chunk score: float # 融合后的最终分数 source_scores: dict # 每路召回的分数,便于debug这个数据模型是借鉴了LlamaIndex的Document/Node设计,同时吸收了RAGFlow的版面结构概念和Haystack的查询结果抽象。每个Chunk都不是孤立的文本碎片,它知道自己属于哪篇文档、位于哪个标题之下、有哪些业务标签。
4.3 各模块的接口收口
接口设计我遵循一条原则:宁可每个接口多传一个普通参数,也别把它做成模块间共享的全天变量。典型接口如下:
# 解析器接口 def parse(file_path: str) -> Document: ... # 切分器接口 def split(doc: Document, strategy: SplitStrategy) -> List[Chunk]: ... # 索引器接口 class IndexStore: def add_chunks(self, chunks: List[Chunk], embeddings: List[list]) -> None: ... def delete_by_doc(self, doc_id: str) -> None: ... def query(self, query: str, top_k: int, filter: dict) -> List[RetrievalResult]: ... # 检索器接口 def retrieve(query: str, top_k: int) -> List[RetrievalResult]: ... # 支持多路召回 + 融合 + 重排,对外暴露的只是一个普通方法 # 生成器接口 def generate(query: str, retrieval_results: List[RetrievalResult]) -> Answer: ...为什么把这些接口做得这么薄?因为拆完六款产品之后,我深刻意识到:RAG系统最大的风险不是某个算法不行,而是模块之间的耦合过于复杂,导致你根本没法单独升级某个环节。接口薄,替换成本就低,这是应对快速变化的大模型生态的核心策略。
4.4 技术选型:不是每块都需要自研
根据蓝图落地时,我建议按“核心自研、次要集成”的原则来选型:
| 模块 | 自研还是集成 | 推荐方案 | 理由 |
|---|---|---|---|
| 解析器 | 先集成后自研 | PyMuPDF + 结构化解析引擎 | 解析涉及大量版面识别细节,短期自研不划算 |
| 切分器 | 自研 | 结构切分 + 固定窗口回退 | 逻辑简单,自定义需求多 |
| Embedding | 集成 | BGE系列 / 云厂商向量化接口 | 效果差距不在模型,在适配和评测 |
| 向量库 | 集成 | Milvus / Qdrant / ES向量检索 | 自研向量库纯属重复造轮子 |
| Reranker | 集成 | bge-reranker系列 | 自研交叉编码器成本极高,效果还不一定好 |
| 编排层 | 自研 | 自己写管道 | 用LangChain容易失控,自己的管道最可控 |
| 评测回流 | 自研 | 自定义数据集 + 评测脚本 | 这是一个长期积累的过程,必须自研 |
这里多说一句Embedding选型的心得:你不需要找到“最好的Embedding模型”,你需要的是“对你自己数据召得最准的模型”。拿一批真实文档检索任务,把主流Embedding模型都跑一遍,看召回效果再决定用谁,千万别只看排行榜。
5. 落地蓝图后的五个真实坑:从教训里重新理解设计
蓝图归蓝图,真正落地的时候,我至少有五个坑是踩得很结实的。写出来,也等于给这份蓝图打个补丁。
5.1 PDF表格识别,是所有环节里最值得砸钱的地方
第一个让我崩溃的场景是表格PDF。产品文档里大量的对比表、参数表、指标表,普通的解析策略把它们当成普通文本流,切出来的Chunk就是一堆“1”“2”“列名”“数值”的乱序排列。检索的时候,你问“这个型号支持的最大带宽是多少”,根本召不回那一行。
后来我按RAGFlow的思路调整了策略:解析PDF时先做表格区域检测,把表格整体提取出来并转成Markdown表格或HTML表格,再作为独立Chunk入库。这样检索时,表格作为一个整体被召回,大模型才可能读懂“这个字段在这一行这一列”。在RAG里,表格不是文本的附属品,它是一等公民。
5.2 切分策略和检索效果的关系,需要用数据说话
一开始我拍脑袋选了个“固定512字符、重叠128字符”的配置,结果发现某些文档召回效果特别差。后来做了切分策略评测,才发现问题出在:这份文档里有大量小标题和短段落,固定切分把标题和正文拆到了不同的Chunk里,检索时召回了正文却没召回标题,上下文信息严重不足。
解法是引入“父子切分”的思路:小粒度去检索,大粒度去生成。也就是说,检索时用短Chunk(比如256字符)做匹配,但拿到结果后,给它补充父级段落或整段标题路径作为上下文再送进大模型。改完之后,这类文档的召回命中率明显提升。切分策略不是拍脑袋定的,要做一组AB对比再定。
5.3 元数据过滤能救半条命,但前提是入库时就写好
第二个崩溃场景是权限问题。我们有多部门的文档,用户问问题时,应该只能检索到他们权限范围内的内容。一开始向量库的过滤条件形同虚设,因为入库时压根没给Chunk打上部门标签。后来重新梳理了Doc的元数据体系,在入库阶段强制要求每条Chunk至少带上部门、密级、来源文档三个字段,检索阶段再叠加过滤条件。与其在检索端想各种办法过滤,不如在入库端就把元数据打全。
5.4 混合检索不是可选项,是必选项
向量检索有它的盲区:精确匹配和短实体召回。比如用户搜“编号NO.2024-007”或者某个API的错误码,纯向量检索几乎召不回精确结果,因为语义相似和字符相似是两码事。后来我在检索层加上了BM25关键词召回,和向量召回做结果融合。混合检索的效果不是“锦上添花”,而是把召回率的下限兜住。尤其是在技术文档、代码报错、型号编号这类内容上,关键词匹配往往比语义匹配更可靠。
5.5 没有评测系统之前,所有优化都是赌博
最惨痛的教训是:有一次我优化完解析策略,觉得效果一定变好了,结果一上线,整体效果反而下降。原因是我把“层级的表格识别”做强了,但分块的粒度变了,导致某些长文档的召回失衡。这时候我才意识到,没有一套固定的评测集,你根本没法判断改动到底是在优化还是退化。
后来我建了一个小规模的评测集:200个问题,每个问题对应至少一条标准答案内容片段。每次改动之后跑一遍,看召回命中率和回答忠实度。没有这套东西,后面所有的调优都是摸黑走路。
6. 源码阅读顺序:从哪款读起,怎么“抄”最省力
6.1 给想自研的人一条阅读路径
如果时间有限,不建议六款产品挨个精读。我的建议路径是先按角色分工来读:
- 想搞清楚检索链路怎么串:先读QAnything的核心检索部分,它的链路短、清晰,适合建立整体印象。
- 想搞清楚数据模型怎么设计:读LlamaIndex的Document/Node定义,这是元数据设计的范本。
- 想搞清楚解析链路怎么做重:读RAGFlow的解析流程,重点看它是怎么处理表格和版面结构的。
- 想搞清楚模块边界和可替换性:读Haystack的Pipeline定义和组件接口。
- 想搞清楚产品化的配置和体验:读Dify的知识库模块,看它如何把参数暴露给使用者。
- 想搞清楚流程编排和组件适配:最后再看LangChain,它的生态适合你遇到具体的集成需求时再查,而不是一开始就深入。
6.2 “怎么抄”的能力框架
我最后想说的是:抄开源,不是把代码down下来改个名,而是把你的问题和它的解法放在一起,弄清楚它为什么这么设计,边界条件是什么,你缺什么,它的方案在你这里失效可能是什么原因。把“它为什么这么写”想清楚,自研的蓝图才算真正属于你自己。
拆完这六款产品,我个人最深刻的一个体会是:没有一款开源项目能直接满足你的业务,但每一款都能在某一个环节给你很实在的启发。RAG不是一个“搭积木”的一锤子买卖,它是一个需要持续调优的活系统——文档在变、用户在变、模型在变,所以你的架构必须留出“每个模块都能独立演进”的空间。这也是我在整个逆向工程过程中,拿到的最值钱的一份蓝图。