项目跑通的那天晚上,我拿自己简历做了第一次测试。
问了一句"我的工作经历是什么?"——系统返回的内容,是另一家公司的经历,引用来源也指错了。
我一开始以为是切片切得不好,调了一晚上,没用。后来意外换了个 embedding 模型——问题反而解了。
但接着又冒出另一个问题:我问"A 公司和 B 公司有什么区别",系统直接命中了上一个问题的缓存,答了 A 的内容。
一个我以为已经"做完"的 RAG 项目,在拿真实文档测的那一刻,暴露了 3 个我之前完全没意识到的问题。
这篇文章讲清楚这 3 个坑——它们都不是大模型的问题,而是几个看起来朴素、但每一个都能让整个系统翻车的工程决策。
一、切片不是越大越稳
最开始我做文档切片时,思路很直接:按固定长度切。
每 500 字符一段,相邻片段重叠 120 字符。这个方案很好实现,也很常见——只要每个片段不超过 embedding 模型的输入长度,看起来就能跑。
但实际问答时,效果没有想象中稳定。
有些问题文档里明明有答案,但召回回来的内容很长,里面混了很多无关信息。模型最后也能回答,但经常回答得不够干净,有时候还会把旁边的内容一起带出来。
我反过来想了一下:用户问的很多问题,其实答案只有几十个字。
试用期是多久? 报销发票需要保留多久? 哪些情况可以转人工?
这些答案可能只在文档某一小段里。如果我把它塞进一个 500 字符的 chunk 里,那么向量表示的就不是这条规则本身,而是一整段混合语义。
这会带来两个问题。
第一,检索时相似度被稀释。一个 chunk 里主题太多,embedding 表达出来的是一团平均后的语义,不一定能精准命中用户的问题。
第二,召回后上下文噪声变多。模型拿到的内容里有答案,也有无关内容。它可能回答对,但也更容易把无关信息揉进去。
所以我把切片策略改了。
不是先按固定字符切,而是先尽量尊重语义边界,递归往下切——参考的是 LangChain 的 RecursiveCharacterTextSplitter 思路,自己用 Java 实现了一份:
- 先按段落分隔(
\n\n)切 - 段落太长,再按行(
\n)、按中英文句号、感叹问号切 - 还太长,降级到子句分隔符(
,;)、空格,最后才按最大长度硬切 - 切完之后再把过短的相邻片段合并(我设了一个最小长度 200 字符,小于这个值就向后合并),避免出现"姓名"这种碎片单独成片
- 切片之间保留 120 字符 overlap——经验值,够保住关键信息跨边界的情况,又不会让向量库重复存储太多
overlap 这一步看起来不起眼,但很关键。因为有些关键信息刚好跨在边界上——前一句说条件,后一句说处理方式。如果切片完全没有重叠,检索时可能只召回其中一半,模型看到的信息就是断的。
排查的时候,我的思路也很简单:
先不要急着调模型,先把用户问题、召回 chunk、chunk 来源、相似度分数全部打印出来。
- 如果召回的 chunk 里答案只有一小句,旁边全是无关内容 → 切片太粗
- 如果答案被拆到两个 chunk 里,只召回了其中一个 → 检查 overlap
- 如果召回内容完全不是一类问题 → 那就是后面要讲的 embedding 问题
切片做对,只是 RAG 的地基。地基不平,后面所有优化都是在歪楼上加层。
二、切片做对了,系统还是答错——问题在 embedding
切片改完之后,我以为效果至少能稳一半。
结果新一轮测试,问题还在,而且更隐蔽了。
我问"我在 A 公司做了什么"——回答里夹着 B 公司的内容。 我问"我在 B 公司的职责"——又冒出 A 公司的细节。
每一次召回的 chunk,单看都"看起来对"——确实是工作经历那一段。但具体是哪一段,系统好像分不清。
我打了一次相似度日志,发现一个反直觉的事实:简历里 3 段不同公司的工作经历,在 embedding 空间里两两相似度都在 0.85 以上。
这是 embedding 模型本身的问题。
简历里每一段工作经历,句式高度结构化:
2015-2018 某公司 Java 开发工程师 负责 XX 系统的 YY...
2018-2021 某公司 高级工程师 主导 XX 平台的 YY...
2021-至今 某公司 架构师 负责 XX 架构的 YY...
句式相似 + 结构相似 + 用词相似——在 embedding 模型眼里,这几段"几乎是同一回事"。
我当时用的是阿里云 DashScope 的 text-embedding-v4(1536 维)。通用场景下口碑不错,但对**简历这种"段落语义高度相似但实际指向不同对象"**的文档,它分不开。
转机来得有点意外。
那段时间我的 DashScope token 额度用完了,我懒得充值,顺手把 embedding 换成了本机用 Ollama 跑的 bge-m3(1024 维)重跑了一遍——
效果立刻好了。
同样的切片、同样的检索逻辑、同样的问题——回答开始指向正确的公司,引用也对了。
这里要补一句开头那个引用来源指错的问题——它和"答非所问"其实是同一个根因的两面。每个 chunk 入库时,我都把 recordId、原始文件名、页码这些信息塞在 metadata 里;最终前端展示的引用,就是从命中的 chunk metadata 里读出来的。所以一旦检索召回的是错的那段,LLM 看到的内容是 B 公司的,前端展示的引用来源也跟着指向 B 公司——内容和引用是同步错位的,不是两个独立的 bug。换 embedding 之后召回准了,这两件事就一起好了,我没再单独动引用相关的代码。
bge-m3 在中文场景上做了大量针对性训练,对"中文里语义微弱差异"的捕捉,确实比通用模型强一档。
(顺带一提,换 embedding 模型不是改个参数那么简单。维度从 1536 变 1024,pgvector 表必须 DROP 重建,旧的向量数据全部要重新跑一遍——这是迁移过程中真实付出的代价。)
这件事的反思是:
很多人做 RAG 优化时,会一头扎进切片、prompt、检索策略——但 embedding 模型本身的选择,可能比这些参数加起来还重要。
如果 embedding 拿到的向量本身区分不开,后面再怎么调,都是在错的输入上做正确的事。
切片决定信息怎么被切开,embedding 决定这些信息能不能被分辨。前者是地基,后者是地基下面的土质——土不行,地基再平也立不住。
三、缓存命中错了,以为是相似度问题,其实不只是
切片调好、embedding 换好之后,系统看起来终于稳定了。
我以为这次真做完了。直到我问出这一句:
"A 公司和 B 公司有什么区别?"
系统回了一段——是 A 公司的工作内容。
完整不缺,引用也对。但它答的不是我问的。
排查之后我才发现,问题出在我加的语义缓存上。
为了省 token + 加快响应,系统在收到问题之后会先做一步:把问题向量化,在缓存里查有没有相似的老问题——如果相似度足够高,直接返回老答案,不走完整的检索 + LLM 流程。
这个设计在大部分场景没问题,直到遇到"对比类问题"。
"A 公司工作内容是什么" 和 "A 公司和 B 公司有什么区别"——这两个问题在 embedding 空间里相似度极高(我打日志看了下,实测 score 是 0.75),原来阈值定的是 0.68,直接被判定为"同一个问题",返回了上一次的缓存答案。
第一反应,我把阈值从 0.68 提到了 0.92。
跑了一段时间,对比类问题不再误命中了。但很快出现了新的问题——
很多本应该命中缓存的同义问题,也不命中了。
"A 公司做啥" 和 "A 公司的职责是什么" —— 语义完全相同,但相似度可能只在 0.88~0.91 之间,卡在阈值之下。
顺便提一句,这里其实还藏着另一个坑:0.68 这个阈值不是凭空写错的,是我换 embedding 模型之前在 text-embedding-v4 上调过的。换成 bge-m3 之后,整个 score 分布抬高了一个档(同义改写经常 ≥ 0.95,不同意图也常常 ≥ 0.7),旧阈值就完全失效了。
换 embedding 模型 = 所有阈值要重新校准,这一条在文档里写一百次都不过分。
提高阈值解决了一个问题,又制造了另一个问题。
我反过来观察这两类问题的差异,看能不能找一个"正交"的信号——
- "A 公司工作内容" → 7 字
- "A 公司做啥" → 5 字
- "A 公司和 B 公司有什么区别" → 14 字
我发现:对比类问题、追问类问题,天然比原问题长。原问题问"是什么",对比/追问会加上限定语、对比对象、补充条件——长度往往会增加 50% 以上。
所以我加了第二个维度的判定:
相似度 ≥ 0.92 且 长度差比例 < 0.4 → 才命中缓存
双指标组合。一个看语义,一个看意图差异的物理特征。
这次,既保住了同义问题的缓存命中,又拦住了对比/追问类问题的误命中。
这里要诚实说一下:0.92 这个数字不是从评测集里跑出来的,是一个工程估计。它是这样推出来的:
- 已知必须挡住的下限:本次 badcase 的实测 score = 0.75
- 已知不能误伤的上限:bge-m3 上同义改写一般 ≥ 0.92(经验分布,不是实测)
- 中间留 buffer,直接取上限值 0.92
这是一个保守估计,宁可漏命中(无非多走一次完整 RAG),也不误命中(直接给错答案)。
它最大的弱点也很明显——只有 1 个 badcase 实测,同义改写在简历这个文档域上的真实分布我没量过。要把它升级成"可信值",得做一件之前一直拖着的事:整理 30~50 对 query(一半是该命中的同义改写、一半是该挡住的对比/追问),分别跑 score,找两个分布交集最小的那个分割点——那才是这个文档域 + bge-m3 下的真实最优阈值。在没跑这套评测之前,0.92 是性价比合理的默认值,不是最优值。
为了避免下次再出现"调个数要改代码重启"的事,我把这两个值挪到了 Nacos 配置里——assistant.chat.qa-cache.similarity-threshold 和 assistant.chat.qa-cache.max-length-diff-ratio,顺便加了 enabled 开关方便临时关闭整个缓存层做对比测试。换一个文档域(比如技术文档 QA),或者换一个 embedding 模型,这两个数字都要重新调——但双指标的设计本身,我觉得是稳的。
这件事让我重新认识缓存层在 RAG 里的位置。
很多 RAG 文章讲完检索就结束了,语义缓存这一层很少被提到。但只要你的系统上线、有真实流量、要省 token,这一层迟早要做。而做这一层时单指标判定的本能反应是不够的——
对相似但不同的问题,需要找一个"正交维度"的信号去拦住误命中。
阈值是连续的,信号要正交。这是这次踩坑给我最大的认知。
小结
这次做下来,我对 RAG 的理解,从"模型为主、工程为辅"彻底翻转了过来。
切片决定信息怎么进入向量库。 embedding 决定这些信息能不能被分辨。 缓存层决定相似但不同的问题会不会被混在一起。
这三层都在大模型之前——大模型只是最后一步,如果前面三层做歪了,大模型再聪明也救不回来。
RAG 项目最值钱的功夫,不花在 prompt 上,也不花在调用模型上,而是花在这些"看起来朴素"的工程决策里。
(项目里还踩过另一个跟引用相关、但根因完全不同的坑:引用元数据绑定错位——召回的 chunk 是对的,LLM 拿到的内容也是对的,但前端展示的引用却指向了相邻的另一段。这个问题不是检索召回错,而是 chunk metadata 在 overlap 拼接时的归属设计有歧义。改天单独写。)