作者用700行TypeScript/SQLite-Vec/Voyage/Claude搭建RAG系统后,花一周做评估实验,发现三个负面结果,其中最valuable的是揭示自身评估方法有缺陷——chunk overlap设计反而引入假相关。
我的 RAG 评估一直在骗我,而且是两次!!
我搭了一套常见的东西:一个 CLI,摄入文档、嵌入向量、用引用回答问题。Bun、TypeScript、SQLite(用 sqlite-vec 做向量索引)、Voyage 做嵌入、Claude 做生成。大约 700 行代码。这套技术栈没什么特别的——周末工作量的东西,网上有五十个教程讲这个。
真正有意思的是接下来那一周——我想看看这东西到底效果如何。我跑了四个实验,三个阴性,最有价值的一个告诉我:我的测量方式本身就是坏的。
3,200 段维基百科 passage,按 500 词分块、重叠 50 词,用 voyage-3.5 嵌入到 1024 维,存放在 vec0 虚拟表里。查询时把问题嵌入、用 L2 距离取 top k,然后把相关块交给 Claude 并指示它引用或拒绝回答。
一次查询长这样——答案流式输出,然后打印来源,* 标记的是答案实际引用到的:
$ bun run index.ts query "What is the capital of Uruguay, and how many people live there?" -k 3
Montevideo is Uruguay's capital [1][3]. Its metropolitan area is home to 1.7 million of the country's 3.3 million people [1].
[1] data/rag-mini-wikipedia.jsonl#0 — distance 0.846 [2] data/rag-mini-wikipedia.jsonl#15 — distance 0.916
[3] data/rag-mini-wikipedia.jsonl#36 — distance 0.922
三个取回的块里用了两个;第三个取到了但没用到。打印出未使用的块,这件事比我预想的更重要—— retrieval 暴露出来的内容和答案实际依赖的内容之间的差距,是你不用跑任何评估就能看出 retrieval 质量的地方。
这个数据集自带 918 对问答,随语料一起提供,这才让任何测量成为可能。它没有标注哪段 passage 回答哪个问题,所以没有 gold label 可以对比。我的代理指标:一个 retrieval 算命中,只要答案字符串出现在某个取回的块里。
麻烦就从这个代理指标开始了。
我的第一版检查规范化后的答案是否作为子串出现。简单、直接、明显——但看了一段时间才发现它错在哪里。
下面这个问题被判定为未命中:
▎ Q: What countries border Liechtenstein? ▎ Expected: "Switzerland and Austria." ▎ Retrieved, rank 1: "...a tiny, doubly landlocked alpine country in Western Europe, bordered by Switzerland to its west and by Austria to its east."
检索是完美的。评分器说不,因为确切的短语"Switzerland and Austria"从未连续出现过。
我换成了 token 重叠——去掉停用词,如果 80% 的答案内容词出现在同一个块里,就算命中。测得的命中率在 k=3 时从 65% 移动到了 78%。我的系统一直比我想的好了 13 个点。
还有第二个同类 bug。语料写的是"around 60,000",答案写的是"60000"。去掉标点后前者变成了两个 token,永远无法匹配后者。在规范化之前先把数字组合连起来,又找回了一个点。
真正让人心痛的部分:在那个坏的指标下,命中率从 k=3 之后看起来是平的——k=3、5、10 时分别是 65%、66%、67%。我得出结论:取回超过 3 个块是浪费上下文,考虑降低默认值。而用修正后的指标,曲线一直在上升:79%、82%、83%。坏的指标不只报了一个错的数字,还把我指向了错误的决策。
我一开始跑了 20 道题,因为对于一个副业项目来说那感觉是合理的评估集大小。
n=20 时我测到 50%(k=3)。n=100,同样的种子、同样的代码:65%。(两者都在上面的修复之前的原始子串指标下——这里的重点是差距,不是那个数字本身。)
这 15 个点的差距纯粹是采样噪声。20 道题时 95% 置信区间大约是 ±22 个点——宽到能吞掉任何你实际在测试的变化。n=100 时两个不同种子相差 1–5 个点,这才是可用的分辨率。
如果你的评估集小到一下午就能手写出来,那它也小到可以告诉你任何你想听的话。
混合检索——把 BM25 关键词排名和向量排名合并——是标准的下一步,而且我有一个来自自己笔记的动机案例。问"关于 refinance 我需要知道什么",正确地暴露了一篇文档——里面"refinance"和"refi"这两个词出现次数恰好为零。关键词搜索会把它排在不知道哪里。
于是我在同样的块上加了 FTS5 索引,用 reciprocal rank fusion 融合两个排名。结果,n=100:
┌─────────┬─────┬─────┬─────┬──────┐
│ │ k=1 │ k=3 │ k=5 │ k=10 │
├─────────┼─────┼─────┼─────┼──────┤
│ vector │ 69% │ 79% │ 82% │ 83% │
├─────────┼─────┼─────┼─────┼──────┤
│ keyword │ 60% │ 71% │ 78% │ 82% │
├─────────┼─────┼─────┼─────┼──────┤
│ hybrid │ 67% │ 78% │ 82% │ 83% │
└─────────┴─────┴─────┴─────┴──────┘
混合最多和向量持平,在低 k 时还输了。在动融合权重之前,我先测了原因——在 k=10 时每个检索器找到对方遗漏的东西的频率:
both retrievers found it 80
vector only 3
keyword only 2 <- everything fusion could rescue
neither 15
vector 单独 83% 完美,fusion 天花板 85%
总共两个点的提升空间。任何融合策略——RRF、加权、学习——在这里都不可能超过 85%,而且 RRF 把两个点花在了降级好的向量命中上。这不是调参问题,花一下午调参什么也发现不了。
为什么agree这么多?因为这是针对百科散文的自然语言问题,回答 passage 和问题共享含义和词汇。混合检索在查询带有精确 token——姓氏、错误码、零件号、函数名——而嵌入会模糊这些的时候才有用。这个语料几乎没有这种情况。
互补性测试花了十分钟,本来可以节省一天的工作。我应该在构建任何融合之前、在任何语料上先跑这个测试。
评估暴露了一个具体的失败案例困扰着我:
▎ Q: Who graduated in ecclesiastical law at the early age of 20? ▎ Retrieved, rank 1: "He graduated in ecclesiastical law at the early age of 20 and began to practice."
检索又是完美的。块就是不包含自己的主语——"Amedeo Avogadro" 在两个块之前,而"He"没有指代身份。
教科书式的修复是上下文检索:在嵌入之前给每个块前面加上文档级上下文。我没法应用它。这个数据集中每个 passage 都是作为自己的单块文档摄入的,所以没有父文档可以继承上下文——那句话到的时候已经被切开了。在你预算花数千次 LLM 调用来生成不存在的上下文之前,值得先检查一下。
我能低成本测试的是:把每个检中的 hit 扩展到包含它两侧的块。零额外嵌入。它精确修复了目标案例——在 radius 0 时找不到答案;radius 1 时窗口读出来是"Amedeo Avogadro was born in Turin... He graduated in ecclesiastical law..."
按 k 来衡量,看起来是明显的胜利:k=1 提升 3 点,k=10 提升 2 点。
但按相等 k 来比较是在作弊,因为每个 hit 现在携带了最多 3 倍的文本。按实际消耗的上下文来匹配:
┌────────────┬────────────┬─────────────────────┐
│ context │ plain │ expanded │
├────────────┼────────────┼─────────────────────┤
│ 3 chunks │ k=3 → 79% │ radius 1, k=1 → 72%│
├────────────┼────────────┼─────────────────────┤
│ 5 chunks │ k=5 → 82% │ radius 2, k=1 → 75%│
├────────────┼────────────┼─────────────────────┤
│ ~10 chunks │ k=10 → 83% │ radius 1, k=3 → 81%│
└────────────┴────────────┴─────────────────────┘
普通检索在每个预算下都赢了。十个独立的猜测覆盖的语料比三个用邻居填充的猜测更多。
任何在每个 hit 上增加文本的技术都必须按匹配的上下文来比较,而不是按匹配的 k。否则你测的是"更多上下文有帮助吗"——这个问题的答案是显而易见的(有帮助),但告诉不了你这个技术本身怎么样。
然后我读了那些未命中的
15 道题两个检索器都没找到。我全部读了,花了二十分钟——这本来应该是我最先做的事。
其中八道根本没有任何系统能回答:"页面上的第一个数字是什么?""页面上的最后一个词是什么?""他什么时候死的?"(没有指代对象)"近年来发生了什么?",一道需要跨两个事实做算术,一道答案是"It is arguable."
还有三道是评分器的人为因素——正确的块取到了,答案表述方式不同,或者刚好在我的 80% 阈值下面(71% 和 67%)。
剩下四道是真正的检索失败,100 道里。
所以原始基准是 83%。排除语料库中没有答案的问题后,更接近 93%。两个数字都是真的,它们之间的差距才是重点:基准里包含垃圾,而你还没检查过那些失败案例的数字就不是测量。
在任何优化之前先读那些未命中的。我的每一个发现都来自读失败案例,而不是盯着 aggregate 数字变动。
在构建之前先界定改进空间。互补性检查花了十分钟,封堵了一天的工。
先怀疑指标。它错了两次,朝同一个方向错,还改了一个决策。
按成本匹配,而不是按你转的那个旋钮。固定的 k 不是固定的上下文。
n=20 是冒烟测试,不是评估。
四个实验里三个失败了。系统还在原地——纯向量搜索,k=5——我现在知道这是正确的配置,这比一个我无法解释的 2% 提升更有价值。
代码:github.com/JQ100/ask-my-notes。测试工具是 scripts/eval-retrieval.ts;--mode vector|keyword|hybrid 和 --expand N 可以复现上面每个表格。