1. 为什么 RAG 的瓶颈往往不在模型本身
做 RAG 项目做久了,你会发现一个很反直觉的现象:换更大的模型、调更高的 temperature、甚至把 embedding 模型从英文换成多语言,效果提升可能只有几个百分点;但把 chunk 策略改一改、把检索从纯向量换成混合检索、再加一层 rerank,整体回答质量能肉眼可见地往上跳一个台阶。这不是玄学,而是 RAG 系统的信息流决定的——检索阶段决定了模型能看到什么,生成阶段只是把看到的东西组织成话。如果检索回来的内容本身就是残缺的、错位的、或者被无关段落稀释的,再强的模型也只能在垃圾上做文章。
Spring AI 2.0 这一代把 RAG 的各个可插拔环节做得比 1.x 清晰很多,VectorStore、DocumentRetriever、Advisor这几层的职责边界明确了,也就意味着我们有了更多可以下手优化的地方。但工具变多之后,很多人反而不知道从哪开始调。我见过不少项目,上来就堆 rerank 模型,结果 chunk 切得稀碎,rerank 再准也救不回来;也见过 chunk 切得很讲究,但检索只用单一向量相似度,遇到关键词精确匹配的场景直接翻车。
这篇内容我想按信息流的顺序,把 Chunking、混合检索、Rerank 这三段拆开讲,每一段都给出可落地的配置思路、参数取舍的理由,以及我在实际项目里踩过的坑。适合已经在用 Spring AI 搭 RAG、但效果卡在某个瓶颈上不去的同学,也适合刚开始接触 RAG 工程、想少走弯路的开发者。核心关键词会围绕Spring AI、RAG、Chunking、混合检索、Rerank这几个点展开,但不会停留在概念层面,而是尽量给到能直接抄的配置和判断标准。
先说一个我自己的判断:RAG 优化是有优先级的,顺序错了,投入产出比会差好几倍。我的经验顺序是 Chunking > 混合检索 > Rerank。原因很简单,Chunking 决定了信息的最小单元,它是地基;混合检索决定了召回的质量和覆盖面;Rerank 是在召回结果里做精排,它只能优化"已经召回的候选集",救不了根本没召回来的内容。下面逐段展开。
2. Chunking:决定 RAG 上限的地基工程
2.1 固定长度切分为什么在真实文档上总是翻车
刚上手的时候,几乎所有人都会用TokenTextSplitter或者固定字符数切分,设个 chunkSize=800、overlap=100 就开跑。在结构规整的文档上(比如一段一段的说明文)这招还能用,但一旦遇到真实业务文档——带表格的、带多级标题的、代码和正文混排的、问答对格式的——固定长度切分的问题就暴露得很彻底。
最典型的问题是语义截断。一个完整的操作步骤被从中间切开,前半段在 chunk A,后半段在 chunk B。检索时如果只召回了 A,模型看到的就是一个"半截指令",生成出来的答案要么缺步骤,要么把不完整的步骤当成完整的。我遇到过一个特别坑的案例:一份配置文档里"修改端口后需要重启服务"这句话被切到了下一个 chunk,结果模型给出的答案里永远少了重启这一步,用户照着做怎么都不生效。
第二个问题是噪声稀释。固定长度切分不关心内容边界,一个 chunk 里可能混了三个不同主题的段落。向量化之后,这个 chunk 的 embedding 是三个主题的"平均",跟任何一个具体问题的相似度都不高。检索时它可能勉强被召回,但排名靠后,而且塞进上下文后反而干扰模型判断。
提示:判断你的 chunk 是否切得合理,有个很土但很有效的办法——随机抽 20 个 chunk 打印出来人工读一遍。如果你自己读着都觉得"这段话说了一半",那模型大概率也理解不了。
2.2 按文档结构切分:TokenTextSplitter 之外的几个选择
Spring AI 2.0 里可用的切分器比 1.x 丰富,除了基础的TokenTextSplitter,还有基于段落、基于句子、以及可以自定义分隔符的实现。我的建议是优先按文档的天然结构切,而不是按长度切。
具体做法上,Markdown 文档按标题层级切是最自然的。一级标题下的内容作为一个大块,如果超过阈值再按二级标题拆,以此类推。这样每个 chunk 天然带有一个语义主题,embedding 的"纯度"高很多。代码文档可以按函数或类切,问答对按 Q-A 边界切,表格尽量整表保留(表格被切开基本就废了)。
Spring AI 的DocumentTransformer接口允许你在切分前先做预处理,我一般会写一个自定义的 transformer,把文档按标题结构解析成树,再自底向上合并小节点、自顶向下拆分大节点。核心逻辑是:先按结构切,再对超长节点做二次切分,对过短节点做向上合并。这样既保留了语义边界,又控制了 chunk 大小。
关于 chunk 大小的取值,没有万能数字,但有个经验区间可以参考:
| 文档类型 | 建议 chunk 大小(token) | overlap | 理由 |
|---|---|---|---|
| 技术文档/API 说明 | 400-600 | 50-80 | 段落短,主题集中,小 chunk 精度高 |
| 长篇文章/报告 | 600-900 | 100-150 | 段落长,需要更多上下文才能完整表达 |
| 问答对/FAQ | 按 Q-A 对切 | 0 | 天然边界,不需要 overlap |
| 代码文档 | 按函数/类切 | 视情况 | 函数完整性优先于长度 |
overlap 的作用是缓解边界截断,但也不是越大越好。overlap 太大会导致相邻 chunk 高度重复,检索时召回一堆相似内容,浪费上下文窗口。我一般控制在 chunk 大小的 10%-15%。
2.3 元数据:被大多数人忽略的检索增强利器
Chunking 阶段还有一个经常被忽略的点:给每个 chunk 打上元数据。Spring AI 的Document对象支持 metadata,这个字段在检索阶段能做很多事。
最基础的元数据包括来源文件名、章节标题、页码、文档类型。这些信息在检索后可以用来做过滤(比如只在某个文档范围内检索),也可以拼进上下文帮助模型定位信息("以下内容来自《XX 配置手册》第 3 章")。更进阶一点,可以给 chunk 打上主题标签、时间戳、权限等级,配合VectorStore的 filter 表达式做精细化检索。
我做过一个项目,文档更新频繁,不同版本的配置项含义不同。如果只按语义检索,很容易把旧版本的说明召回给用户。后来在 chunk 元数据里加了version和effectiveDate,检索时用 filter 限定只查当前生效版本,问题直接解决。这个改动成本很低,但效果立竿见影。
注意:元数据的字段设计要在入库前想清楚,因为一旦向量库里有大量数据,后期补元数据是很痛苦的事。建议至少保留
source、section、chunkIndex这三个字段。
3. 混合检索:让召回既懂语义又认关键词
3.1 纯向量检索在什么场景下会失灵
向量检索的强项是语义匹配——用户问"怎么改端口",文档里写的是"修改服务监听端口",字面不一样但语义一致,向量能召回。但它的弱项同样明显:对精确关键词、专有名词、编号、代码标识符不敏感。
我踩过最典型的一个坑是产品型号检索。用户问"XX-2000 支持哪些协议",向量检索召回的却是一堆"XX-1000""XX-3000"的文档,因为型号在 embedding 空间里距离很近,模型分不清这几个数字的差异。还有 API 名称、错误码、配置项 key 这类内容,向量检索经常召回一堆"看起来相关但实际不对"的结果。
另一个场景是长尾查询。用户问的是一个很冷门的组合条件,向量检索因为训练数据的偏差,可能根本召不回正确文档。这时候关键词检索(BM25 这类)反而能靠字面匹配命中。
所以结论很清楚:纯向量检索不够,纯关键词检索也不够,混合检索才是正解。混合检索的核心思路是同时跑两路召回,然后做融合。
3.2 Spring AI 里怎么搭一套可用的混合检索
Spring AI 2.0 本身对混合检索没有开箱即用的"一键方案",但它的抽象层设计让你可以比较自然地组合。我的做法是实现一个自定义的DocumentRetriever,内部持有两个检索器:一个基于VectorStore的语义检索,一个基于关键词的检索(可以用 Lucene、Elasticsearch,或者简单点用内存倒排索引)。
流程是这样的:用户 query 进来,同时发给两路检索器,各自返回 top-K 候选,然后用融合算法合并成一个统一排序的列表。融合算法最常用的是RRF(Reciprocal Rank Fusion),它的好处是不依赖两路检索的分数尺度,只看排名,鲁棒性好。
RRF 的公式很简单:对每个文档,得分 = Σ 1/(k + rank),其中 rank 是它在某一路检索结果里的排名,k 是个平滑常数(一般取 60)。两路都排在前面的文档,融合后得分最高。这个算法不需要调参,实测下来比加权求和稳定得多,因为向量相似度和 BM25 分数根本不在一个量纲上,硬加权很容易翻车。
在 Spring AI 里实现的时候,我建议把两路检索的 top-K 设得比最终需要的候选数大一些。比如最终要 10 个候选给 rerank,那每路召回 20-30 个,融合后再截断。这样能保证召回率,给后面的 rerank 留足空间。
3.3 关键词检索这一路,用什么实现最省心
关键词检索的实现选择,取决于你的数据规模和部署条件。小规模(几万 chunk 以内)用内存倒排索引完全够用,启动时把 chunk 内容建索引,查询时走内存,延迟很低。规模再大就上 Elasticsearch 或 OpenSearch,它们原生支持 BM25,还能顺便做分词和同义词扩展。
中文场景要特别注意分词。英文按空格切就行,中文必须用分词器(IK、jieba 之类),否则 BM25 的效果会很差。我见过有人中文文档直接用空格分词,结果关键词检索这一路基本等于没开。分词器的词典最好能导入业务专有名词,不然型号、术语还是会被切碎。
还有一个细节:关键词检索的字段权重。标题、章节名这些字段的匹配应该比正文权重高,因为标题往往概括了内容主题。Elasticsearch 里可以用 boost 控制,内存索引的话就在打分时手动加权。这个调整对召回质量的影响比想象中大。
| 检索方式 | 强项 | 弱项 | 适用场景 |
|---|---|---|---|
| 向量检索 | 语义匹配、同义改写 | 精确关键词、编号、专名 | 自然语言提问 |
| 关键词检索 | 精确匹配、专名、编号 | 同义改写、语义泛化 | 型号、API、错误码查询 |
| 混合检索 | 兼顾两者 | 实现复杂度高 | 真实业务通用场景 |
4. Rerank:在候选集里做精排的最后一公里
4.1 Rerank 到底解决了什么问题
混合检索解决了"召回得到"的问题,但召回结果里往往还是混着不少相关性一般的内容。向量检索和关键词检索都是"粗排",它们追求的是快和召回率,对相关性的判断比较粗糙。Rerank 的作用就是在这个候选集上做一次"精排",用更强的模型(通常是 cross-encoder 结构)逐对计算 query 和文档的相关性,把真正相关的顶上来。
Cross-encoder 和双塔(bi-encoder,也就是 embedding 模型)的区别在于:双塔是 query 和文档分别编码再算相似度,快但精度有限;cross-encoder 是把 query 和文档拼在一起过模型,能捕捉两者之间的细粒度交互,精度高但慢。所以工程上的标准做法就是:双塔负责召回,cross-encoder 负责精排,各司其职。
Rerank 带来的提升在什么场景下最明显?我的观察是:候选集越大、噪声越多,rerank 的价值越大。如果召回阶段已经很准,top-5 里全是相关文档,rerank 提升有限;但如果召回 top-20 里只有一半相关,rerank 能把相关的那一半顶到前面,效果就很显著。
4.2 在 Spring AI 里接入 Rerank 的几种姿势
Spring AI 2.0 的DocumentRetriever抽象让接入 rerank 变得比较自然。基本思路是:混合检索返回候选列表后,不直接返回,而是过一层 rerank 再返回。实现上可以包一个RerankDocumentRetriever,内部持有混合检索器和 rerank 客户端。
Rerank 模型的部署方式有几种选择。如果追求省心,可以用现成的 rerank API 服务;如果要求数据不出内网,就本地部署开源 rerank 模型。本地部署的话,模型体积和推理速度要权衡,小模型(几百 MB)延迟低但精度一般,大模型精度好但需要 GPU。我一般建议先用小模型跑通链路,确认 rerank 确实有收益,再考虑要不要上大模型。
接入的时候有个容易忽略的点:rerank 的输入长度限制。Cross-encoder 对 query+document 的总长度有上限,如果 chunk 太长会被截断,截断后精度下降。所以 chunk 大小和 rerank 模型的最大长度要匹配,chunk 别切太大。这也是为什么我在第 2 节强调 chunk 要控制在合理范围。
4.3 Rerank 的收益评估与参数取舍
Rerank 不是免费的,它增加了一次模型推理,延迟会上升。所以上线前一定要评估收益是否值得这个延迟。我的评估方法是:准备一批真实 query 和标注好的相关文档,对比"混合检索直接返回 top-5"和"混合检索+rerank 返回 top-5"的命中率。如果提升明显(比如从 60% 到 80%),那延迟增加是值得的;如果提升只有几个百分点,就要考虑是不是召回阶段的问题更大,先优化召回。
参数上主要调两个:候选集大小和最终返回数量。候选集太小,rerank 没有发挥空间;太大,延迟高且收益递减。我的经验是候选集取最终返回数的 3-5 倍比较合适,比如最终要 5 个,候选集取 15-25 个。最终返回数量则取决于下游模型的上下文窗口和你的 prompt 设计,一般 3-8 个。
还有一个实践细节:rerank 分数可以做阈值过滤。如果所有候选的 rerank 分数都很低,说明知识库里可能根本没有相关内容,这时候与其硬塞几个不相关的 chunk 给模型,不如直接告诉用户"没有找到相关信息"。这个兜底逻辑能显著减少幻觉。
5. 三段优化的联动与实测踩坑记录
5.1 优化顺序错了会怎样:一个真实的反例
前面说了优化优先级是 Chunking > 混合检索 > Rerank,这里讲一个我亲眼见过的反例。有个团队一上来就上了 rerank,模型选的是当时效果最好的,结果整体效果提升微乎其微。排查下来发现,他们的 chunk 是固定 1000 字符切的,很多 chunk 本身就是"半截话",rerank 再准也只能在这些残缺内容里排序,排出来的第一名依然是残缺的。
后来他们把 chunk 策略改成按标题结构切,同样的 rerank 模型,命中率直接涨了一大截。这个案例说明:rerank 是在候选集里做选择,它无法创造候选集里不存在的好内容。地基没打好,上层怎么优化都是白费。
反过来,如果 chunk 切得好、混合检索召回也全,但没上 rerank,效果通常也不会太差,只是 top 结果里可能混着几个相关性一般的。所以我的建议是:先把 Chunking 做扎实,再上混合检索,最后用 rerank 收尾。每一步都验证收益,不要跳步。
5.2 各阶段的验证方法:怎么知道这一步优化有没有用
优化最怕的是"感觉变好了"但说不清好在哪。我一般会给每个阶段准备一套小规模的评测集:20-50 个真实 query,每个 query 标注 1-3 个应该被召回的相关 chunk。然后看两个指标:召回率(相关 chunk 有没有出现在候选集里)和MRR(相关 chunk 排在第几位)。
Chunking 阶段主要看召回率,因为切分影响的是"内容能不能被找到"。混合检索阶段看召回率和 MRR 的综合提升。Rerank 阶段主要看 MRR,因为它优化的是排序。这套评测集不需要很大,但一定要用真实 query,自己编的 query 往往太"标准",测不出真实问题。
评测集还有个好处是可以防止回归。RAG 系统调参很容易顾此失彼,改了 A 参数 B 指标掉了。有了固定评测集,每次改动跑一遍,心里有数。
5.3 几个容易忽略的工程细节
最后分享几个实操中容易忽略但影响不小的细节。
第一,embedding 模型和 rerank 模型要匹配语言。中文场景用英文为主的 embedding 模型,效果会打折扣。选模型时先确认它对中文的支持程度,最好用业务数据实测一下。
第二,向量库的索引类型影响检索速度和召回。HNSW 快但内存占用高,IVF 省内存但需要训练且召回可能略低。数据量不大的话 HNSW 是首选,参数上efSearch调大能提升召回但增加延迟,需要权衡。
第三,上下文拼接的顺序有讲究。给模型的 chunk 不要随便堆,按相关性从高到低排,并且标注来源。模型对靠前的内容注意力更集中,把最相关的放前面能提升回答质量。
第四,注意 token 预算。召回的 chunk 加上 prompt 模板、对话历史,很容易超出模型上下文窗口。要在拼接前算好 token 数,超了就截断或减少召回数量。这个逻辑最好做成可配置的,不同模型窗口不一样。
第五,缓存。相同 query 的检索结果可以缓存,尤其是 rerank 这种耗时操作。缓存 key 用 query 的归一化形式,能省不少重复计算。
提示:RAG 优化是个系统工程,不要指望某一个环节的调整能带来质变。真正有效的做法是每一段都做到"及格线以上",然后靠整体协同把效果拉起来。
6. 关于 Spring AI 2.0 RAG 工程化的一点个人体会
做了一段时间 Spring AI 2.0 的 RAG 项目,我最大的体会是:框架把抽象做得好,是为了让你能替换每一层,而不是让你不用管每一层。VectorStore、DocumentRetriever、Advisor这些抽象确实让组合变得灵活,但灵活也意味着你需要对每一层的原理有判断,否则很容易在错误的层上使劲。
Chunking 这块,我现在的习惯是任何新文档进来,先人工看一遍结构,再决定切分策略,而不是套一个默认配置了事。混合检索这块,关键词那一路的分词和字段权重值得花时间调,它带来的召回提升往往被低估。Rerank 这块,先确认召回质量再上,别把它当成万能药。
还有一点,RAG 的效果评估一定要有数据支撑,不能靠感觉。我见过太多项目在"感觉优化了"和"感觉又变差了"之间反复横跳,最后谁也说不清到底哪个版本好。一套小而真实的评测集,比任何调参技巧都重要。
如果你正在做 Spring AI 的 RAG 项目,卡在效果上不去,我的建议是回到 Chunking 这一层重新审视一遍。很多时候问题不在模型,而在你喂给模型的那几段文字本身。