1. 从一次翻车的知识库项目说起
去年下半年我接手了一个企业内部的智能问答项目,需求方给了一堆产品手册、售后工单和内部 Wiki 页面,要求做一个能回答“这个型号的滤芯多久换一次”“报错 E07 怎么处理”这类问题的助手。团队一开始信心满满,觉得现在大模型能力这么强,把文档往向量库里一塞,检索出来丢给模型不就完事了。结果第一版上线,测试同事随手问了几个问题,回答要么答非所问,要么把两个不同型号的参数混在一起,命中率惨不忍睹。
那次翻车让我彻底明白一件事:RAG 的效果上限,八成取决于检索质量,而检索质量又几乎被分块、召回、重排这三个环节锁死。模型本身反而是最不需要操心的部分。后来我们花了大概三周时间,把这三个环节反复调优,命中率从最初的不到四成拉到了八成五以上。这篇文章就是那段时间踩坑、试错、复盘之后沉淀下来的六个实战结论,围绕RAG、分块、召回、重排、embedding这几个核心点展开,适合正在做 RAG 项目、或者准备把 RAG 落到真实业务里的朋友参考。不管你是用 LangChain、LangChain4j 还是 Spring AI 搭的框架,这些结论都是通用的。
我先把结论摆出来,后面再逐条拆解:分块不是越小越好,语义完整比长度均匀重要;召回阶段要的是“宁可多召回,别漏”,重排阶段才是“精挑细选”;embedding 模型选型要看语言和领域,别迷信排行榜;重排模型是性价比最高的一环;元数据过滤能救命;评估必须建立自己的测试集,别拿通用榜单当真理。
2. 分块策略:为什么你的切分方式决定了检索天花板
2.1 分块的本质是在“信息完整”和“检索精度”之间找平衡
很多人第一次做 RAG,分块就是简单粗暴地按固定字符数切,比如每 500 字一块,重叠 50 字。这么做不是不行,但它默认了一个假设:文档里每 500 字是一个自洽的语义单元。现实里这个假设几乎不成立。一份产品手册里,一个完整的操作步骤可能横跨 800 字,一个参数表格可能只有 200 字,一段注意事项可能夹在两个章节中间。
分块太大,向量会“稀释”。举个例子,一块 1500 字的文本里,只有 100 字在讲“滤芯更换周期”,剩下 1400 字在讲安装步骤。这段文本的 embedding 会被安装步骤的内容主导,用户问滤芯周期时,这块的相似度反而不高。分块太小,语义会被“腰斩”。一个完整的因果句被切成两半,前半块说“如果出现 E07 报错”,后半块说“请检查进水压力”,检索到前半块根本回答不了问题。
我的经验是:分块的核心不是长度,而是语义边界。理想状态下,每一块应该是一个能独立回答某类问题的完整语义单元。这个单元可能是一段、一个列表、一个表格,甚至一个小节。
2.2 三种分块方式的实测对比
我们当时拿同一份 300 页的产品手册做了对比测试,用同一套 50 个问题的测试集,看不同分块方式下的召回命中率(这里指正确答案所在块被召回的比例)。
| 分块方式 | 平均块长度 | 块数量 | 召回命中率 | 主要问题 |
|---|---|---|---|---|
| 固定字符切分(500字/50重叠) | 500 | 约 1200 | 62% | 语义割裂严重,表格被切碎 |
| 按段落/换行切分 | 不均,80-900 | 约 2100 | 71% | 短块太多,长块仍稀释 |
| 递归字符切分(按标题→段落→句子) | 300-800 | 约 900 | 83% | 需要文档结构规范 |
| 语义分块(embedding 相似度断点) | 200-600 | 约 1100 | 86% | 计算成本高,速度慢 |
递归字符切分是我们最终采用的主力方案。它的逻辑是:优先按文档的自然层级切——先按一级标题切,如果某段还是太长,再按二级标题切,再长就按段落,最后才按句子。LangChain 里的RecursiveCharacterTextSplitter就是干这个的,你可以自定义分隔符的优先级列表。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=600, chunk_overlap=80, separators=["\n## ", "\n### ", "\n\n", "\n", "。", "!", "?", ";", ",", ""], length_function=len, )这里有个细节值得说:分隔符的顺序就是优先级。我特意把 Markdown 标题放在最前面,因为我们的文档是 Markdown 格式,标题天然就是语义边界。如果你的文档是 Word 转的纯文本,标题可能变成孤立的行,那就得先做一轮清洗,把标题识别出来加上标记。
2.3 分块的两个反直觉结论
第一个结论:重叠不是越多越好。很多人觉得重叠能防止语义割裂,就把 overlap 设得很大,比如 chunk_size 的 30%。实测下来,重叠超过 15% 之后,收益急剧下降,反而带来两个副作用:一是向量库里重复内容变多,检索时同一段信息反复出现,挤占了其他有用块的排名;二是存储和计算成本上升。我们最后把 overlap 定在 80 字左右,大概是 chunk_size 的 13%。
第二个结论:表格和代码块要单独处理。这是踩过坑才明白的。产品手册里有个参数对照表,按普通文本切分后,表头和表体被切到不同的块里,检索出来的块只有一堆数字,模型根本不知道这些数字对应什么参数。后来我们的做法是:在预处理阶段识别出表格和代码块,把它们作为独立的块,不参与常规切分,同时在块的开头补一句上下文说明,比如“以下是 XX 型号的参数对照表”。
提示:如果你的文档里有大量表格,建议在分块前先做结构识别,把表格转成“表头+行”的自然语言描述,比如“型号 A 的滤芯更换周期是 3 个月”,这样检索和生成都会更准。
3. 召回环节:宁可多召回,别漏掉正确答案
3.1 召回的目标不是精准,而是“不漏”
这是我在项目里反复跟团队强调的一点。召回阶段和重排阶段的目标是相反的:召回要的是高召回率,也就是正确答案尽量别漏;重排要的是高精确率,也就是把最相关的排到最前面。很多人把这两个阶段的目标搞混了,在召回阶段就想着“只取 top 3”,结果正确答案根本没进候选集,后面重排再强也救不回来。
我们的做法是:召回阶段取 top 20 到 top 50,具体数量看知识库规模。知识库小(几千块),取 top 20 足够;知识库大(几十万块),取 top 50 甚至 top 100。多召回一些,让重排模型去干精挑细选的活。
3.2 向量召回 + 关键词召回,两条腿走路
纯向量召回有个天然短板:它对精确匹配不敏感。比如用户问“E07 报错”,向量检索可能召回一堆讲“报错处理”的块,但偏偏漏掉了那个只讲 E07 的块,因为“E07”这个 token 在 embedding 里权重不高。这时候关键词召回(BM25 或全文索引)就能补上。
我们最终用的是混合召回:一路走向量检索,一路走 BM25,两路各取 top 20,合并去重后得到候选集。合并的时候可以给两路不同的权重,比如向量 0.7、BM25 0.3,也可以简单粗暴地各取一半。实测下来,混合召回比纯向量召回的命中率高了将近 15 个百分点,尤其是在有型号、编号、专有名词的场景下。
# 伪代码示意混合召回 vector_results = vector_store.similarity_search(query, k=20) bm25_results = bm25_retriever.get_relevant_documents(query, k=20) # 合并去重,按分数加权 merged = {} for doc in vector_results: merged[doc.id] = merged.get(doc.id, 0) + 0.7 * doc.score for doc in bm25_results: merged[doc.id] = merged.get(doc.id, 0) + 0.3 * doc.score candidates = sorted(merged.items(), key=lambda x: x[1], reverse=True)[:30]3.3 元数据过滤:被低估的召回利器
这一条是我个人觉得最值得单独拎出来讲的。很多 RAG 项目只做语义检索,完全忽略了元数据。但现实业务里,元数据过滤往往能直接把召回范围缩小一个数量级。
举个例子:用户问“A 型号的滤芯多久换”,如果知识库里有 A、B、C 三个型号的手册,纯语义检索可能把三个型号的滤芯信息都召回来,模型一看就懵了。但如果你在入库时给每个块打上model: A的元数据标签,检索时先按model == A过滤,再在过滤后的子集里做语义检索,准确率立刻上一个台阶。
我们的元数据字段包括:文档来源、产品型号、章节类型(操作/参数/故障)、更新时间。检索时根据 query 里识别出的实体做过滤,识别不出来的字段就不过滤。这套组合拳下来,召回的相关性提升非常明显。
注意:元数据过滤是“与”逻辑还是“或”逻辑要小心。多个过滤条件同时满足时用“与”,但条件太严可能把正确答案过滤掉。建议先宽松过滤,再靠重排兜底。
4. 重排:整个 RAG 链路里性价比最高的一环
4.1 为什么重排能带来质变
召回阶段为了不漏,取了一大堆候选块,这些块的排序是粗排的结果,向量相似度高不代表真的相关。重排模型(Reranker)的作用就是对候选集做一次精细的相关性打分,把真正相关的块顶到最前面。
重排模型和 embedding 模型的区别在于:embedding 是“双塔”结构,query 和文档分别编码,最后算余弦相似度,速度快但精度有限;重排模型是“交叉编码”结构,query 和文档拼在一起送进模型,能捕捉两者之间的细粒度交互,精度高但速度慢。所以典型架构是:向量召回负责快和全,重排负责准。
我们用的是开源的 BGE Reranker 系列,中文场景下效果很稳。接入方式也很简单,LangChain 里有对应的CrossEncoderReranker,或者直接用 FlagEmbedding 库。
from FlagEmbedding import FlagReranker reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True) pairs = [[query, doc.page_content] for doc in candidates] scores = reranker.compute_score(pairs) # 按分数重排,取 top 5 reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:5]4.2 重排的实测收益和成本
我们做过一组对比:同一套测试集,召回 top 30,分别测“不重排直接取 top 5”和“重排后取 top 5”的最终答案准确率。
| 方案 | 答案准确率 | 单次检索耗时 |
|---|---|---|
| 不重排,向量 top 5 | 58% | 约 80ms |
| 不重排,混合召回 top 5 | 67% | 约 150ms |
| 重排后 top 5 | 84% | 约 450ms |
| 重排后 top 3 | 81% | 约 450ms |
可以看到,重排带来的准确率提升接近 20 个百分点,而耗时只增加了 300ms 左右。对于绝大多数问答场景,这个延迟完全可以接受。如果只能优化一个环节,我会毫不犹豫选重排。
4.3 重排的两个实操细节
第一个细节:重排的候选数量要适中。候选太少,重排没得选;候选太多,一是耗时线性增长,二是可能引入噪声。我们的经验值是 20 到 50 之间,具体看知识库密度。候选块之间内容重复度高的时候,可以先去重再重排。
第二个细节:重排分数可以做阈值过滤。重排模型给出的分数是有绝对意义的,分数低于某个阈值的块,说明和 query 真的不相关,可以直接丢掉,哪怕它排在 top 5 里。我们设的阈值是 0.3 左右(不同模型尺度不同,需要自己标定)。这样能避免模型拿到一堆不相关的上下文,硬编出一个答案。
5. Embedding 模型选型:别迷信排行榜,看你的数据和场景
5.1 排行榜第一不等于你的场景第一
网上有很多 embedding 模型排行榜,MTEB、C-MTEB 之类的,很多人直接照着榜首选。我踩过的坑是:排行榜的测试集是通用语料,而你的业务数据可能是产品手册、法律条文、医疗记录,领域差异巨大。一个在通用榜单上排第一的模型,在你的垂直领域可能还不如一个中等模型。
我们的做法是:用自己的测试集做小规模对比。从业务文档里挑 100 个典型问题,每个问题标注正确答案所在的块,然后测不同 embedding 模型的召回命中率。这个测试集不用很大,100 到 200 条就足够看出差异。我们当时对比了四五个模型,最终选中的并不是榜单第一的那个,而是在我们产品手册数据上表现最稳的那个。
5.2 中文场景的选型建议
如果你的业务是中文为主,选型时要注意几点:一是模型是否在中文语料上充分训练过,很多英文模型直接拿来跑中文,效果会打折扣;二是模型的向量维度,维度高精度好但存储和检索成本高,维度低反之,一般 768 或 1024 维是比较平衡的选择;三是模型的最大输入长度,要和你分块后的块长度匹配,块长 600 字的话,模型至少支持 512 token。
我们最终用的是 BGE 系列的中文模型,配合重排模型也是同系列的,两者搭配效果比较协调。这里不是说别的模型不行,而是同系列模型在训练目标上往往更一致,配合起来踩坑少。
5.3 一个容易被忽略的点:query 和文档要用同一个模型
这个听起来像废话,但我真的见过有人 query 用一个模型编码,文档用另一个模型编码,然后抱怨检索不准。embedding 模型的向量空间是模型自己定义的,不同模型的向量空间完全不兼容,混用等于随机检索。另外,有些模型区分“query 编码”和“文档编码”两种模式,比如 BGE 系列建议 query 加指令前缀,文档不加,这个细节也要注意,用错了会损失几个点的精度。
6. 评估与迭代:没有测试集的 RAG 项目就是在盲调
6.1 建立自己的评估测试集
RAG 项目最怕的就是“感觉还行”。你改了一个参数,感觉回答变好了,但到底是真变好还是运气好,没有测试集根本说不清。我们的做法是:项目启动第一周就建测试集,从真实业务问题里挑 100 到 200 条,每条标注标准答案和答案所在的文档块。
测试集要覆盖几类问题:事实型(某参数是多少)、操作型(某故障怎么处理)、对比型(A 和 B 有什么区别)、否定型(某功能是否支持)。不同类型的问题对检索的要求不一样,分开统计才能看出短板在哪。
6.2 三个核心指标
评估 RAG 不能只看最终答案对不对,要拆开看每一环:
- 召回命中率:正确答案所在的块,有没有被召回进候选集。这个指标低,说明分块或召回有问题。
- 重排准确率:正确答案的块,有没有被重排进 top 5。这个指标低,说明重排模型或候选质量有问题。
- 答案准确率:最终生成的答案对不对。这个指标低,但前两个指标高,说明是生成环节的问题,可能是 prompt 没写好或者上下文太长。
分开看这三个指标,才能定位问题到底出在哪一环。我们当时就是发现召回命中率有 85%,但答案准确率只有 60%,一查发现是重排后取的块太多,上下文里塞了一堆无关内容,模型被干扰了。把 top 5 改成 top 3 之后,答案准确率立刻上去了。
6.3 迭代的优先级
如果测试下来效果不理想,调整的优先级建议是:先调重排,再调召回,最后调分块。因为重排改动成本最低,效果最直接;召回次之;分块改动成本最高,因为要重新入库,但收益也最持久。当然,如果分块问题特别明显(比如表格被切碎),那还是得先解决分块。
7. 六个实战结论的完整复盘
把前面这些内容收拢一下,就是我在这个项目里最想分享的六条经验。
第一条,分块看语义边界,不看固定长度。递归切分配合文档结构,比无脑按字符切强太多,表格和代码块要单独处理。
第二条,召回要宽,重排要严。召回阶段取 top 20 到 50,混合向量和关键词两路,别在召回阶段就想着精准。
第三条,元数据过滤是被低估的利器。产品型号、章节类型这些结构化信息,能直接把检索范围缩小一个数量级。
第四条,重排是性价比最高的一环。20 个点的准确率提升,只换来几百毫秒延迟,没有理由不做。
第五条,embedding 模型要用自己的数据选。排行榜只能参考,最终决策必须基于业务测试集。
第六条,没有测试集就没有优化。召回命中率、重排准确率、答案准确率三个指标分开看,才能定位问题。
这六条里,如果只能记住一条,我建议记住重排那条。它是我在这个项目里投入产出比最高的一个改动,也是很多 RAG 项目容易忽略的一环。很多人把精力花在换更大的模型、调更复杂的 prompt 上,却忘了检索出来的上下文本身就是错的,模型再强也救不回来。
最后分享一个我们后来养成的小习惯:每次线上出现回答错误,都把那个 query 和召回的块记录下来,定期复盘。这些真实 badcase 比任何测试集都宝贵,它们会告诉你系统真正的短板在哪里。我们靠这个习惯,在项目上线后又陆续发现了几个分块和元数据的盲区,命中率又往上提了几个点。RAG 这东西,没有一劳永逸的配置,只有持续迭代的耐心。