外观
RAG 召回不准,问题多半在分块
约 1153 字大约 4 分钟
2026-09-09
内部知识库上线第二天,HR 同事甩了张截图过来:问「年假怎么申请」,机器人回了一句「员工每年享有带薪年假」,然后就没了。
我第一反应是模型偷懒,把 prompt 改狠了一点,没用。直到把召回的 chunk 原样打出来才发现,它拿到的确实只有半句——原文是「员工每年享有带薪年假。申请须在 OA 提交,由直属主管审批后生效……」,而切分正好落在那个句号上,后半句躺在下一个 chunk 里,top-k 没捞到。
问题从来不在模型,在切文档那三行代码。
默认参数是给英文文档调的
我们当时用的是 Spring AI 1.0.0 的默认切法:
// 等价于 new TokenTextSplitter(512, 128, 50, 2048, true)
// 依次是 chunkSize / minChunkSizeChars / minChunkLengthToEmbed / maxNumChunks / keepSeparator
List<Document> chunks = new TokenTextSplitter().apply(documents);512 token 听着挺宽裕,但中文一个字差不多就是一个 token,512 token 只有三四百字,一条制度条款经常刚好跨在边界上。更关键的是 TokenTextSplitter 不认段落,它按 token 数硬切,\n\n 和 ## 一视同仁。
改法是先按结构切一刀,再交给 token 切分兜底:
var structural = new TextSplitter() {
@Override protected List<String> splitText(String text) {
return Arrays.stream(text.split("(?=\\n#{1,3} )|\\n{2,}"))
.filter(s -> s.strip().length() > 20)
.toList();
}
};
var fine = new TokenTextSplitter(256, 50, 20, 4096, true);
List<Document> chunks = fine.apply(structural.apply(documents));我攒了 50 个真实提问做回归集,三档跑下来:512 无 overlap 的 recall@4 是 76%,512 + 128 overlap 是 81%,256 + 50 overlap 加结构切分是 89%。再往下切到 128,单个 chunk 信息太碎,反而掉到 83%。
顺手把 QuestionAnswerAdvisor 的 topK 从默认的 4 提到 10——默认值是给 512 chunk 设计的,chunk 变小了召回条数就得跟着变大。
最优输入不等于最大输入
text-embedding-3-small 最大能吃 8191 token,但实测最优输入长度在 512 token 附近;bge-large-zh-v1.5 也是 512。按「最大值」分块是最常见的误解,按最优值切通常能多拿十几个点的精度。塞满 8191 token 那一条向量,语义已经被平均没了。
换 embedding 模型那天,服务起不来了
中文场景下开源 embedding 召回明显吃亏,我们决定从 text-embedding-3-small 换到 bge-large-zh-v1.5。改完配置重启,启动直接炸:
ERROR: expected 1536 dimensions, not 1024text-embedding-3-small 是 1536 维,bge-large-zh-v1.5 是 1024 维,而 pgvector 的列类型在建表时就写死成 vector(1536) 了。Spring AI 换向量库只需要换 starter 依赖、接口不变,但 维度是数据库 schema 的一部分,它不会帮你迁移:
spring:
ai:
vectorstore:
pgvector:
initialize-schema: false # 生产环境务必关掉
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1024 # 必须和 embedding 模型对齐initialize-schema: true 会在启动时自动建表。开发环境很方便,生产环境是定时炸弹——我就是靠它在测试库上把表重建了一遍才反应过来。换维度的正确姿势是新建表、全量重新 embedding、再切流量:
-- pgvector 0.8.1
CREATE TABLE vector_store_v2 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
content text,
metadata jsonb,
embedding vector(1024)
);
CREATE INDEX ON vector_store_v2 USING hnsw (embedding vector_cosine_ops);全量重跑 4.2 万个 chunk,embedding 调用花了 $6 出头,比我预想的便宜,比我预想的慢——跑了快两个小时。
留下来的几条经验
| 症状 | 八成是 |
|---|---|
| 答案只有前半句 | chunk 切在了句子/条款边界,加 overlap |
| 召回一堆无关内容 | chunk 太小,或者 topK 开太大又没 rerank |
| 换了模型召回暴跌 | 维度对上了,但距离度量没对上(cosine 要归一化向量) |
| 主库 CPU 莫名飙高 | OLTP 和向量检索跑在同一个 PostgreSQL 实例上 |
最后那条值得单说:pgvector 挺好用,但它和业务表抢同一个 shared buffer pool。数据量小的时候没感觉,几百万向量往上就会把主库拖下水。到那一步就该把向量库拆出去了,不是调参能解决的问题。
回头看,这类问题最难受的地方在于它不报错。切错了照样能跑,照样能回答,只是答得不完整,而「不完整」这件事只有懂业务的人能看出来。所以后来我们做的第一件事是把那 50 个问题和期望答案存成一份回归集,每次改分块参数、换 embedding 模型、调 topK,都跑一遍。没有这份集合,任何调参都只是凭感觉。