ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Demo到生产级RAG:六大关键分水岭解析

从Demo到生产级RAG:六大关键分水岭解析

RAG 这个词,这两年确实被说烂了。随便一个技术群里,都能看到有人甩一个仓库链接,说“十分钟搭好你的知识库问答”。打开一看,核心逻辑几乎一模一样:读文档、切成小段、向量化、塞进向量库、检索、拼 Prompt、丢给大模型。这套流水线,GitHub 上一搜一大把,跑通一个 demo 真的不难。但你要是真拿它去处理点实际业务,比如公司内部几百份制度文档、产品手册、技术规范,马上就会发现问题——答非所问、引用张冠李戴、问深一点就胡编。不是 RAG 没用了,而是你做的是那条流水线,不是真正的 RAG 系统。

我自己从最早用 LangChain 搭 demo,到自己写检索模块,再到给团队做知识库基线,折腾了不少轮。回头看,真正拉开差距、决定一个 RAG 能不能从“玩具”变成“工具”的,从来不是那条流水线,而是流水线之外、甚至之内的六个地方。这六个地方,我踩过的坑比写过的代码还多,今天一条条拆给你看。这篇东西适合两类人:一类是已经跑通 demo、但觉得效果“不行”的人,照着这几处去排查和改,大概率能上一个台阶;另一类是准备正经上知识库项目的,先看完这六处,至少能避开我当年绕了半年才绕出来的坑。

1. 文档解析:第一道真正的分水岭

绝大多数人做 RAG,第一步就是从 PDF 或者 Word 里把文本抽出来,然后直接切块。我之前也这么干,直到有一次被一份带复杂表格的年度报告整到怀疑人生——检索出来的片段,表格里的数字全乱了,答案自然也是错的。从那时候起我才意识到,解析这一步,才是 RAG 真正开始分高下的地方。

1.1 文本抽取不等于文档理解

你用 pypdf 或者 pdfplumber 把 PDF 里每一页的文字抠出来,看起来是“抽到了文本”,实际上你可能丢掉了很多东西:标题的层级、列表的结构、表格的行列关系、图片里的信息、页眉页脚和正文的区分。这些信息对纯阅读来说无所谓,但对 RAG 来说至关重要——因为检索是对“语义单元”做匹配,而一个语义单元往往不是从第 3 行开始、到第 7 行结束那么机械。

举个具体的例子。一份产品说明书里,有一张“故障代码对照表”,三列:故障码、现象、处理建议。用普通抽取方式,这张表会变成一长串用空格或者 Tab 分开的字符串。你把它切成 4 个 chunk 之后,很可能出现这种情况:第一个 chunk 里有“E101、电机过热、检查散热”,第二个 chunk 里有“E102、过流保护、检查负载”。用户问“E101 怎么处理”,向量检索确实能召回第一个 chunk,看起来没问题。但如果用户问的是“电机过热有哪些故障码”,系统要去哪里找?如果表格结构没有被正确解析,跨 chunk 的信息就很难拼起来。我后来的做法是,把表格转成 Markdown 格式的文本再入库,保留表头和行列关系。这样“故障代码对照表”作为一个整体被索引,检索效果立刻不一样。

再一个容易被忽略的是扫描件 PDF。你直接抽文本,抽出来一片空白,因为那是图片不是文字。我见过不少团队把这种 PDF 硬塞进知识库,然后抱怨“为什么我的 RAG 什么都答不出来”——不是 RAG 的问题,是文档从来没被真正“读懂”过。这种情况必须先做 OCR,而且在切块之前做,因为 OCR 的输出质量直接决定后续所有环节的天花板。

1.2 网页结构怎么提取,直接决定 wiki 类知识库能用多久

热搜词里有人提到“wiki 和 RAG”,这其实是个很经典的应用场景。不少团队拿内部 wiki 或者帮助中心做知识库,但 wiki 页面不是纯文本,它有导航栏、面包屑、相关推荐、评论区。你要是整页抓下来,一个 chunk 里一半内容都是无关的导航和广告,检索质量能好才怪。

正确做法是拿结构化的抽取工具,比如 trafilatura 或者 readability,先提取正文主体,再根据 HTML 里的标题标签(h1、h2、h3)来保留文档的目录结构。我之前做过一个帮助文档站点的知识库,用 BeautifulSoup 写了一个提取脚本,规则很简单:去掉 nav、footer、aside 这几个标签里的内容,把 main 区域里按 h1/h2/h3 做树形切分,h2 作为一级标题,h3 作为子节标题,然后把标题拼到正文前面。这样一个 chunk 的自包含性会强很多,因为用户问“怎么重置密码”,那个小节标题“重置密码”本身就出现在 chunk 里,检索的时候相似度分数会高不少。

关于这段解析代码,供参考:

from bs4 import BeautifulSoup def extract_article(html): soup = BeautifulSoup(html, 'html.parser') # 剔除无关注释区域 for tag in soup(['nav', 'footer', 'aside', 'script', 'style']): tag.decompose() main = soup.find('main') or soup.body or soup sections = [] current_section = None for elem in main.descendants: if getattr(elem, 'name', None) in ('h2', 'h3'): current_section = {'title': elem.get_text().strip(), 'content': []} sections.append(current_section) elif elem.name == 'p' and current_section is not None: text = elem.get_text().strip() if text: current_section['content'].append(text) # 后续可以按 section 为单位切 chunk,而不是按纯文本长度硬切

解析是整个 RAG 的地基,地基没打好,后面再花哨的检索和重排都救不回来。这句话我在不同场合说过很多遍,但真正吃透的人不多。

2. 切块策略:不是数着字数切,是切出语义边界

切块(chunking)是 RAG 里看起来最简单、实际最讲究的一步。很多教程给个固定值,比如每 500 个字符切一块、重叠 50 个字符,就算完事了。这种“数着字数切”的方式,只能应付那些随便测测的场景。真到实际知识库里,你会遇到一段代码横跨两个 chunk、一句结论被拦腰切断、表格被从中间劈开这些破事。每个问题都会让召回质量肉眼可见地变差。

2.1 固定窗口为什么天然有缺陷

固定窗口切块的问题在于:它完全不理解内容的语义边界。举个例子,你在切一份技术方案,突然来了一句:

“因此,我们决定采用方案 B。方案 B 的优势在于……”

这句话的前半截在一个 chunk 里,后半截在下一个 chunk 里。如果用户的问题是“为什么选方案 B”,那么包含“因此,我们决定采用方案 B”的那个 chunk,它只有结论没有理由;包含“方案 B 的优势在于”的下一个 chunk,有理由却没提“因此”和“采用”。向量检索的时候,两个 chunk 跟这个问题的相关度都被稀释了。你调什么 embedding 模型都救不了这个。

后来我学到一个比较实用的技巧:用结构信息切分,而不是用字数切分。逻辑很简单:先按标题层级把文档切成多个部分,如果某个部分太长(比如超过了模型上下文的一半),再在这个部分内部,按照段落、句子进一步切,并保留小标题前缀。这样每个 chunk 基本对应一个“最小完整主题”,而不是一段“刚好 500 字的连续文本”。

当时处理一份 30 页的官方白皮书,我用了这样的层级切分逻辑:

  1. 解析出文档的所有标题及其层级(h1 → h2 → h3)
  2. 每个三级标题下的内容,单独作为一个候选 chunk
  3. 如果这个候选 chunk 过长,就在句子边界处切分,同时给子 chunk 补上前缀(“一级标题 > 二级标题 > 三级标题”)
  4. 如果候选 chunk 过短(比如只有一句话),就与相邻标题下的内容合并

这样切出来的 chunk,每一块的语义完整性都远好于固定窗口。检索时能召回的,往往是一段完整的论述,而不是被拦腰斩断的半句话。

2.2 重叠窗口和父子 chunk 的取舍

重叠(overlap)是固定切分的补救措施,它保证切割线附近的内容不会完全丢失呼应关系。但重叠又不是越多越好——重叠太多,一是浪费向量库的存储和检索资源,二是同一个信息在多个 chunk 里重复出现,容易在检索时召回过多的冗余片段,反而干扰大模型组织答案。

我常用的参数是:默认块大小 800~1000 个字符(这个范围我用下来在中文和英文混排场景下均衡性比较好),重叠 100~150 个字符。但这个参数不是抄来的,是拿自己的数据试出来的。测试方法也很简单:挑 20 个典型问题,分别用不同块大小跑一遍检索,看召回的片段是否命中标准答案中的关键句。哪个参数组合召回率高,就用哪个。没有放之四海而皆准的默认值。

另外一个进阶手法是父子 chunk。孙子兵法里有句话叫“知己知彼”,在 RAG 里可以反着用:做检索的时候,用粒度更小的子 chunk 去跟用户 query 算相似度,这样召回更精准;把命中的子 chunk 映射回它的父 chunk(比如完整的小节),把父 chunk 整体丢给大模型。这样既能靠小 chunk 提高检索精度,又能靠大 chunk 让模型看到足够多的上下文。我之前在做一个合同问答系统时用的就是这套,效果比单纯切大块或小块都好。实现起来也不复杂,存两个集合,子块里记录父块 ID,检索时先查子块,再根据映射关系把父块内容捞出来拼给 LLM。

切块这关过了,你手里的知识库才真正“有结构”。这一步做得好,后面的检索压力会小很多。

3. Embedding 选型与混合检索:别只看相似度分数,要看成不成立

切片切完之后,下一步就是把 chunk 向量化、入库、检索。很多人在这一步纠结“用什么 embedding 模型”,这个确实重要,但还有两件同样重要的事经常被忽略:一是人类能在多大程度上相信相似度分数,二是一个纯向量的检索方案在真实业务里够不够用。

3.1 embedding 模型怎么选,中文场景特殊在哪里

Embedding 模型的选择,是个特别容易被低估的环节。很多人图省事直接套 OpenAI 的 embedding 接口,或者用一个通用的开源模型。但对中文场景,尤其是垂直领域(法律、医疗、金融、制造),通用模型的语义理解能力可能不够。

我自己踩过的坑是这样的:用一个通用英文 embedding 模型去处理中文产品手册,查“重启设备”和“重新启动”,向量相似度居然不高,因为模型对这两个中文表达的语义等价性捕捉得很弱。后来换成了针对中文优化的模型(比如当时用的 bge-large-zh),效果立刻明显好转。如果在垂直领域,还应该用领域数据做微调。微调 embedding 模型不需要特别大的成本,有一两千条领域内的(query,正例段落)对就够了。

选型的时候还有一个容易踩的坑:不同 embedding 模型的向量维度差异很大,768 维、1024 维、1536 维都有。维度越高不代表越好,但确实影响存储和检索成本。我之前有一个项目因为换了向量库、忘了重新生成向量,导致线上查询维度不匹配直接报错。所以记住:切换 embedding 模型之后,必须全量重新向量化,旧的向量数据千万不要和新模型的向量混在一起。

3.2 混合检索为什么是生产环境的标配

纯向量检索的局限,我在不少项目里体会得特别深。核心问题在于:向量检索天然擅长语义相近的匹配,但不太擅长精确关键词匹配。你问“2024 版安全规范第 3.2 条”,如果知识库里的原文确实是“第 3.2 条”,向量检索大概率能匹配到;但如果用户说“第三章第二节”,向量检索可能就废了——它不太理解“3.2”和“第三章第二节”的数学等价关系。

解决方式就是混合检索:向量检索和关键词检索同时跑,然后把结果合并。经典的做法是“BM25 + 向量检索”的融合。BM25 是传统的词频统计检索,它老老实实地按字面匹配,对精确数字、专有名词、型号代码之类的检索非常有效。向量检索则擅长“语义相关但字面不重叠”的情况。两个结果合并的时候,常用 RRF(Reciprocal Rank Fusion)算法,把两个结果集中每个文档的排名倒数相加,然后按分数排序。它不需要统一向量分数和 BM25 分数的量纲,实现简单、效果稳定。

我有一段时间图省事,只用向量检索,结果知识库里所有“设备型号”相关的问题召回率都不太行。后来加了 BM25 通道,就把这类问题救回来了。这个改动不起眼,但对实际效果的提升经常比换一个更贵的 embedding 模型还明显。所以如果有人跟你说“RAG 就是向量检索”,你可以告诉他:生产环境里,混合检索才是常态。

3.3 Rerank:召回之后的最后一道筛选

还有一个容易被忽略但非常关键的环节,是 rerank(重排)。向量检索的职责是从几十万甚至上百万 chunk 中快速筛出几百个“可能相关的”,但它的准确性有限,因为向量相似度和“真实有用性”之间不是完全对等的关系。Rerank 模型做的事情是:把召回的候选 chunk 和用户问题拼在一起,用更精细的交互匹配模型,逐个打分,选出真正最相关、最可能回答问题的那 3~5 个。

我在生产环境中的经验是:加上一个 rerank 环节,回答质量的主观评分提升非常明显,而且还能减少幻觉——因为喂给大模型的片段更精准了,模型编造的空间就更小了。有人觉得加 rerank 会多一层延迟,实测下来增加的时间大概在 50~200 毫秒,但换来的准确度提升往往值得。

4. 检索之后:上下文编排和“让模型知道哪些不知道”

流水线的最后一段,是把检索到的片段拼给大模型,让它“基于引用内容回答”。这一步看起来就是拼个 Prompt,但同样有讲究。拼得不好,再好的检索结果也会被浪费。

4.1 上下文拼接:不要把检索结果一股脑全倒进去

我第一次做 RAG 的时候,检索到什么就全给大模型,最多按相似度分数排个序。结果经常出现一种“上下文污染”现象:用户问的是“A 设备的电池寿命”,检索结果里有一条非常相关、其他几条部分相关但包含大量无关细节,模型就被无关细节带偏,回答里混杂了 B 设备的内容。

现在我的做法是:

  1. 检索得到的 Top-K 个 chunk,先按顺序拼接,给每个 chunk 加一个清晰的来源引用标记,比如 [1]、[2]
  2. 告诉大模型:只能根据方括号标注的来源信息回答,没有来源支持的内容不得回答
  3. 如果检索结果里有明显不相关的噪声 chunk,宁可少给,也不要硬塞。我一般从 Top-8 里人为收窄到 Top-5,质量反而更高
  4. 在 Prompt 里加一句:“如果这些内容不足以回答用户的问题,请直接说明信息不足。”这句话能大幅减少胡编的可能性

这个其实对应一个很火的词:RAG 智能体。但我觉得没必要把它说得玄乎。本质就是让检索、记忆、判断这几个步骤在流程里更灵活,而不是机械地“检索一次、回答一次”。你可以让模型先判断自己是否需要进一步检索不同来源,再决定是否二次检索,但前提是第一步的检索质量是可控的。

4.2 引用溯源:防止“张冠李戴”的最后防线

我之前在做企业内部知识库时,老板对 RAG 最不满意的一点,不是答不出来,而是答非所问时找不到是哪份文档出了问题。后来我强制要求生成的答案带引用来源——每个关键结论后面标注 [来源1]、[来源2],下面列出对应的文档名和章节号。这样哪怕答案是错的,用户也能快速定位到是哪份文档、哪一段造成了误导,而不是对着 AI 一脸懵。

实现方式倒简单:把引用信息从 chunk 的元数据字段里带上,拼接 prompt 时保留来源标记。真正考验人的是要保证这个对应关系足够准确:一个 chunk 可能跨了两个章节,来源标记就要精细到“文档名 + 标题 + 段落号”。这一步做好了,RAG 系统才算是一个“可信赖的工具”,而不是一个偶尔灵光乍现的玩具。

5. 知识库维护:RAG 系统的“运维之道”,比刚搭起来时更考验人

很多人把 RAG 项目当成一次性交付,做完就完事。实际情况是,知识库里的文档会更新、会删除、会新增,而一个不更新的 RAG 系统,跑得越久越不准。这块内容在各类教程里很少被认真讲,但它恰恰是生产环境里最容易出问题的环节。

5.1 更新策略:全量重建最容易实现,但做多了会出事

最简单的知识库更新方式,是把所有文档重新解析、重新切块、重新生成向量,然后全量替换。好处是逻辑简单、不容易出 bug,代价是时间成本和算力成本,而且更新窗口内系统还会有一段时间不可用。对一个几千 chunk 的小知识库来说,全量重建完全没什么问题;但到了十万、百万级 chunk,全量重建就不是一个能频繁做的事件了。

增量更新才是多数生产系统的常态:只有新增或变动的文档需要被重新处理。难点在于“变动的文档怎么找出”。我经历过一个很尴尬的场景:某份制度文件修改了几个条款,但因为文件名没变,增量更新脚本就没发现它变了,结果用户问“新休假政策”,系统答的还是老版本。

后来我在知识库里加了一个校验字段:文件哈希或最后修改时间。入库前先对比哈希,变了才重新解析和索引。这个逻辑本身不复杂,但很多人一开始根本没想到。如果你用的是 Elasticsearch 或 OpenSearch 这类带管道的方案,也可以把更新触发挂在对象存储的变更事件上(比如 S3 事件通知),文件一变,管道自动跑一轮。这样整个知识库的“保鲜期”就能无限延长。

5.2 多版本冲突:一份文档的新旧版本同时存在怎么办

增量更新会带来另一个问题:旧版本没有及时删除,新版本已经入库,检索时新旧两个版本可能同时被召回。它们的内容可能完全矛盾。比如旧版说“员工可休 15 天年假”,新版说“调整为 10 天”,用户问“今年年假几天”,一个回答里同时出现两个数字,这谁敢用?

处理办法有两个层面。一是文档层面,同一份文档的更新逻辑应该保留“版本号”元数据,检索时优先取最新版本。二是检索层面,如果出现了不同版本对同一问题的说法差异,可以考虑在入库时就删除被替换的旧版本,而不是放任它在索引里游荡。

我做过的比较简朴的处理是:入库前,根据文档的唯一标识(比如文件名)查一下向量库里是否有旧版本,有的话先删掉再写新版本。这个逻辑跑起来之后,知识库的自相矛盾问题基本绝迹了。如果你用的是支持过滤条件的向量库,还可以用“文档 ID + 版本号”字段做过滤,新版本发布后把旧版本的过滤标签置为不可检索。

运维这块是 RAG 真正进入“产品化”之后的分水岭:能不能持续可靠地运行时,比拼的不只是模型效果,而是工程细节。我甚至有一段时间专门去优化更新管线,写完的代码量比整个 RAG 检索链路的代码都多。

6. 评测:没有评测体系的 RAG,就是自我感动

最后这个地方,我放在压轴讲,是因为它是几乎所有做 RAG 的人都忽视、但恰恰决定系统能否迭代的关键。很多人给我演示他们的 RAG 系统时,都是挑几个问题来试,问出好结果就觉得很满意。实际上这种“演示式验证”根本不能说明系统的真实水平,因为你挑的那几个问题,往往是自己精心挑选过、模型大概率能答对的问题。

6.1 没有评测,你根本不知道系统在哪些场景会崩

我当时真正意识到评测有多重要,是在一次团队内部做“红队测试”的时候:让其他同事随机提一些知识库里的偏门问题,结果系统答对率只有不到一半。这个数字让我非常震惊,因为此前我一直以为系统“还行”。后来我总结,评测的核心在于:构建一个覆盖面足够广、难度分布合理的问题集,并给每个问题配上标准答案或至少是答案关键词。

具体做法可以分几步:

  1. 从知识库里挑一部分内容,人工写成 50~100 道问题,覆盖不同类型:事实问答、总结归纳、对比、流程步骤、数字提取等
  2. 给每道题标注一个“参考文献范围”(chunk ID 或者文档页范围),这样评测召回率时可以自动判断“是否命中了相关片段”
  3. 跑一遍系统,分两个指标看:召回率(命中相关片段的比例)和答案准确率(答案与标准答案一致的多少)

这里有个热搜词叫“RAG 瓶颈”,我理解的其中一个核心瓶颈就在评测环节:很多人知道系统有问题,但说不清楚问题在哪。没有评测体系,你永远只能靠“感觉”来排查,效率低到可怕。

6.2 离线评测指标怎么定:命中率、忠实度、幻觉率

评测指标可以分为三个维度,我简化为:

  1. 检索命中率(Recall@K):把评测问题的参考 chunk 跟 Top-K 检索结果比对,看多少问题的正确答案片段能被召回
  2. 生成忠实度(Faithfulness):生成的答案里,有多少信息在参考片段中有支撑。这个可以人工打分,也可以借助另一个强力的 LLM 来判
  3. 可用率(Correctness):综合考虑答案的正确性和完整性,建议直接让真人打 1~5 分

我实际操作中,最先盯的永远是检索命中率。因为如果 Top-5 里根本没有正确答案的片段,那么大模型再强也回答不对。命中率低了,问题基本出在前面几章(解析、切块、检索)。命中率够了但答案还是错,问题多半出在生成环节(prompt、上下文编排、rerank)。这给排查问题提供了一个很清晰的路径,不会像无头苍蝇一样乱调。

评测集不是一次性建完就完事的。我自己的经验是:每迭代一版知识库,就随手补充几条之前系统答错的、新发现的边界问题,让评测集跟着知识库一起长大。几个月下来,评测集可能从 50 题涨到 200 题,系统的任何改动都能快速回测,这个反馈闭环越早建立,系统迭代越快。

说得直白一点:评测不是 RAG 的附加题,而是 RAG 的分水岭。一个连评测集都没有的 RAG 系统,你根本不知道它现在站在哪个水平线上,又该如何规划下一步。

写在最后:关于这些分水岭,我的一些实际操作心得

做了这么久 RAG,我自己的体会是:技术框架一直在变,但围绕“数据质量、检索质量、生成可信度”这三个方向的打磨逻辑是不变的。现在网上铺天盖地的“五分钟搭建 RAG”,能帮你跑通 demo,但帮不了你上线生产。真正值得花时间的,恰恰是那些看起来不够炫、但决定成败的细节:把解析做扎实、把切块做合理、把评测建起来。

最后分享一个我自己常用来检查 RAG 系统健康度的小技巧:定期随机抽 10 个测试问题,人工看一遍系统输出,把每个答案和引用来源都检查一遍。如果 10 个问题里有 3 个以上的答案引用错误或者信息张冠李戴,那么不管得分多高,都说明底层的解析或者检索链路有需要修整的地方。别等用户来给你报 bug,自己先做更挑剔的用户,系统的水位才能一点点涨上去。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进