最小 RAG:召回最高的那档切分,我不敢拿去生成
本文的每个数字都是跑出来的。 环境是 Ollama
0.33.2+bge-m3(检索)
qwen2.5:7b-instruct-q4_K_M(生成),全程离线不需要 key。 验证代码与评测集在仓库experiments/rag/。 ⭐ 语料是本仓库自己的 91 篇已发布文章(684,431 字),评测集进版本库 —— 判据要求「recall 数字必须能被别人用同一份数据复现」,所以它只能是这个。
RAG 的每一步都有几个显然的选择:块切多大、要不要重叠、用向量还是关键词。 这些选择的说法很多,而它们全都可以用一份 50 问的评测集当场分出高下。
这一篇最贵的东西不是代码,是那份评测集。 判据把这件事说死了:
没有评测集就不要写这一篇。recall 数字必须能被别人用同一份数据复现。
一、评测集的三个设计决定
每道题四个字段:问题、答案子串、答案所在文档、提问方式。
① ground truth 锚在(文档,答案子串)上,不锚 chunk 下标
🚨 这一条是能不能比较切分策略的前提。chunk 边界每种策略都不同 —— 写死 chunk 下标的话,换一种切分下标就全错了。
所以判定是:一个 chunk 算命中 ⟺ 它来自正确的文档且包含那个答案子串。
⚠️ 副作用要说清楚:把答案子串切开的策略会被判为未命中。 那不是判定不公,那正是切分粒度的代价 —— 是这一篇要量的东西之一。
② 题目分「原词提问」和「换词提问」,分开报
- 原词(26 题):问题与原文词面重叠高
- 换词(24 题):刻意避开原文的特征词
不分开的话,「向量 vs BM25 谁更好」这个问题的答案完全由我出题的风格决定, 而那是一个我可以随手拧的旋钮。
📌 这个划分是我人工判断的,不是量出来的。它是整份评测集最主观的一环。
③ 每道题的答案必须逐字在原文里,而且要自动检查
validate.mjs 逐条校验,不过就直接退出。它抓的是三类
都不报错、只让 recall 悄悄偏掉的错:
| 错 | 后果 |
|---|---|
| 答案子串不在那篇文档里 | 那题恒为未命中,而我会去怪检索器 |
| 文档 id 拼错 | 同上 |
| 答案太短太常见 | 本文里到处都是,命中判定偏松 |
它当场抓出我自己写的 4 个偏松锚点 —— exit 2、lockable、my-portfolio、
grill-me 在各自那篇里出现 9~15 次。已换成更长的特征子串。
二、五种切分 × 三种检索
三个检索器都是自己实现的几十行(向量用 bge-m3 余弦,BM25 用标准 k1=1.5, b=0.75,
混合用 RRF)。不引检索库的理由:这一篇要量的是「切分粒度和检索方式各贡献多少召回」,
而库会把这两件事和它自己的默认参数混在一起,量出来的是那套默认值。
| 切分策略 | 块数 | 长度中位数 | 最长 | 可达上限 | 向量 | BM25 | 混合 | 混合 top-5 字数 |
|---|---|---|---|---|---|---|---|---|
| 定长 300 | 2315 | 300 | 300 | 50/50 | 34/50 | 36/50 | 40/50 | 1473 |
| 定长 600 | 1181 | 600 | 600 | 50/50 | 30/50 | 38/50 | 41/50 | 2929 |
| 定长 600 + 150 重叠 | 1554 | 600 | 600 | 50/50 | 34/50 | 40/50 | 41/50 | 2919 |
| 按标题 | 1293 | 389 | 22586 | 50/50 | 37/50 | 39/50 | 44/50 | 3384 |
| 按段落合并 | 1326 | 540 | 2227 | 50/50 | 36/50 | 38/50 | 42/50 | 2653 |
混合检索在每一档都不低于两个单独的检索器,最好的一档 44/50 —— 比该档最强的单一检索器(BM25 39/50)多 5 道。这一条符合预期。
下面两条不符合。
🚨 召回最高的那一档,我不敢拿去生成
「按标题」赢了(44/50)。但看最后一列:它的 top-5 平均 3384 字, 是「定长 300」(1473 字)的 2.3 倍 —— 而后者拿到 40/50,只差 4 道。
原因不神秘:recall@5 天然偏爱大块。块越大,越可能装着答案。 而它最长的那个块有 22586 字 —— 拿它去生成,光一个块就把上下文吃掉一大截。
⇒ 「哪种切分召回最高」和「哪种切分最好」是两个问题。 所以第四节让模型作答时,我用的是「按段落合并」(最长 2227 字), 不是召回最高的那一档。
只报召回不报 top-5 的代价,就是把一个指标偏向读成了结论。
重叠只对向量有用
「定长 600」→「定长 600 + 150 重叠」:向量 30 → 34(+4),BM25 38 → 40(+2), 混合 41 → 41(一道没多)。块数多了 32%。
重叠的作用是“别把一句话切两半”,而 BM25 靠词项命中,本来就不太在乎句子完整; 混合已经把两边的强项吃掉了,所以重叠那点增益在融合后被吸收掉了。
⚠️ 「可达上限」那一列全是 50/50,但这不等于「切分从不切断答案」。 答案子串在原文里常常出现多次,只要有一次落在块内就算可达。 脚本另外数了「至少被切断过一次」的题数:只有 0~1 道。 所以这份评测集几乎没有压测「边界切断答案」这个失效模式 —— 要压测它,得挑那些全文只出现一次、长度又接近块长的答案。本轮没做。
三、「向量擅长换词提问」——这一轮没测出来
拆开原词 / 换词那张表的动机就是这个预期。结果:
| 切分策略 | 原词·向量 | 原词·BM25 | 换词·向量 | 换词·BM25 |
|---|---|---|---|---|
| 定长 300 | 19/26 | 23/26 | 15/24 | 13/24 |
| 定长 600 | 15/26 | 23/26 | 15/24 | 15/24 |
| 定长 600 + 重叠 | 20/26 | 23/26 | 14/24 | 17/24 |
| 按标题 | 22/26 | 23/26 | 15/24 | 16/24 |
| 按段落 | 19/26 | 22/26 | 17/24 | 16/24 |
- 原词提问:BM25 明显更好(22
23/26 vs 向量 1522/26), 而且它对切分策略几乎不敏感 —— 23、23、23、23、22。 - 换词提问:基本打平(向量 14
17,BM25 1317)。 有一档 BM25 反超(17 vs 14),有一档向量反超(17 vs 16)。谁赢取决于切分。
⇒ 量到的不是「向量擅长换词」,是**「BM25 的优势集中在原词提问上, 而换词提问上两者一起掉到六成左右」**。
📌 一个说得通但没测的解释:我那些“换词”题里仍然留着数字和专有名词
(q4_K_M、TimSort、43.4%),BM25 靠这些就够命中了。
要验证得再造一批连数字都换掉的题 —— 那时向量才有机会显出差别。
本轮没做,所以第三节的标题是「没测出来」,不是「不成立」。
顺带看那 5 道三种检索都没找到的题,共同点很清楚:
| 类型 | 答案 | 难在哪 |
|---|---|---|
| 换词 | $0.30 vs $3 |
符号串,二元组切不出词,向量也不认 |
| 换词 | 2 – 3 分钟 |
中间是带空格的 en dash |
| 换词 | core profile |
问题里没有 profile 这个词 |
| 换词 | 5 个精选 |
极短,且全由高频词构成 |
| 原词 | 3×3 |
问「覆盖哪几种形状」,答案是纯符号 |
⭐ 共同点不是「问得像不像原文」——那道原词提问失败的答案也是符号。 是答案本身没有可检索的词。
四、检索到了,和答对了
把「检索命中」和「回答正确」交叉制表(按段落切 + 混合检索 + 本地 7B):
| 回答正确 | 回答错误 | |
|---|---|---|
| 检索命中 | 35 | 7 |
| 检索未命中 | 0 | 8 |
- 检索召回 42/50,端到端答对 35/50
- 🚨 检索命中但回答错误:7/42,占已召回的 17%
也就是说:每六道已经把答案摆在模型面前的题,有一道还是答错。 这部分损失与检索质量完全无关,换更好的 embedding 一道也救不回来。
那 7 道还得再拆一刀,因为两种毛病的修法不同:
| 形态 | 例子 | 修法方向 |
|---|---|---|
| 弃答 3 例 | 问「差分比朴素快多少」→ 答「片段里没有」,而 2.23× 就在片段里 |
块太大把答案淹了 → 缩小块 / 加重排 |
| 挑错行 4 例 | 问「后端成本放大最小是多少倍」→ 答 1.20×(正确是 1.05×) |
表格类内容 → 生成时保留行标签 |
📌 挑错行那 4 例全部发生在表格上(1.20× 该是 1.05×、4.50 bit 该是
6.35 bit、约191倍 该是 31%、技巧列表漏了一个 8)。
检索把整张表交给了模型,而模型在表里认错了行。
五、那个 17%,第一版是 32%
第一版判定直接写 said.includes(答案子串),跑出「命中但答错 13/41(32%)」。
逐例一看,八个里有四五个是判定的错不是模型的错:
| 应含 | 模型答的 | 差在哪 |
|---|---|---|
第 13 位 |
13 |
少了「第…位」 |
70 分 |
打了70分 |
少一个空格 |
约 50% |
50% |
少一个「约」 |
200 词 |
200 个填充词 |
量词不同 |
-90% |
降了约 90% |
少一个负号 |
真错的只有 1.05×→1.20×、6.35 bit→4.50 bit 这类数值本身不对的。
⭐ 病根是一个字段被当成两个东西用:
| 字段 | 用途 | 要求 |
|---|---|---|
| 答案子串 | 检索锚点 | 必须逐字在原文里,否则 recall 判不了 |
| 答案判据 | 判生成对不对 | 只保留必须出现的核心值 |
它们的要求是矛盾的:检索锚点要长要特征,才不会误命中;
答案判据要短要宽松,因为自然语言回答不会照抄原文的空格和量词。
拆成两个字段、再加一层归一化(去空白、×→x、–—→-、去中文标点),
同一批回答从 12/42(29%) 变成 7/42(17%)。
📌 脚本现在两个数都打印。差值不是模型的差别,是判定选错字段的代价。
六、那一格必须是 0
2×2 里「检索未命中却答对」那一格,第一版是 1/50。
它的存在理由是:本站的事实(自己实测的数字)不可能在模型的参数里, 所以这一格理论上必须是 0。 它不是 0,说明判定在从别处拿答案。
查出来的原因不是泄漏,是我评测集标错了:
65519那个实测在《回溯与 DFS》那篇,而《图论基础》只是引用它 (「那篇实测过 43 次 vs 65519 次调用」)。我把 ground truth 标在了引用方而不是来源。
于是检索取到了含答案的另一篇,判定说“未命中”,模型答对了。重标之后回到 0/50。
⭐ 不看这一格的话,那道题会一直以「检索失败」的身份计入 recall, 而 recall 数字看起来完全正常。
七、回到判据
没有评测集就不要写这一篇。recall 数字必须能被别人用同一份数据复现。
判据成立:语料是这个仓库的已发布文章,评测集在 experiments/rag/evalset.json,
复现的全部前提是「克隆仓库 + ollama pull bge-m3」。
跑完之后要给它补两条,因为光靠“可复现”挡不住上面那两次自伤:
- ⭐ 评测集自己要有自检,而且自检要在主流程里跑一遍。 答案子串不在原文里、文档标错、锚点太常见 —— 这三类都不报错, 只让 recall 悄悄偏掉。而第一条和第二条我都真的犯了。
- ⭐ 判「检索对不对」和判「回答对不对」必须是两套判据。 前者要长要特征,后者要短要宽松,要求是矛盾的。 合用一个字段,代价是 29% 对 17% —— 而错的那一版看起来更“严格”, 更容易让人以为自己在认真评测。
本文没有回答的三个问题:
一是 中文分词。本机没装分词器,BM25 用的是「ASCII 词 + CJK 二元组」 (「单调栈」→
单调、调栈)。这是BM25 那一列的前提,不是实现细节 —— 换成 jieba 之后那一列会变,而向量那列不受影响。 也就是说第三节「BM25 在原词提问上更强」这个结论带着一个未测的前提。二是 重排(rerank)。全篇只有召回,没有重排模型。 第四节那 3 例弃答正是重排该救的场景,而本文没测它能救回几道。
三是 切断答案的压测。「可达上限」全是 50/50,是因为答案在原文里 常常出现多次。要真的量「块边界切断答案」的代价,得专门造一批 全文只出现一次、长度接近块长的答案。