RAG(Retrieval-Augmented Generation,检索增强生成)最近几乎成了私有知识库问答的代名词。我见过不少人第一天照着教程搭好 demo,第二天就发现模型开始用通用知识“硬答”内部问题,表面上流畅,实际错得离谱。真正让我意识到 RAG 价值的,是一次内部文档问答需求:资料里明明写了明确的制度条款,模型却答得模棱两可,甚至把几条不同政策混在一起编造答案。原因倒不复杂——它没有真正“读”资料,只是被告知要参考资料。RAG 要做的事,是先把资料清洗、切块、索引,再用用户问题去检索相关片段,最后把这些片段作为证据一起交给生成模型,让它回答之前确实有东西可查。这篇文章不打算按框架教程的套路写,而是记录我自己从零构建第一个 RAG 系统时,真正决定成败的环节和踩过的坑。
1. 你以为的 RAG 和实际要解决的差距
很多人在概念上把 RAG 简化成“向量数据库 + 大语言模型”,觉得只要会调用接口就能搭出知识库问答。这种理解不是全错,但它掩盖了真正的问题:一个可用的 RAG 系统,成败不在于单点技术,而在于整条链路上误差的累积。文档清洗差一点,分块不合理一点,检索噪声大一点,最后生成模型都会把它们放大成一次明显的错误回答。
1.1 完整链路的最小划分
先抛开复杂的生产架构,一个最小 RAG 系统也要覆盖四个环节:
- 导入:把原始文档读进来,去掉标记、页眉、目录等干扰信息。
- 索引:将清洗后的文本按一定规则切块,做向量化,连同元数据写入存储。
- 查询:用户问题向量化后,在存储中召回候选片段,必要时做重排。
- 生成:把重排后的片段拼进提示词,让模型依据这些片段作答。
这四个环节里,前两个经常被忽略,却决定了后两个的上限。很多 demo 的问题就出在这里:文档里有答案,但答案所在的片段根本没有被正确索引,或者检索时被更高相似度的噪声片段挤掉了位置,最终模型拿到的是“相关内容”却不是“关键内容”。
1.2 “先跑通”和“能用”的分界线
我建议第一次构建时不要追求完整系统,先做一个最小闭环:选定一批内部文档,做基础清洗和切块,用一个轻量向量存储,接一个大模型接口,跑通十几个固定问题。这里的验收标准不是“能回答”,而是三点:答案是否能在对应文档片段里找到依据;检索返回的前几条是否语义相关;当资料里确实没有答案时,模型能不能老实说不知道。
这个“最小闭环”的价值在于定位问题。如果你的系统在十几个问题上表现很好,但换个问题就崩,往往不是生成模型的问题,而是索引和检索链路的问题。相反,如果检索出来的片段明明正确,答案仍然胡说,那才需要去调整提示词或换更强的生成模型。先跑通,不是为了交差,是为了获得一个可以逐段排查的基线。
2. 第一版数据工程:清洗、分块与元数据设计
我见过不少团队直接把一堆 PDF、Word 丢进解析工具,然后把输出的纯文本全部切块入库。等到线上查询时,检索结果里出现大量“目录”“页眉”“参考文献”,答案自然被带偏。数据工程这一关省不掉,尤其当文档来源复杂时,脏数据会直接污染向量索引。
2.1 清洗规则:先决定“哪些内容根本不配进向量库”
清洗不是把所有文本都变干净,而是主动丢弃那些对回答问题没有帮助的内容。比较常见的需要扔掉的包括:重复的页眉页脚、目录条目、纯装饰性文字、乱码段落、扫描件自带的 OCR 噪声。判断标准很简单:如果一个片段被检索出来后,人类无法从它身上找到任何可用于回答的信息,那它就只是在浪费检索候选位。
以一份制度文档为例,我常用的清洗策略是保留正文段落、条目化条款、表格中可读的说明文字,但去掉封面、目录、无编号的页眉。表格要先判断是否为“问答可依赖内容”,如果表格是核心数据,最好转换成“字段名:值”的文本行,而不是保留换行和竖线格式。这个转换要保留语义,例如“报销时限:30个工作日”,而不是把表格矩阵原样塞进文本,否则向量匹配时很难被问题召回。
2.2 分块粒度:一个我在 512 与 1024 之间反复调整的真实过程
切块粒度没有标准答案,但有规律。我第一次做分块时习惯按固定字数硬切,比如每 1024 字一段,结果发现很多句子被从中间切断,语义残缺。后来改成按语义边界切,优先在段落、句号、问号、分号处断句,再结合字数上限做聚合,效果好很多。
对于中文制度类文档,我比较常用的参数是:单块 600 到 800 字,重叠 100 到 150 字。数字本身不是真理,关键在于重叠。重叠的作用是让跨块被切断的完整语义有机会在多个块中出现,检索时不会因为关键句恰好在切缝处而漏掉。你可以做一个小实验:找一个必须看后半段才能完整理解的段落,分别用“无重叠”和“有重叠”的分块方式入库,然后问一个问题的答案出现在第二段里的查询,观察召回结果差异。大多数情况下,加入重叠后召回稳定很多。
也要考虑块类型。代码片段、表格、长篇引用说明需要单独切块,并在块上打上类型标签。比如chunk_type可以标记为paragraph、table、code。这样做的原因是同一种切块策略很难同时适配所有内容,把类型单独记录后,后续可以做更有针对性的检索过滤。
2.3 元数据:让检索结果自带“上下文说明牌”
很多第一个 RAG 系统只存了文本向量,完全忽略元数据,这是一个很隐蔽的坑。元数据的作用不只是展示来源,它还能参与过滤和在提示词中帮助模型理解上下文。
我建议每一条块至少记录这几个字段:来源文件名、页码或章节编号、块标题、更新时间、权限级别。还是拿制度文档举例,假设用户问的是差旅报销流程,而你有一堆不同部门的制度文件,没有“部门/适用范围”这个元数据,检索结果可能把销售制度和研发制度混在一起。有了元数据,就可以在检索时按权限或业务域过滤,也可以在生成时把来源信息展示给调用方。
这些字段写起来不难,难的是数据准备阶段就要设计好。等系统上线后想再补元数据,通常得重新清洗和索引,成本比一开始就加上高得多。
3. 选型不迷信“全家桶”:向量库、Embedding 与混合检索
第一次做选型时,人很容易被各种宣传带偏,觉得分布式集群、多种检索方式、管道化编排是标配。但一个小型内部系统,真正需要的是易上手、可替换、能解释问题。
3.1 向量库选型:从文件型存储起步的可行路径
如果你的文档量在百万级以内,第一个版本完全可以用轻量级文件型向量存储,而不是一上来就上分布式集群。原因很直接:你的主要目标是验证检索效果,而不是验证架构上限。轻量方案可以本地启动、直接调试,能很快暴露检索策略的问题。
我列过一个简单的选型对比:
| 方案类型 | 适合数据量 | 维护复杂度 | 适合阶段 |
|---|---|---|---|
| 文件型/嵌入式存储 | 小规模(千级块) | 低,进程内即可 | 第一个 RAG 原型 |
| 单机向量数据库 | 中等规模(百万级向量以下) | 中,需要备份和升级 | 有一定真实用户后 |
| 分布式集群 | 超大规模、高并发 | 高,需要专职运维 | 企业级生产环境 |
这不是说轻量方案没有缺点,它的过滤能力和并发写能力都比较弱。但第一阶段你缺的不是性能,而是对检索效果的判断能力。等检索策略稳定了,再迁移到更重的存储,迁移成本其实可控。
3.2 嵌入模型选择:中文场景比你想象的更敏感
嵌入模型决定了文本被映射到向量空间后的语义亲疏关系,选错模型,后面所有检索优化都像在沙子上盖楼。中文场景下,我吃过一个亏:用的模型对口语化查询理解可以,但对正式制度条文里的术语匹配很弱,用户问“报销时限”,检索结果却召回一堆“预算执行”相关片段。
选嵌入模型时,不要只看榜单分数,直接拿自己的文档跑一组自测:准备 10 个真实查询,每个查询从文档里挑 5 个正确答案片段和 10 个干扰片段,然后看模型召回的 Top 3 里有多少个正确答案。这个测试比抽象指标有用得多。另外要注意模型的文本长度上限,如果你的分块经常超过上限,文本会被静默截断,对应关系也不知道。尽量选支持较长输入的模型,否则你的分块策略会被迫让步。
还有一点容易被忽略:不同嵌入模型产出的向量维度、相似度分布不在同一尺度,不要混用。同一个系统里,文档向量和查询向量必须用同一个模型生成,否则相似度没有意义。
3.3 混合检索:关键词与向量不是二选一
向量检索擅长“语义相近”,但遇到精确术语、编号、特殊写法时,它的召回不一定比关键词更好。比如用户问“Q3-2024 报告”,如果相关文档里写成“2024年第三季度报告”,向量模型可能认为是一回事,但如果写成“3Q24”这种缩写,向量模型就不一定了。
所以我建议即便是第一个 RAG,也要做最轻量的混合检索:一路是向量召回,一路是关键词召回(BM25 这类经典算法就够用)。两路结果合并去重后,再统一打分。最简单的融合方法是 RRF(Reciprocal Rank Fusion):对每个文档,统计它在两路排序中的名次,得分是名次倒数的累加值。
score(doc) = sum(1 / (k + rank_i(doc)))其中k通常取 60 左右。这个方案没有调参负担,也不会因为某一路分数分布偏移导致另一路失效。混合检索不一定让每个问题都变好,但能明显减少“明明有精确匹配却召回不到”的情况。
4. 检索链路组装:召回、重排、上下文窗口与提示词
检索链路是从“用户输入”到“模型生成”的中间地带,也是第一个 RAG 系统优化空间最大的地方。很多人把精力放在换更强的生成模型上,却忽略了检索结果只要更准一点,生成质量就会有明显提升。
4.1 召回阶段的 Top-K 根本不该拍脑袋定
我最早实现时直接把 Top-K 设为 3,结果很多情况下前三片语义相关,却都不包含真正的答案。后来意识到,召回阶段的任务是“尽可能不遗漏”,而不是“只给最像的几条”。Top-K 太小,跨章节问题时容易召回不到关键片段;Top-K 太大,生成阶段噪声太多。
推荐的做法是召回阶段取一个较大的候选池,比如 20 条,然后通过重排压缩到 3 到 5 条。这样既保留了“能找到答案”的可能性,又避免了把一堆相似片段全塞给模型。当然这个数字要跟着你的数据分布走,文档块比较碎、答案分布更零散时,候选池可以再大一些。
4.2 重排序:先用一个轻量级模型筛掉无效片段
向量召回本质上是快速粗筛,它对语义相关性的判断是“近似”的。现在很多系统会在召回后接一个重排序模型,它的做法是把查询和每个候选片段拼接成一段输入,逐字计算相关性,比单纯计算两个向量相似度更精细。
重排序最直接的效果是纠正“语义相关但不相关”的情况。举个例子,用户问“申请流程”,早一轮召回里可能全是“申请条件”“申请材料”,文本长度也长,向量相似度不低,但真正描述步骤的段落反而排在了后面。用重排序模型重新打分后,准确率会明显提升。
不过重排序不是免费的,逐条计算耗时会增加。我的经验是先用一个小候选池,比如召回 20 条,只对池内做精排,把延迟控制在可接受范围。注意不要把重排结果当作绝对的“真值”,它同样有误差,但它相对原始向量排序更稳定,尤其适合判断“某个片段有没有直接回答这个问题”。
4.3 上下文组装:避免把“相关但不充分”的片段凑给模型
生成阶段能交给模型的片段数量有限,而且不是越多越好。相关内容塞得过多,模型反而不知道哪些是核心。这里有一条经验:宁可给 3 个高度相关的片段,也不要给 10 个泛泛相关的片段。
组装上下文时,我会保留每个片段的来源编号,比如:
资料 [1]:《差旅报销规定》第三章,部门财务部 资料 [2]:《费用管理制度》第五章,报销时限要求这些来源信息放在提示词里,既帮模型理解片段边界,也方便人工检查答案是否把出处搞混。实际生产时,如果同一文档的相邻两块都出现在候选里,可以考虑在组装时适当去重或合并,避免重复内容占用宝贵的上下文窗口。
4.4 提示词怎么写:给模型一个“引用原文再作答”的任务
很多人第一个 RAG 直接把检索片段拼在“请回答以下问题”后面,剩下的交给模型自由发挥,结果模型依然会借题发挥。提示词应该是任务的约束,而不是背景介绍。
我目前比较常用的模板是:
请依据下面的资料片段回答问题。 资料: [1] ... [2] ... 规则: 1. 优先使用资料片段作为依据,回答时标注片段编号; 2. 资料不足以回答时,直接说明“资料中未找到”,不要推测; 3. 不要使用资料之外的内部信息或背景知识。这个模板看着简单,但它做了三件事:限制模型的信息来源,强迫答案与证据绑定,给模型一条体面的“不知道”退路。你可以在同样检索结果下对比“自由发挥版”和“引用约束版”,后者在可追溯性上会好很多。
5. 把第一个 RAG 变得可度量:测试集与指标
不做评估的 RAG 系统,基本上是在盲调。我见过有些团队上线后靠感觉判断“效果还行”,一旦换一批问题就崩。第一个系统就应该建立一套低成本、可重复的评估方式。
5.1 两类评估:端到端回答质量与检索质量
评估可以拆成两个层面,不要混在一起。
检索侧评估关心“该找的内容找没找到”,常用指标有 Recall@K(前 K 条包含相关文档的比例)和 MRR(第一个相关文档排名的倒数)。生成侧评估关心“回答是否准确、忠实”,常用指标是答案相关性和忠实度。忠实度尤其重要,它衡量答案中的关键论断能否在上游检索片段中找到出处,也就是“是否在瞎编”。
| 评估对象 | 常用指标 | 关键问题 |
|---|---|---|
| 检索链路 | Recall@K、MRR | 正确答案片段有没有进入候选池? |
| 生成链路 | 答案相关性、忠实度 | 答案是否准确且每个关键句有依据? |
一个常见误区是只做端到端人工评价,发现问题却不知道根因在哪。正确的做法是两条链路分别评估:先看检索有没有召回正确片段,再看模型是否在正确片段上生成了正确结论。这样每次 bad case 都能量化到具体环节。
5.2 自己构造测试集的方法(以及“坏问题”的排除规则)
没有现成测试集时,不要直接拿网上语料,最好从真实用户场景里收集候选问题。选材料时有个原则:问题不要抄文档原文。如果你把某个标题改成问句当测试题,系统很容易靠关键词匹配作答,这会高估检索质量。尽量做改写,让测试问题更像真实提问。
我一般会构造三类问题:
- 有明确答案的事实型问题,例如“申请报销需要提前几天提交?”
- 跨章节综合型问题,答案是多个片段拼出来的,例如“新员工入职后,社保和差旅报销分别找哪个部门?”
- 资料中没有答案的反问题,例如“离职后还能报销在职期间的费用吗?” 这时候系统应当给出“未找到”而不是强行回答。
测试集规模不用很大,20 到 50 条足够发现第一版的大问题。但要注意把测试集分成开发集和验证集,避免你边调边看同一批问题而“过拟合”。我的做法是留 30 条做日常调试,另外 10 条只在最终验证时跑,防止自我感觉良好。
5.3 发现“检索失败但答案正确”的常见盲区
最迷惑人的 bad case 是检索结果完全错误,但最终答案看起来正确。原因是模型用自身知识把答案“圆”了回来。这种情况下,端到端评估会给高分,但系统实际上没有在做 RAG,它只是在做一个带抽取干扰的生成任务。
要堵住这个盲区,方法很直接:要求答案必须带引用编号,或者在评估脚本里检查答案文本与给定资料片段是否存在可验证的对齐关系。比如在测试集上强制要求“如果资料没有相关内容,回答必须是资料未找到”。如果一个查询检索失败,模型却给出很具体的答案,直接标记为高风险样本,回到检索侧去修,而不是自欺欺人地认为生成模型够聪明。
6. 从 Demo 到内部可用:缓存、日志与迭代维护
RAG 系统从“能跑”到“能长期用”,中间还隔着一层工程化。很多原型死在没人维护、问题无法定位、数据一变就崩。我在第一个系统中被迫解决过这些问题,整理几个非常实际的抓手。
6.1 缓存:不是照搬 Web 缓存就行
简单缓存相同问题确实能省不少调用费,但 RAG 的缓存容易踩两个坑:一是答案会过期,文档更新后旧回答需要作废;二是错误答案不能因为缓存就长期存续。
我建议至少做两层缓存。第一层,相同的问题文本加索引版本号后,在短时间内直接返回历史答案,适合高频重复问题。第二层,缓存检索候选列表,不缓存最终答案,这样可以在文档没有变化的前提下,让新的生成模型快速重新生成。要注意的是缓存键里最好携带用户权限维度,避免不同权限的用户共享到本不该看到的片段。
文档更新后,我会对更新过的文件重新切块和向量化,并把缓存版本号加一。这个机制比定期全量重建简单得多,起步阶段完全够用。
6.2 日志里要能看到哪些信号
生产环境的日志,是看不见的测试集。我要求每个查询至少记录:用户问题、召回的候选列表及排序分、重排后的最终片段、答案文本、答案引用的片段编号、耗时。不一定要全量持久化,但至少保留最近一段时间内的记录,供 bad case 复盘。
有一条特别实用:把“模型未引用任何片段”的回答单独标记出来。我在早期从日志中筛出了一批这类案例,发现它们几乎都是检索质量差,而不是提示词问题。没有这层日志,你很难确定优化方向。另外,日志里要对“文档版本”做关联。同一个问题在不同版本下的答案差异,最能暴露索引同步问题。
6.3 一个小技巧:把“用户不点反馈”的答案回流成测试用例
很多系统做了用户点赞/点踩按钮,但数据躺在数据库里没人看。其实最有效的闭环是把“不认可的答案”直接加入测试集,回归跑一遍。这一步成本很低,收益却很大:每一个 bad case 都是未来改动后的“锚点”,保证优化不会倒退。
我会为每个回流样本记录三类信息:原始问题、实际答案、可能原因(检索遗漏、排序错误、生成跑偏)。下次改完分块策略或重排模型后,把全部历史 bad case 重跑一遍,看修复比例是多少。这比任何抽象指标都更能说明问题。
迭代本身也是这样:先修数据,再调检索,最后才动生成。顺序反了,经常白忙一场。
第一个 RAG 系统真正难的不是跑通,而是让它每次回答都能拿出站得住的依据。我个人的体会是,先把数据清洗和评估测试集做扎实,再去追新工具并不迟。你第一次构建时踩过的那些坑,几乎都是宝贵的判断依据,把它们变成可回归的测试用例,系统才能越用越稳。