你的 RAG 缺的不是更好的嵌入模型,而是更好的分块

Dev.to ML 2026-10-30T09:53:03.116089

过去一周,我一直在用 LongMemEval 测试 Palace —— 一个我为编码代理(coding agents)做的本地记忆库。它是一个单一的 Rust 二进制程序,配了 SQLite、BM25 和一个小型 ONNX 嵌入模型。写入和召回这两条路径里都没有任何大语言模型(LLM)。这篇文章不讲最终分数,讲的是我在推进过程中学到的东西:哪些改动提升了召回,哪些没有,以及多少所谓的「改进」其实只是普通 bug —— 不跑一遍基准测试根本发现不了。

先给个简版结论,不想往下看也可以就此打住:

测的是什么

LongMemEval 会针对一段很长的聊天历史提问题。在 s_cleaned 划分里,每道题都配了一个约 50 个会话(大约 11.5 万 token)的语料库(haystack,也就是「大海捞针」里那堆针),而能回答问题的会话就藏在这堆东西的某处。有 470 道题参与打分;30 道「弃权」题照例排除在外。

我只测检索:正确的会话有没有出现在 top K 里?指标是会话粒度上的 recall_any@K。这和很多记忆系统报告的端到端 QA 准确率不是一回事 —— 那种是让 LLM 读完检索到的上下文再作答。检索召回是读取模型能达到的上限,也是我能控制的部分。所以请别把这些数字跟 QA 准确率表去比,它们量的是不同的东西。

本文每次跑的配置

有个数字值得从头到尾记在心里:一道题等于 0.0021。在 470 道题下,所谓「+0.4 个点」的提升,其实就是两道题。

我尽量说清楚每次改动到底让多少个问题发生了变化,因为小数位会让很小的差异显得比实际更大。先说起点:这个数字已经悄悄变了。今年 5 月,README 里写的是 R@5 0.981。到了 10 月,我用同一套配置重跑,得到 0.977。我第一反应是“噪声,再跑一次”。但这里根本不存在噪声。检索是确定性的:固定的 ONNX 模型、BM25、余弦相似度,没有采样。每次重跑都是同样的 0.977。所以确实有两个问题发生了变化,而且是代码里的某些改动导致的。自 5 月以来,排序器在原本的 BM25/余弦融合之上多了几层加权:编码智能体意图加权、偏好匹配、时效性。我把每个问题的结果和 5 月基线做了 diff,找到了第一个“元凶”:「查询里包含“latest”这个词」。意图分类器在词里看到了“test”,判定这是关于跑测试的问题,于是把一个不相关的抽屉提到了前五。修法是只在词首匹配意图词(词形变化仍然匹配)。R@5 从 0.977 回到 0.979,编码智能体那项评测没有变化。第一条教训:README 里的 benchmark 数字,是对你当前代码的一个声明。排序器一变,这个数字就得重新挣回来。复现旧数字,是发现哪里坏了的好办法。

换了模型,什么都没变

我刚加上可切换 embedding 模型的支持,所以最直接的问题就是:换个更好的模型会有帮助吗?我用同一套按会话(per-session)的配置跑了五个模型:

模型 R@1 R@5 R@10 摄取耗时(秒)
minilm-l6-v2 (fp32) 0.889 0.977 0.991 508
minilm-l6-v2-q8 (int8) 0.891 0.977 0.991 355
bge-small (fp32) 0.898 0.972 0.985 920
bge-small-q8 (int8) 0.898 0.972 0.985 622
bge-base-q8 (int8, 768 维) 0.887 0.970 0.979 1181

表里每一个召回率的差异,都只对应 1 到 4 个问题。BGE 的 R@1 略好一点,主要好在单会话用户问题上,但代价是多会话问题上 R@5 和 R@10 变差。没有任何一个模型对 MiniLM 的优势,能让我愿意称之为真实提升。真正实打实的是速度:int8 的 MiniLM 召回率持平,摄取速度快了大约 1.4 倍。

这让它成了更有意思的选项——不是因为更聪明,而是因为在给出同样答案的前提下更便宜。

第二课:在检索基准测试里,瓶颈根本不在嵌入模型。我原本以为会是它。十二层的模型每次嵌入的成本是六层模型的两倍,而在这份数据上,我什么也没换来。

插曲:分批反而比不分批更慢

在估算模型规模时,我在真实抽屉上测了嵌入吞吐,结果有点反直觉:一次嵌入一条文本能做到 94 个抽屉/秒,而分批反而更慢——批大小 16 时 69/秒,32 时 53/秒,64 时 42/秒。原因是填充(padding)。一个批次会被补齐到其中最长的文本,于是只要抽屉里有一条长文本,整批里所有短文本都得陪着它为长度买单。按长度排序、把批大小压到 8 之后,吞吐从 29/秒(旧的分批路径)提升到了大约 110/秒。

另一段插曲:int8 向量会受邻居影响

后来我把 int8 模型设为新宫殿(palace)的默认值,结果一个已有测试开始报错。这个测试检查的是:把一条文本放在批次里嵌入,得到的向量应当和单独嵌入它时一致。对 fp32 来说,「分批」和「单独」之间最差的余弦相似度是 0.999999;对 int8 来说,这个值掉到了 0.976。

动态量化的 ONNX 模型会在整个输入张量上计算激活缩放系数,也就是说,是在整个批次上算。把一条文本放在一个又长又古怪的邻居旁边,它的向量就会偏移。对一个记忆存储来说这是真问题:同一句话可能因为同一次调用里还挖到了别的东西,就落到略微不同的位置。

修法是:int8 模型每次调用只嵌入一条文本。这会牺牲一部分加速,但 q8 模型建索引仍然比 fp32 快(在真实宫殿抽屉上是 150 对 132 个抽屉/秒,在更长的合成文本上大约快 1.7 倍),而且现在一条文本的向量只取决于它自己。要不是这次切换,我根本发现不了这个问题;而要不是基准测试告诉我 q8 是「免费」的,我也不会去切换。

分块:测你真正上线的东西

LongMemEval 的常规设置是把每个会话存成一份文档。但 Palace 存对话不是这么干的。它在挖掘聊天记录时,会把它切成「问答对」:每个用户轮次加上对应的回复算一块。

所以这个基准测试测的是一个用户根本不会用到的配置。我给运行器加了一个 --chunking 参数,支持三种模式:

在分块模式下,会话按它表现最好的那个块来计分。

分块方式(q8) R@1 R@5 R@10 抽屉数 耗时
session 0.891 0.979 0.991 22k 351 s
text 0.896 0.983 0.996 401k 1016 s
exchange 0.906 0.985 0.994 194k 1150 s

按「问答对」分块(exchange)的方案赢了,而且提升最大的地方恰恰是我最没料到的:单会话偏好这一项的 R@1 从 0.633 涨到了 0.767。

一条偏好(比如「我最近在试着少喝咖啡」)往往只是埋在一大段聊别的事情的会话里的一句话。如果整段会话当成一个大文档,它的向量会被其他所有内容带偏。而拆成问答对之后,这条偏好轮次就单独有了自己的向量。

text 模式是个很好的对照:它的抽屉数是 exchange 的两倍,效果却更差。所以关键不是「切得更多、更碎」,而是「切出来的块要贴合对话本身的形状」。

分块器一直在丢文本

把真正的分块器接进基准测试后,反而挖出了产品里的一个 bug。exchange 分块器只保留用户那一轮,加上回复的前 8 行,剩下的就被悄悄丢掉了。只要助手回答得长一点,第 8 行之后的内容全部丢失。

一直没人发现,因为从来没有任何东西拿「挖出来的对话」和原文做过比对。而用真正的分块器跑过几千个已知答案的会话之后,问题就暴露了。

现在回复会完整保留。超过约 2,000 字的问答会被切成后续的若干块,每一块都重复带上用户那一轮的问题,这样问题上下文就能跟着答案的每一段一起走。用户轮次少于三次的短会话,则退回段落分块,和产品里的做法一致。

第三课:要拿用户实际会跑的那条代码路径来做基准测试,而不是图省事找个替身。如果我当初一直用「每会话一个大文档」的配置,我得到的数字会稍微低一点,而分块器还会继续把回复吃掉。

排序环节:三个通用机制和一条坏规则

到这里,我给自己定了一条规矩,后面所有改动都受它约束,所以值得原样引出来:“我们不是在为分数调优,我们是在为输出调优。”LongMemEval 只是一个代理指标。通过读失分案例去找通用弱点是好事;为修好某个具体问题而写规则不是。任何改动都必须是一个能真正帮到实际记忆宫殿的机制,而且在上报任何数字之前,必须用另一套不同于启发它的数据来验证。按这个前提,排序器改了三处。

1. “should I”并不属于约定类问题

记忆宫殿里有个意图分类器,听起来像项目约定的问题(“我们该用 tab 吗?”)会被加分、往约定类抽屉里靠。触发词里包含“should I”。LongMemEval 里有一道偏好题正好以“should I”开头,分类器把它归到了约定类,给错的抽屉加了分。我把触发条件收窄了。那道题恢复正确,而且在 coding-agent eval(另一套独立测试集,不是 LongMemEval)上 R@1 也涨了。

2. 日期感知排序

时间类问题(“两周前我买了什么?”)是我最想改善的类别。在真实记忆宫殿里,每个抽屉都有日期,那么指向某个时间窗口的问题就该偏向那个窗口里的抽屉。为了让这件事可测,测试运行器得先停止往反方向“作弊”:现在每道题都用它自己的 question_date 提问,每个抽屉用它会话的日期打标。像“10 天前”“上个周二”“过去一个月”这样的相对日期,现在会像在真实宫殿里那样被解析出来。排序器解析这些时间区间,给窗口内的抽屉一点小加分(权重 0.12)。构建过程中揪出两个 bug:像“两周”这样的区间要按整段算,而不是当成一个时间点;句子里的“在周二”通常根本不是时间指代,所以这种模式会跳过。

如实汇报:在 s_cleaned 上,时间类 R@5 没动。剩下的三个时间类失分案例输在主题上,不是输在日期上。标准答案块确实来自正确的那一天,但得分仍然低于同一天里不相关的块。日期感知能帮忙打破平局,但救不了一个看起来就不相关的块。

在更大的“草堆”里、每天有很多会话的情况下,它也基本只是个“决胜项”。我还是把它留下了,因为在真实的使用场景里,“我们上周决定了什么”是非常常见的问题,即便这个数据集对它的回报不大,机制本身是对的。

3. 多个位置都能匹配的来源

当一个会话按最佳片段排序时,“三个不同对话片段都命中了问题”的会话,看起来和“只有一个片段命中”的会话一模一样。这其实是在丢掉证据。现在,同一个来源里其他匹配度较高的片段(drawer),会给当前片段一个小幅加成(权重 0.1)。我是用另一个数据划分,外加真实场景的探针来挑这个权重的,选定后就冻结了,然后只跑了一次 s_cleaned。0.1 效果最好;0.3 已经起反作用了。

我依然保留的那条“坏规矩”

既然要说实话就说到底:Palace 那个独立的编码 agent 评测是做过部分调优的。排序器里包含了一小批从该评测自己的题目里抄来的短语。带上这些短语,int8 模型在上面能拿到 R@1 0.922;不带,只有 0.902。README 里写明了这点,两个数都列出来了。它们对 LongMemEval 和真实场景的探针毫无影响,我保留它们只是权宜之计,等通用机制追平同样的数字就撤掉。我提这件事,是因为如果没人披露,这种操作正是让 benchmark 数字变得毫无意义的典型原因,而且一不小心就会做出来。

最终结果

用对话片段分块、int8 MiniLM,再加上上面的排序改动(在开跑前就冻结了):

配置 模型 R@1 R@5 R@10
每个会话一个 drawer minilm-l6-v2 0.896 0.981 0.991
每个会话一个 drawer minilm-l6-v2-q8 0.898 0.981 0.991
对话对(exchange pairs) minilm-l6-v2-q8 0.915 0.987 0.996

按问题类型拆分(对话对,q8):

问题类型 R@1 R@5
knowledge-update 0.958 1.000
multi-session 0.909 0.992
single-session-assistant 1.000 1.000
single-session-preference 0.767 0.933
single-session-user 0.984 1.000
temporal-reasoning 0.858 0.976

从这周初到现在,对话对从 0.906/0.985/0.994 提升到了 0.915/0.987/0.996。也就是 R@1 又多对了四道题,R@5 多对了一道。按会话统计,R@5 回到了 0.981,也就是五月份的数字,但这次用的是真正在仓库里的代码。

仍然薄弱的地方

偏好类(Preferences)。依然是最弱的一类。

“我喜欢 X”这类句子,和真正需要用到它的问题(比如“今晚我该做什么饭?”)几乎不共享任何词汇,所以 BM25 帮不上忙,384 维的嵌入也只能部分解决。交换分块帮了大忙。剩下的问题大概得从结构上入手,而不是换个新权重。

那些因话题而落败的时间类问题。 如上所述。这不是日期的问题。s_cleaned 已经接近天花板了。在 R@5 达到 0.987 时,剩下的未命中只有少数几个问题,而且非常容易不小心就对它们过拟合。这也是为什么源权重是在别处选定的,也是为什么我对最后几个点的解读格外谨慎。我还在跑大得多的 m_cleaned 拆分集,那里的数字更低,也更有参考价值。等我对结果满意了再写。

这是检索,不是回答。 下一个诚实的步骤,是把其他记忆系统也放进同一套测试框架里跑,这样比较才是同类对同类,而不是跨不同指标。

一周前我会告诉自己的事

追新数字之前,先重跑旧数字。 漂移直接指向了一个 bug。

数问题个数,别数小数位。 这里一个问题就是 0.0021。大多数“改进”不过是一两个问题的事。

测你实际部署的那条路径。 产品里的分块器胜过了标准配置,而且测试它还发现了一个一直在悄悄丢文本的 bug。

更大的模型不是白捡的便宜。 在这份数据上,它更慢,也没更好。

量化的副作用不止于精度。 int8 向量会依赖批次里的邻居。一个很无聊的相等性测试就抓到了它。

在你不会拿来报告的数据上挑权重,冻结它们,然后只跑一次。 公开你调过的地方。哪怕很小,哪怕放在单独的评估里。

复现它

这里的一切都能从仓库本地运行。基准测试运行器、每一行的确切命令,以及逐问题的输出,都在 benchmarks 文档里:

https://github.com/AncientiCe/palace-rs

如果你在不同硬件或另一个拆分集上跑出不同的数字,我想听听。

有个问题想问你:还有谁见过 int8 或量化嵌入会随批次里其他内容而漂移?我很想知道是不是只有我的环境这样。

过去一周,我一直在用 LongMemEval 测试 Palace。Palace 是我为编码智能体(coding agent)做的本地记忆库,就是一个 Rust 单二进制程序,里面装了 SQLite、BM25 和一个小型 ONNX 嵌入模型。写入和召回这两条路径上都没有大语言模型(LLM)参与。

这篇文章不讲最终分数,而是讲我在摸索过程中学到的东西:哪些改动能提升召回率,哪些不能,以及有多少所谓的“改进”其实是普通 bug,只有跑一遍基准测试才会暴露出来。

先给个精简版,看到这儿你就可以打住了。

查看原文