在 62 本历史书籍上实测混合搜索、重排序等流行 RAG 技术效果。发现大多数技术在同一阶段相互竞争而非互补,强调了针对具体数据集的实测重要性。
我在62本书的46,000个文本块上测试了RAG检索技术。以下是完整的翻译:
搜索"advanced RAG techniques",你会得到二十项左右的列表:混合搜索、重排序、HyDE、查询分解、上下文检索、RAPTOR、GraphRAG、父子分块、多向量检索、语义分块。这些技术被呈现为一份菜单,好像每一项都能让你的系统略微改进。
但它们的组合效果并非如此。处于同一管道阶段的技术大多相互竞争——它们修复同一个故障,所以你添加的第二个技术找不到任何剩余的问题可修复。而唯一知道你的系统实际需要哪个技术的方法,就是在你自己的语料库上进行测量。
我在62本古代历史书上构建了一个RAG系统(约46,000个文本块),并让我能想到的每种检索技术都通过同一个测试流程:实现、针对固定的161个问题测试集进行测量、保留或拒绝、记录数字。这篇文章是该账本的检索部分——什么被发布了、什么被拒绝了,以及拒绝教会我什么。我的生产系统没有混合搜索,也没有重排序,这是一个测量结果,而不是捷径。
LLM写出任何内容之前的所有内容都存在于四个阶段之一:
A. INGEST → B. QUERY TRANSFORM → C. RETRIEVAL CORE → D. POST-RETRIEVAL
同一阶段 → 技术竞争。不同阶段 → 它们能组合。这一条规则否定了列表中暗示的大部分排序。
第二条规则涉及成本,而且是不对称的:
摄入时成本仅支付一次,离线,在你已有的硬件上。一次聪明的语料库扫描永远便宜。
查询时成本在每个请求上支付,在延迟和令牌中,贯穿系统的整个生命周期。
所以查询端的优化必须越过比摄入端优化高得多的门槛。一项为每次查询的付费API调用赢得+1.7点的技术,不如同样的+1.7来自一次夜间批处理任务的好——即使排行榜上那一行看起来完全相同。
还有一件事,那是没人标记的部分。证据分为三个等级,混合它们是坏建议传播的方式:
在此测量 ——在这个语料库上,46k个块,161个问题。
在较小范围测量 ——在我的前一个项目上(4本书,950个块,50个问题)。较弱。每当发现看起来对规模敏感时就重新测试。
推理得出 ——从未测量。一个成本/收益判断,当它是这个时我会说明。
这是整个检索菜单及其判决。文章的其余部分解释有趣的行。
基线 ——一切都是针对它测量的:朴素密集检索,500令牌块,top-5 ——recall@5 = 35.2%。三个问题中的两个永远无法到达模型与正确的段落。
在四本书时,你可以目测每个怪癖。在六十二本时,你不能,在原始古腾堡文本上运行一个分割器意味着页眉、目录和译者脚注都会泄入你的块中,并被嵌入为好像它们是内容一样。
伸缩的架构在中间放置了一个硬接口:
raw book ──(per-format parser)──▶ normalized document tree ──(ONE uniform chunker)──▶ chunks
所有每本书的怪癖都存在于解析器适配器中。分块器是一个单一的、经过充分测试的函数,永远不了解它正在处理哪本书:按优先级顺序在结构边界上分割(部分 → 段落 → 句子),贪心打包至约500令牌,永远不在部分边界上合并,永远不在句子中切割。
大规模分块是一个解析问题,而不是令牌计数问题。大小讨论得到所有关注,是该阶段中最不有趣的决定。
我会坚持两件事。每个块都在规范化文本中携带字符偏移——这使测试集黄金跨度分块不变,所以我可以改变分块策略而不重写测试集。并且每个块都携带其规范引用定位器(凯撒,高卢战争4.25),因为古典文本有稳定的参考方案,在每个版本中都存活下来。这为答案中的专业引用和机械测试集创作带来了收益,代价是教解析器识别书籍/章节编号。
来自第12章中间的原始切片读起来是"他然后向北行进"——嵌入器不知道"他"是谁或这是哪场战争。修复是一个摄入时通道:廉价的本地LLM为每个块写1-2句来标注上下文,嵌入覆盖context_note + heading_path + chunk_text而不是裸文本。46,159个(共46,170个)块在消费GPU上的一次本地批处理中得到了扩展,零查询时成本。
标题结果是+1.7 recall@5。看起来是噪声。但内部数据不同:
合成——项目中最差的类别——进行了转变。跨书变差了。整体排名明显高于噪声(recall@1 +5.8,MRR +0.060)。一个平坦的标题隐藏±18点的内部波动是正常情况,而不是例外。如果我只看了总体指标,我会称这为无操作并继续进行。
生成端的数字甚至更具误导性。答案完整性似乎下降,4.45 → 4.30。但它没有。在两次运行都回答的113个问题上,完整性是平坦的(4.46 → 4.40)。实际发生的是:上下文检索将12个之前拒绝的问题转换为已回答的问题,而这12个——检索困难的硬问题——得分为3.42,拉低平均值,同时每个先前的答案保持不变。范围内的虚假拒绝从15.6%下降到7.4%。
一个打扮成回归的胜利。任何将拒绝转换为答案的更改都会对你这样做,唯一的防御是始终在两次运行都回答的问题集合上计算指标。
该阶段的其余部分很便宜被拒绝。父子检索(嵌入小块,将其父部分交给LLM)在我的前一个项目上回归了完整性3.22 → 2.67——可重新测试,但我首先想要上下文饥饿的证据。ColBERT风格的多向量成本10-50×向量存储;基于存储预算拒绝,不测量,我会直言其故。语义分块和嵌入摘要而不是块被跳过,基于预期ROI——后者被上下文注释所包含,它保留原始文本并添加上下文。
这里的每项技术成本是你甚至还没搜索前的额外LLM调用。
HyDE ——让模型写一个假设的答案并嵌入那个而不是问题——在我的前一个项目上得分−9.7 recall@5。该机制值得理解,因为它概括:HyDE替换你的查询,丢弃用户实际给你的判别性术语。它是为查询/文档词汇不匹配而构建的。如果你的嵌入器足够强不具有那种不匹配,你正在扔掉信号并为额外的延迟付费。
多查询扩展(用n种方式释义问题,合并结果)测量+2.1——真实但边际,它是一个额外的调用加上n个在每个请求上搜索。作为关闭的标志保留。
查询分解和step-back提示我跳过作为独立技术的原因是另一个:可以多次搜索的检索循环适应性地执行这两者,由它实际发现的驱动。不要手工构建一个循环免费给你的行为的静态版本。
这是最重要的,而它是一行配置。
将默认嵌入器交换为强的嵌入器(qwen3-embedding-8b,托管)将recall@5从35.2%带到53%。最受打击的类别——现代英语问题对维多利亚文学翻译散文——获得了+41.7点。本文中没有其他任何东西接近。
我如何选择它是可转移的部分:按约束进行候选名单,通过消融进行决定。约束是具体的——它能在廉价CPU容器上提供查询还是必须是API,许可证是什么,它需要指令前缀吗,上下文窗口是否适合上下文注释。这产生了四个候选。排行榜在最终决定中从未获得投票,因为没有排行榜包含维多利亚文学翻译散文。你的语料库是唯一计数的排行榜。
两个陷阱在这里给真实的项目真实的质量成本:
前缀奇偶性。大多数现代嵌入器都想要不同的指令前缀来查询与文档,而托管API提供开放权重模型通常不为你注入它们。语料库用前缀嵌入,没有前缀的查询,是一个看起来什么也不像的无声多点丢失。将嵌入包装在一个拥有前缀策略的模块中,永远不从两个地方调用模型。
相同模型 ≠ 相同向量。运行时差异(sentence-transformers vs llama.cpp vs托管API)、fp16 vs fp32和量化都转移向量。便宜的防御:在两个堆栈上嵌入20个固定句子,并在信任它们作为一个索引之前断言余弦相似度≥0.999。
标准2024时代的建议是混合搜索——关键词BM25与向量搜索融合——在规模上总是赢,特别是对于罕见的专有名词。我的语料库充满了罕见的专有名词(Vercingetorix、Pharsalus)用不一致的维多利亚拼写。我已经在950个块时拒绝了混合一次,我在运行它之前写下了一个预测:在46k个块这翻转为赢。
它没有翻转。它返回了什么都没有。
不是"大致相同"——字节相同的池回忆,按类别。BM25能通过精确令牌匹配找到的每个答案,8B嵌入器已经有了。而融合使顶级排名略微变差,因为RRF注入关键字噪声块,替换排名良好的密集命中。
机制是发现:当强密集检索器错过时,答案是分布式的,而不是关键字可找到的——所以BM25也无法到达它。混合总是赢的建议假设弱词汇第一阶段。具有现代8B嵌入器,该假设在这个语料库上就是假的。
注意什么使那个结果可读的:recall@50,被视为池回忆。仅recall@5会显示−0.4,并让我猜测BM25是否贡献了新的候选,融合则误排序了。一个设计用来区分"扩大了池"与"重新排序了池"的指标将一个模糊的冲洗转变为一个清洁的拒绝。设计你的指标来区分机制,而不仅仅是得分结果。
一个交叉编码器重新评分你的top-50并返回最佳5。它是RAG中最推荐的技术,我测量了五个。
这是嵌入器门的确切倒数。在那里,组件太弱了,任何更好的都是一个巨大的赢。这里嵌入器太强了,以至于0.6B交叉编码器比8B嵌入器自己的排名更差——它增加了噪声。只有最先进的托管重排序器有帮助,这意味着发布重排序意味着永久发布付费的每查询依赖。
一个架构法则来自于此,而且遵守它是免费的:重排序你嵌入的相同文本。评分裸块文本,而索引保持上下文化文本,使重排序器与检索器争斗,完全撤销上下文增益(47.9% vs 51.6%在我的前一个项目上)。检索和重排序必须共享一个表示。
有趣的部分是我为什么放弃它。重排序器被暂时保留了一个特定的陈述原因:上下文检索花了我在跨书问题上9点,重排序top-50被假设会恢复它们。它没有——跨书落在26.0对一个34.4底线,没有重排序器恢复它,尽管池明确持有答案。跨书是一个候选池问题,而不是排序问题,重排序无法表面什么不在池中。
所以:重排序器到处有所帮助,除了被雇用来帮助的地方。边际、付费、每查询、永久,并在自己的理由中证伪。当一个更强的生成器稍后到达时,它完全脱离了管道。
一个诚实的警告。模型交换和重排序器放弃发生在同一次运行中,所以该放弃从未被清洁隔离——我跳过了本来会分开它们的测试分支,出于成本原因。这是一个在其他方面完整账本中的收据间隙,我宁愿命名它,也不愿让表格暗示超过它有的严谨性。
该阶段的其余部分:去重和丢失中间重新排序各一行,免费,我发布了它们而不测量。上下文压缩——LLM总结检索到的块在填充之前——我通过推理拒绝:提示缓存已经使原始块便宜,压缩增加了延迟,一个摘要可以无声删除你打算引用的确切句子。
在所有这一切之后,这里是什么实际上决定了这个语料库上的检索质量:
嵌入器模型。+18点来自一行配置。本文中所有其他部分组合都更小。不要继承教程的嵌入器。
块里面是什么。不是它有多大——它包含什么。一个摄入时上下文通道按+18.2转变了最差类别,成本在查询时什么都不是。
解析层。边界落在哪里以及哪些元数据沿着。不起眼的,在任何技术获得投票之前决定,以及以后改变的昂贵。
指标本身。两个指标重新设计比任何技术除了嵌入器改变了我的结论更多:池回忆,将混合结果从模糊转变为决定性,以及一个回忆指标,我在发现它惩罚具有跨多本书的多个源的问题后不得不重写——评分0.2以检索五个通常完全回答问题的段落中的一个。一个惩罚你的系统所针对的确切勤奋的指标会安静地在数月内错误地引导你。
而什么不重要:混合搜索(零)、四个中的五个重排序器(负或无)、HyDE(−9.7)以及我跳过的每个时尚摄入架构。不是因为它们是坏技术——因为在这个语料库上约束条件在别处。这是所有这些下面的模式:在任何时刻恰好一件事是约束条件,每项技术目标在其他任何地方都返回噪声。嵌入器是约束,所以修复它支付18点。一旦它不是,上下文检索是边际的,重排序是边际和付费的,混合是什么都不是。
发布的检索堆栈很无趣:上下文密集嵌入,一个强嵌入器,top-k,元数据过滤。没有混合。没有重排序。recall@5 = 56.7%。
这提出了明显的问题,这也是为什么有这个故事的第二部分的原因:完成的系统回答100%的范围内问题并拒绝96%的无法回答的问题,在一个检索器上找到正确段落56.7%的时间在一次查询中。
它管理那个,因为它不做单次查询。一旦检索被关闭,剩余的回旋余地原来是架构——那是下一篇文章。
我构建RAG和LLM评估系统,可用于合同工作。上面的所有内容都是开放的:代码、161个问题黄金集和完整的仅追加评估日志,这些数字后面的每个运行记录。
实时演示:historian.loroplanner.com
代码 + 案例研究 + 评估日志:github.com/LevRiabov/antic-historian
前一篇文章:RAG for developers who aren't AI engineers
如果你的团队正在尝试让LLM从你自己的数据可靠地回答——或试图弄清你已经构建的是否可以被信任——联系我:levriabov@zohomail.eu