最小 RAG:召回最高的那档切分,我不敢拿去生成

RAG 与记忆系统第 1 / 2 篇

本文的每个数字都是跑出来的。 环境是 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 明显更好(2223/26 vs 向量 1522/26), 而且它对切分策略几乎不敏感 —— 23、23、23、23、22。
  • 换词提问:基本打平(向量 1417,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」。

跑完之后要给它补两条,因为光靠“可复现”挡不住上面那两次自伤:

  1. ⭐ 评测集自己要有自检,而且自检要在主流程里跑一遍。 答案子串不在原文里、文档标错、锚点太常见 —— 这三类都不报错, 只让 recall 悄悄偏掉。而第一条和第二条我都真的犯了。
  2. ⭐ 判「检索对不对」和判「回答对不对」必须是两套判据。 前者要长要特征,后者要短要宽松,要求是矛盾的。 合用一个字段,代价是 29% 对 17% —— 而错的那一版看起来更“严格”, 更容易让人以为自己在认真评测。

本文没有回答的三个问题:

一是 中文分词。本机没装分词器,BM25 用的是「ASCII 词 + CJK 二元组」 (「单调栈」→ 单调、调栈)。这是BM25 那一列的前提,不是实现细节 —— 换成 jieba 之后那一列会变,而向量那列不受影响。 也就是说第三节「BM25 在原词提问上更强」这个结论带着一个未测的前提。

二是 重排(rerank)。全篇只有召回,没有重排模型。 第四节那 3 例弃答正是重排该救的场景,而本文没测它能救回几道。

三是 切断答案的压测。「可达上限」全是 50/50,是因为答案在原文里 常常出现多次。要真的量「块边界切断答案」的代价,得专门造一批 全文只出现一次、长度接近块长的答案。