RAG 这个词这两年火得有点过头,随便打开一个技术社区,满屏都是"三行代码搭建知识库""零基础 RAG 实战"。但我自己真正把六款开源 RAG 产品拆开揉碎看了一遍之后,最大的感受是:大部分教程教你的那套东西,离能上线跑、能扛住真实业务查询,中间还差着十万八千里。开源项目之所以值得研究,不是因为它们代码写得多漂亮,而是因为它们把踩过的坑都固化成了架构决策——这些决策背后的取舍逻辑,才是自研 RAG 时真正能复用的东西。
这篇文章不打算再给你讲一遍"什么是 RAG",而是想把我从六款开源产品里逆向出来的那套可复用蓝图摊开讲清楚。适合两类人看:一类是已经跑通过 demo、但发现效果不稳定想找根因的开发者;另一类是正准备从零自研一套 RAG 系统、不想重复造轮子的架构负责人。我会把每个模块为什么这么设计、参数怎么定、哪些地方最容易翻车,都尽量说透。
1. 先搞清楚开源 RAG 到底在解决哪些真问题
1.1 从"能跑通"到"能上线"之间隔了什么
很多人对 RAG 的认知停留在"文档切块 → 向量化 → 存库 → 检索 → 拼 prompt → 丢给大模型"这条流水线上。这条链路本身没错,但它描述的是最小可行路径,不是生产可用架构。我拆的第一款开源产品,代码量不到两千行,但光是文档解析层就分了 PDF、Markdown、HTML、表格四种处理器,每种处理器的切块策略都不一样。为什么?因为 PDF 里的双栏排版如果按固定字符数切,会把左右两栏的内容混在一起,检索出来的片段语义是断裂的。
这就是开源产品给你的第一个启发:RAG 的瓶颈从来不在大模型,而在数据进入向量库之前的那段预处理。我见过太多团队把 80% 的精力花在调 prompt 和换模型上,结果检索回来的内容本身就是垃圾,模型再强也救不回来。开源项目因为要面对各种真实文档,被迫在解析和切块上做了大量工程化处理,这部分才是逆向工程最该抄的作业。
1.2 六款产品暴露出的共性架构分层
我把六款产品的目录结构拉平对比之后,发现一个高度一致的分层模式,虽然命名各异,但职责边界惊人地相似:
| 层级 | 核心职责 | 常见实现差异 |
|---|---|---|
| 接入层 | 文档加载、格式识别、元数据提取 | 有的用统一 Loader 抽象,有的按格式硬编码 |
| 切块层 | 语义切分、重叠控制、父子块关系 | 固定长度 vs 语义切分差异最大 |
| 索引层 | 向量化、多路索引、关键词索引 | 单向量 vs 混合索引 |
| 检索层 | 召回、重排、融合、过滤 | 是否带 rerank 是分水岭 |
| 生成层 | 上下文组装、引用标注、兜底 | 引用溯源做得深浅不一 |
这张表看着简单,但每一层的取舍都会向下游传导。比如切块层如果用了固定长度,检索层就必须靠 rerank 来补救语义断裂;如果切块层做了父子块结构,检索层就可以先召回小块再返回大块上下文。理解这种层间耦合关系,比记住任何单个模块的实现都重要。
1.3 为什么"逆向工程"比"照着教程做"更有价值
教程的特点是路径唯一、环境干净、数据规整。但真实业务里,你的文档可能是扫描件、可能是带合并单元格的 Excel、可能是几百页的技术手册。开源产品是在"不完美数据"的假设下设计的,它们的很多"看起来多余"的代码,恰恰是为了兜住这些边界情况。
我举个具体的例子。有一款产品在切块时会对每个 chunk 计算一个"信息密度"指标,密度过低的块(比如全是页眉页脚)直接丢弃。这个逻辑在教程里绝对不会出现,因为它对 demo 数据没有影响,但对真实文档能显著降低噪声召回。这种细节,只有把源码读进去才能发现,也是自研时最该借鉴的地方。
2. 文档解析与切块:决定 RAG 上限的隐形战场
2.1 解析层的格式识别与降级策略
六款产品里,解析层做得最扎实的那款,采用了一个"识别 → 路由 → 降级"的三段式设计。先用文件头特征判断真实格式(而不是信扩展名,因为很多人会把 docx 改名成 pdf),然后路由到对应的解析器,如果解析器抛异常,再降级到纯文本提取兜底。
这个降级策略看起来朴素,但极其关键。我在实际项目里遇到过一批加密 PDF,解析库直接报错导致整个入库流程中断。如果当时有降级兜底,至少能把能读出来的文本先入库,而不是全批失败。解析层的健壮性直接决定了你的知识库能不能持续更新,这一点在教程里几乎没人提。
具体到实现,我建议至少准备三级降级:
- 首选专用解析器(如针对 PDF 的版面分析库)
- 次选通用文本提取(牺牲版面信息保内容)
- 兜底原始字节流扫描(提取可打印字符)
2.2 切块策略的三种流派与适用场景
切块是 RAG 里最容易被低估的环节。我逆向出来的三种主流策略,各有明确的适用边界:
固定长度切块:按 token 数硬切,实现最简单,但会切断句子和段落。适合结构松散、语义连续性要求不高的场景,比如 FAQ 库。参数上,chunk size 一般取 256 到 512 token,overlap 取 10% 到 20%。overlap 不能太大,否则检索时重复内容会挤占上下文窗口。
语义切块:按段落、标题、句子边界切,保证每个块语义完整。实现复杂度高,需要处理各种文档结构的边界识别。适合技术文档、法律合同这类结构清晰的场景。我拆的一款产品用了一个巧妙的办法:先按标题层级切大块,再在大块内按句子相似度做二次切分,相似度骤降的地方就是切点。
父子块切块:这是我认为最值得自研借鉴的设计。把文档切成大块(父块)和小块(子块),子块用于检索(粒度细、召回准),父块用于生成(上下文全、语义完整)。检索时命中子块,返回时替换成对应的父块。这个设计完美解决了"检索要细、生成要全"的矛盾。
提示:父子块结构会增加存储和索引成本,如果你的文档量在万级以下,收益可能不明显;但一旦上到十万级,它对召回质量的提升是指数级的。
2.3 元数据设计:被 90% 教程忽略的关键
几乎所有的开源产品都会在切块时附带元数据,但元数据的丰富程度差异巨大。最简的只存 source 和 chunk_id,最复杂的会存标题路径、页码、时间戳、文档类型、甚至实体标签。
元数据在检索阶段的作用是过滤和加权。比如用户问"2023 年的政策变化",如果你的 chunk 带了时间戳元数据,就能在检索时直接按时间过滤,而不是靠向量相似度去碰运气。我实测下来,加了时间过滤之后,这类时效性问题的准确率能提升三成以上。
自研时我建议至少保留这几类元数据:文档来源、标题层级路径、块在原文中的位置、创建/更新时间。标题层级路径尤其有用,它能在生成阶段帮模型理解这个块在文档中的语境位置。
3. 索引与检索:多路召回才是稳定性的来源
3.1 单一向量检索的天花板在哪里
纯向量检索的问题,用过的人都懂:它对精确匹配无能为力。用户问一个具体的产品型号、一个专有名词、一个错误码,向量检索经常召回一堆"语义相近但完全不是这个"的内容。因为 embedding 模型把语义压成了稠密向量,精确的字符级信息在压缩过程中丢失了。
我拆的产品里,没有一款是纯向量检索的,全部用了混合检索。这个事实本身就说明问题:在生产环境里,纯向量检索是不够用的。教程里之所以常见,是因为 demo 数据的查询太简单,掩盖了这个缺陷。
3.2 混合检索的融合算法与权重调参
混合检索的核心是把向量召回和关键词召回(通常是 BM25 或类似算法)的结果融合。融合方式主要有两种:
加权求和:给两路结果各一个权重,加权后排序。实现简单,但权重需要调。我一般从向量 0.7、关键词 0.3 起步,然后根据 badcase 调整。如果发现精确匹配类问题召回差,就提高关键词权重。
倒数排名融合(RRF):不看具体分数,只看排名,用 1/(k+rank) 累加。k 通常取 60。这个方法的优势是不需要归一化两路分数(向量相似度和 BM25 分数根本不在一个量纲上),鲁棒性更好。我逆向的六款产品里,有四款用的是 RRF。
| 融合方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 加权求和 | 可精细调控 | 需分数归一化,调参成本高 | 两路分数分布稳定 |
| RRF | 无需归一化,鲁棒 | 丢失分数信息 | 通用场景首选 |
3.3 重排模型:投入产出比最高的一环
如果只能在一个环节加预算,我会毫不犹豫选重排(rerank)。检索阶段召回 top 50,重排模型精排出 top 5,这个组合的效果提升非常明显。原因是召回阶段追求的是"不漏",重排阶段追求的是"精准",两个阶段的目标本来就不同。
重排模型的选择上,交叉编码器(cross-encoder)效果最好但慢,适合对延迟不敏感的场景;轻量级的重排方案适合高并发。我实测下来,加一层重排之后,答案的相关性主观评分能从 3.2 提到 4.1(5 分制),这个提升幅度远超换一个更大的生成模型。
注意:重排会增加延迟,如果你的场景要求首字响应在 1 秒内,需要评估重排的耗时。一个折中方案是只对 top 20 做重排,而不是 top 50。
3.4 检索结果去重与多样性控制
多路召回带来一个新问题:重复。同一个内容可能被向量路和关键词路同时召回,也可能因为 chunk 重叠而重复出现。如果不做去重,上下文窗口会被重复内容占满,真正有用的信息反而进不来。
去重的粒度有两种:按 chunk_id 精确去重,和按内容相似度模糊去重。前者简单,后者能处理"同一段话被切成两个块"的情况。我建议两者都做,先按 id 去重,再对剩余结果做相似度去重,阈值设在 0.9 左右。
多样性控制则是另一个维度。有时候 top 5 结果全来自同一篇文档的相邻段落,信息冗余度高。可以用 MMR(最大边际相关性)算法在相关性和多样性之间做平衡,让召回结果覆盖更多角度。
4. 生成层的上下文组装与引用溯源
4.1 上下文窗口的分配策略
检索回来一堆块,怎么塞进 prompt 是有讲究的。最粗暴的做法是全塞进去,但这样有两个问题:一是超出上下文窗口,二是无关内容会干扰模型。
我逆向的产品里,上下文组装普遍遵循"相关性优先 + 位置敏感"的原则。相关性优先好理解,按重排分数从高到低填。位置敏感指的是:放在上下文开头和结尾的内容,模型注意力更高。所以最重要的块应该放在开头或结尾,而不是中间。
具体操作上,我会把 top 1 的块放在最前面,然后按分数递减排列,最后再放一个"总结性"的块在末尾。如果块数太多,中间的低分块可以截断或摘要。
4.2 引用标注的实现细节
引用溯源是 RAG 区别于普通问答的核心价值。用户不仅要答案,还要知道答案从哪来。实现上,需要在 prompt 里给每个块编号,要求模型在生成时标注引用的块号,然后在后处理阶段把块号映射回原文位置。
这里有个坑:模型经常标错块号,或者标了不存在的块号。我的处理办法是在 prompt 里明确要求"只引用提供的块号",并在后处理时校验块号合法性,非法的直接丢弃。另外,可以在生成后做一次引用一致性检查,看答案里的关键实体是否真的出现在被引用的块里。
4.3 兜底策略:检索不到时怎么办
这是最容易被忽略、但用户体验影响最大的环节。检索不到相关内容时,如果硬让模型回答,它就会编。正确的做法是设置一个相关性阈值,低于阈值时直接返回"未找到相关信息",而不是让模型自由发挥。
阈值的设定需要根据你的重排分数分布来定。我的经验是取历史查询重排分数的 30 分位数作为阈值,这样能过滤掉大部分无关查询,又不会误杀边界情况。同时,兜底话术要设计得友好,最好能引导用户换个问法或提供更多信息。
5. 从六款产品里提炼的自研蓝图
5.1 模块划分与接口约定
把前面的分析收敛成一套可落地的架构,我建议按这五个模块划分,模块间用明确的数据结构通信:
- Loader 模块:输入文件路径,输出标准化的 Document 对象(含文本、元数据、结构信息)
- Chunker 模块:输入 Document,输出 Chunk 列表(含父子关系、元数据继承)
- Indexer 模块:输入 Chunk,输出可检索的索引(向量索引 + 关键词索引)
- Retriever 模块:输入查询,输出排序后的 Chunk 列表(含分数、来源)
- Generator 模块:输入查询和 Chunk,输出答案和引用
接口约定的关键是数据结构要稳定。比如 Chunk 对象必须包含 id、content、metadata、parent_id 这几个字段,不管内部实现怎么变,对外契约不变。这样每个模块都能独立替换和测试。
5.2 配置化与可观测性设计
自研 RAG 最容易犯的错是把参数硬编码。chunk size、overlap、top_k、权重、阈值,这些都应该配置化。更进一步,每次查询的中间结果(召回了哪些块、分数多少、重排后排序如何)都应该记录下来,方便排查 badcase。
我建议至少记录这几个指标:检索召回率(人工标注)、重排后 top 1 命中率、答案引用准确率、端到端延迟。有了这些数据,调参才有依据,而不是凭感觉。
5.3 迭代路径:先跑通再优化
最后说迭代节奏。我的建议是分三步走:第一步用固定切块 + 纯向量检索 + 无重排跑通全链路,验证数据管道没问题;第二步加混合检索和重排,提升召回质量;第三步加父子块和引用溯源,优化生成体验。每一步都要有评测集验证,不要一次性把所有高级特性堆上去,否则出了问题根本不知道是哪一层导致的。
这套蓝图不是从某一款产品抄来的,而是六款产品共性部分的提炼加上我自己踩坑后的修正。开源产品的价值不在于代码本身,而在于它们用真实场景验证过的架构决策。把这些决策背后的"为什么"搞明白,自研时就能少走很多弯路。