Chapter 02

检索机制:下探一层看召回阶

01 章把 RAG 看成召回→精度漏斗,并把检索(召回阶)记成"宽口、廉价、保召回"。这一章下探到那个框内部:dense 检索为什么会漏掉精确词,BM25 凭什么补得上,hybrid 怎么把两者拼起来,以及重排(精度阶)凭什么更准——把"会用检索"推到"懂检索机制",比官方文档深一层。

本章你将建立的 schema

  • dense 检索:双编码器把 query 与 doc 各压成一个向量,毫秒级,但单向量是有损瓶颈
  • BM25:基于字面词项的打分,精确命中 dense 的盲区,却对同义改写零分
  • hybrid + RRF:合并两路排名而非分数,尺度无关地兼得两者长处
  • cross-encoder 重排:联合编码带来全词交互,所以更准、但只能精排 top-k
  • 查询改写(HyDE / multi-query / decomposition):在漏斗入口让 query 落得更准

沿用 01 章的场景:你在给公司搭内部问答助手,语料是几百页员工手册 PDF。这一章把用户那句 "出差住宿的报销上限是多少?" 放进检索器内部,看每种机制怎么处理它、在哪里失手。

2.1dense 检索:双编码器与有损瓶颈

dense 检索把 query 和每段 doc 各编码成一个定长向量,相关性 = 向量点积,检索 = 在共享向量空间里找最近邻。

为什么需要它

用户说"住宿能报多少",手册写"住宿费上限"——字面不同、意思相同。基于字面匹配会漏掉这种同义改写。dense 检索靠嵌入把语义近的两句话映射到相邻坐标,于是"按意思找"成为可能。这是 RAG 区别于传统全文检索的起点。

底层机制(比文档深一层):DPR(Karpukhin 2020)用两个独立的 BERT 编码器(dual-encoder,即双编码器:query 和 passage 各走各的网络)。一个把 query 编码、一个把 passage 编码,各自取 [CLS] 位置的输出,得到一个定长向量;相关性就是两个向量的点积。关键工程红利在于:passage 向量可以离线预先算好存进索引,在线只需编码 query 一次、再做近邻搜索——所以检索把"判断相关"降维成共享空间里的最近邻问题,余弦/点积大 ≈ 语义近,单次查询能压到个位数毫秒。训练上,DPR 用 in-batch negatives(同一个 batch 里其他 query 的正样本,当作当前 query 的负样本)做对比学习,让负样本几乎零成本地扩量。

代价藏在"一个向量"里。一段文本被压成约 768 维的单个 dense 向量,这是一个有损瓶颈:它把整段的意思平均成一个点,保留语义主旨、丢掉精确的表面形式。于是 dense 检索在三类输入上失手——罕见 token、必须精确匹配的 ID(SKU-7741、1099-MISC)、训练分布外(OOD)的术语:它们的精确字形在压缩里被抹平,向量只留下"报税相关"这个笼统主旨、认不出具体是哪一张表。一句话收束:dense 检索通过"把相关性降维成单向量最近邻"实现毫秒级召回,所以代价/失效就发生在"单向量装不下精确字形"这件事上。

这正回答了 01 章 §1.4 留下的预测:用户问 1099-MISC 怎么填,dense-only 检索不一定把那段排第一——因为 1099-MISC 这个罕见字形,恰好落在它的盲区里。补这个盲区的机制,就是 2.2 的 BM25。

类比 · 带边界声明

dense 编码像给每段文字拍一张语义缩略图:你能一眼认出"这是讲报销的",却读不清图里那行小字写的是 1099-MISC 还是 1099-K。类比失效处:缩略图丢的是像素细节,可放大原图找回;而 dense 向量丢掉的精确字形无法从向量本身恢复——它根本没被编码进去,这是信息论意义上的有损,不是分辨率不够。

2.2sparse / BM25:字面词项的精确命中

BM25 按 query 里每个词的"逆文档频率 × 饱和后的词频"给 doc 打分,命中靠字面 token 重叠,与语义无关。

为什么需要它

dense 的盲区(罕见 token、精确 ID)恰恰是"字面是否出现"最容易判定的情形。用户问 1099-MISC,只要某段里逐字出现了 1099-MISC,就该被强力召回——这跟语义理解无关,跟字面命中有关。BM25 把这件事做到极致,正好是 dense 盲区的互补面。

底层机制(比文档深一层):BM25 给一篇 doc 的得分,是对 query 里每个词项求和,每项 = IDF × 饱和后的 TF。两个零件各管一件事:

  • TF 饱和:词频 f 经 f·(k1+1)/(f+k1) 变换,k1≈1.2。一个词在文档里出现头几次是强相关信号,之后边际收益递减——出现 20 次并不比出现 10 次相关一倍。这条曲线把"关键词堆砌"压住。
  • 长度归一:用参数 b≈0.75 按文档长度相对平均长度做惩罚,压低长文档——否则一篇什么都提一句的长文档会靠"词多"虚高。
  • IDF:越罕见的词权重越高。1099-MISC 在全语料里极少出现 → IDF 很大 → 一旦命中就贡献巨大的分,无关它的语义是什么。

这就是 BM25 能精确命中 dense 漏掉的东西的原因:它工作在字面 token 重叠上,一个罕见的逐字词项凭高 IDF 直接顶起排名,不经过任何"压成向量"的有损步骤。代价是它的盲区与 dense 正好镜像——对同义改写零感知:用户问"住宿能报多少"、文档写"住宿费上限",两者词面零重叠 → BM25 给零分。它看得见 dense 看不见的精确字形,却看不见 dense 看得见的语义相近。两者是彼此盲区的精确补集,这正是 2.3 要把它们拼起来的理由。

想一想

同一个手册场景,两条 query:(A) "住宿能报多少钱",目标段落写的是"住宿费上限 800 元";(B) "1099-MISC 怎么填",目标段落逐字含 1099-MISC。dense 和 BM25 各自会在哪条上失手?(先停十秒)

展开答案(先停十秒)

(A) 上 BM25 失手:query 与目标段落词面零重叠("报多少钱" vs "费上限"),BM25 接近零分;dense 能靠语义相近把它召回。

(B) 上 dense 失手:1099-MISC 是罕见精确字形,被有损压缩抹平,dense 未必排第一;BM25 靠这个词的高 IDF 一击命中。

两条 query 的失手方各占一边——这说明没有单路能通吃,下一节的 hybrid 不是锦上添花,而是必需。

2.3hybrid + RRF:合并排名,而非分数

hybrid 同时跑 dense 和 sparse 两路检索,再用 RRF 把两份排名融合成一份——融合的是名次,不是相似度分数。

为什么需要它

2.1 和 2.2 证明了 dense 与 BM25 是彼此盲区的补集:单跑任一路,都会在另一路的强项上漏召回。hybrid 把两路一起跑、再合并结果,目标是让"语义相近"和"字面精确"两类正确答案都进入候选名单——直接拉高漏斗第一阶的召回上限。

底层机制(比文档深一层):合并两路结果的难点不在"跑两次",而在"怎么合"。Reciprocal Rank Fusion(RRF,倒数排名融合)的打分极简:score(doc) = Σ_i 1/(k + rank_i),k=60,对每一路求该 doc 名次倒数之和——相加的是名次(rank),不是各路的原始分数。

为什么是名次、不是分数?因为两路的分数根本不可比:BM25 分数无上界(命中越多、IDF 越高,可以很大),余弦相似度被夹在 [-1, 1]。把二者放进同一个加权和,等于拿米和公斤相加。常见的补救是归一化(min-max),但它对离群值、对每次 query 的分数分布都很脆弱。RRF 的选择是干脆丢掉数值大小,只保留"谁排第几"——名次天然尺度无关,第 1 名就是第 1 名,无论它的原始分是险胜还是碾压。

这里有个值得专门点出的反直觉结论:RRF 故意丢掉了大家辛苦校准的相似度分数,却仍然在实践中胜过基于分数的融合。代价也恰在这里——它丢掉了置信度:大幅领先的 #1 和险胜半个身位的 #1,在 RRF 眼里被同等对待。换言之 RRF 用"丢失置信度"换来了"尺度无关的稳健"。

dense 排名 1. doc-A 2. doc-B 3. doc-C BM25 排名 1. doc-B 2. doc-D 3. doc-A RRF 融合 Σ 1/(k+rank) 融合后 1. doc-B 2. doc-A 3. doc-C / doc-D 合成一份
图 2.1doc-B 在 dense 里排第 2、在 BM25 里排第 1,两路都靠前 → RRF 求名次倒数之和后把它顶到融合榜首;只在单路出现的 doc-C、doc-D 被压在后面。注意:RRF 把 1/(k+rank) 相加、丢掉原始分数——靠的是"在多路里都排得靠前"这件事,而非"某一路分数特别高"。

落到工程现实,不同向量引擎的默认融合并不相同:Qdrant 用 RRF,而 Weaviate(v1.24 起)默认 relativeScoreFusion(归一化分数再加权,保留置信度)——所以"hybrid"这个词在不同库里行为不同,迁移时要看清默认值。效果上,已公开的 benchmark 把 hybrid 的 recall 从 dense-only 的约 78% 提到约 91%(作为公开数据引用,具体数字随数据集波动)。

2.4重排:cross-encoder 凭什么更准

重排用 cross-encoder 把 query 和候选 doc 拼成一次前向,让两者逐词交互,从而判断 dense 点积判不出的细粒度相关性。

为什么需要它

hybrid 把召回提上来了,但它仍是"宽口"——为了不漏,捞回的候选里混着不少"沾边但不对"的段落。把这一大批直接塞给 LLM,既超 context、又引入噪声(03 章讲噪声如何拖垮生成)。重排在这批候选上做精排,只把真正相关的少数几段顶上来。这正是 01 章 §1.6 说"02 章会回答"的那道机制题。

底层机制(比文档深一层):差别全在"编码结构"。漏斗第一阶的 dense 是 bi-encoder(双编码器)——query 与 doc 分别编码成两个向量,相关性靠事后的点积。好处是 doc 向量可预计算、搜索亚线性(毫秒级扫全库);代价是 query 与 doc 在编码时从未照面,没有任何逐词交互,点积只能比"两张缩略图整体像不像"。

reranker 是 cross-encoder(交叉编码器)——把 [query ; doc] 拼成一条输入送进模型做一次前向,于是每个 query token 在每一层 attention 里都能注意到每个 doc token。这种全词交互能建模点积建模不了的东西:否定、限定词、数值条件、"这个词到底修饰哪一项"。代价是它无法预计算也无法缓存——doc 不再有独立向量,必须 query 和 doc 拼在一起才出分;每个候选都得跑一次前向,复杂度 O(N) 随候选数线性增长。所以它只能用在第一阶交出的 top-k 小名单上,绝不能扫全语料。

这就把 01 章那道题答完了:检索做不到重排的精度——bi-encoder 分别编码、没有交互;重排做不到检索的规模——cross-encoder 每个候选一次前向、不可缓存。精度和规模的对立,根源是"分别编码 vs 联合编码"这一个结构选择。成本与收益也很清楚:rerank 给查询链路增加约 +50–200ms,但显著抬升 NDCG@10 / accuracy,是漏斗里性价比最高的精度修复。

bi-encoder · 第一阶 query doc 编码器 编码器 vec vec · 点积 可预计算 cross-encoder · 第二阶 [query ; doc] 一次前向 全词交互 score
图 2.2左:bi-encoder 把 query 与 doc 分别编码成向量、再算点积。右:cross-encoder 把两者拼成一条输入、一次前向出分。注意:分别编码 → 可扩展但无交互;联合编码 → 精确但每候选一次前向,所以重排只敢用在 top-k。

2.5查询改写:在漏斗入口动手

查询改写不碰索引、不碰检索算法,只在送入检索前改造 query 本身,让它的向量落到离正确答案更近的位置。

为什么需要它

漏斗的召回上限,一部分被 query 本身限死了:用户的提问往往短、含糊、措辞和文档对不上。前面几节都在优化"怎么找",查询改写优化的是"拿什么去找"——它作用在漏斗最前端的入口,改一次入口,后面每一阶都受益。

底层机制(比文档深一层):三种改写各有不同机制,但落点都在入口。

HyDE(Hypothetical Document Embeddings,假设文档嵌入,Gao 2022):先让 LLM 针对 query 生成一段"假设答案",然后嵌入这段假设答案(而非原始问题)去检索。机制在于——答案和真实答案段落落在同一个嵌入邻域,而问题不会:问句"住宿能报多少"在向量空间里离一堆别的问句更近,而一段"出差住宿报销上限为……"的陈述,天然贴着手册里那段陈述。这里也有反直觉的一面:HyDE 故意要 LLM 编一段可能不准的文档。它不怕编错,因为 dense 编码器的有损瓶颈(2.1)会把假设里那些编造的具体数字"洗掉",只留下"这是一段讲住宿报销的陈述"这个语义骨架——而骨架正是用来对齐邻域的。代价:每次检索前多一次 LLM 调用、加延迟;在冷门或事实性极强的 query 上,假设可能把方向带偏。

multi-query:让 LLM 把一条 query 改写成若干条措辞不同的等价问法,并行各检索一次,取结果并集。机制简单直接——用措辞多样性覆盖"用户这一种说法恰好和文档对不上"的风险,从而拓宽召回。

decomposition(查询分解):把一个多部分问题拆成几个子问题分别检索。"出差住宿和交通的报销上限分别是多少" 拆成住宿、交通两条子查询——单一向量装不下两个独立子意图,拆开后每条都能精准命中。这条线为 04 章的 multi-hop / Agentic 检索埋下伏笔。

query LLM 造假设 允许编造 假设文档 嵌入 检索 嵌入它、不是 query
图 2.3HyDE:用 LLM 先生成一段"假设答案",再嵌入这段假设去检索。注意:编造的具体数字会被嵌入的有损瓶颈洗掉,留下的语义骨架贴着真实答案段落的邻域——这正是它比直接嵌入问句更准的原因。

2.6综合:把三种第一阶检索摆上桌

2.1–2.3 拆完了第一阶检索的三种打法。把它们的取舍摆成一张备选方案表——选型的核心不是"哪个最好",而是"哪个的盲区你承受得起"。

第一阶检索 · 备选方案对比
方案 优势 盲区 何时够用
dense-only 抓语义相近、容忍同义改写;query 与文档措辞不同也能召回 罕见 token、精确 ID(1099-MISC)、OOD 术语——有损瓶颈抹平字形 语料口语化、查询靠"意思"而非"精确词",且几乎无关键 ID 检索
BM25-only 精确字面命中、罕见词靠高 IDF 一击即中;可解释、零训练 同义改写词面零重叠即零分;完全不懂语义 查询多为精确关键词 / 代码 / ID,用户措辞与文档高度一致
hybrid(dense + BM25 + RRF) 两路盲区互补,语义与字面通吃;公开 benchmark recall 约 78% → 约 91% 跑两路、成本与延迟略增;融合默认值因引擎而异,需显式确认 生产默认起点——除非你确证查询分布只落在某单一路的强项内

把手册查询走一遍漏斗。用户问 "出差住宿和交通的报销上限分别是多少":

  • 入口改写(2.5):decomposition 先把它拆成"住宿上限""交通上限"两条子查询——单向量装不下两个子意图,拆开各自命中。
  • hybrid 召回(2.3):每条子查询走 hybrid。dense(2.1)负责"报销上限"对上"费用上限"的同义改写;BM25(2.2)负责精确锚定"住宿""交通"这些字面词项;RRF 合并两路排名,保证两类正确段落都进 top-100。
  • rerank 精排(2.4):cross-encoder 把这 100 段逐一与子查询联合编码,分辨"800 元是住宿还是交通的上限"这种点积分不清的细粒度归属,挑出最相关的几段。

这条 入口改写 → hybrid 召回 → rerank 精排 的链路,就是 01 章那张漏斗图在检索内部的展开:每一步只在漏斗的一个具体环节上动手,各自优化召回或精度,互不替代。

想一想

有人主张:"既然 cross-encoder 重排这么准,干脆跳过 hybrid,直接拿它对全语料逐段打分排序,召回精度一步到位。"这个方案错在哪?(先停十秒)

展开答案(先停十秒)

错在规模。cross-encoder 每个候选都要把 [query ; doc] 拼起来跑一次前向、无法预计算、复杂度 O(N)——百万级语料每次查询要跑百万次前向,延迟和成本完全不可接受。bi-encoder 之所以能扫全库,正因为 doc 向量离线算好、在线只做近邻搜索。所以第一阶必须用廉价的 bi-encoder 检索保召回、把候选压到 top-k,第二阶才轮到 cross-encoder 精排。这就是漏斗"宽口廉价 → 窄口较贵"分两阶的硬约束。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. dense 检索把一段文本压成一个向量带来了什么红利、又埋下什么失效面?用"X 通过 Y 实现,所以代价在 W"的句式答。
  2. BM25 对一条同义改写的 query(词面零重叠)打多少分?为什么?这说明它和 dense 是什么关系?
  3. RRF 融合的是各路的名次还是分数?为什么这个选择反而比归一化分数更稳健?它丢掉了什么?
  4. 为什么 hybrid 已经把召回提上来了,却仍然需要 rerank?(提示:从 bi-encoder 与 cross-encoder 的编码结构差异答)
答案(先做完再展开)
  1. dense 检索通过"把 query/doc 各编码成单个定长向量、相关性降维成共享空间最近邻"实现毫秒级、可预计算的召回,所以代价在"单向量是有损瓶颈,装不下精确字形"——在罕见 token、精确 ID、OOD 术语上失手。
  2. 接近零分。BM25 按字面 token 重叠打分,词面零重叠就没有可累加的词项。这说明 BM25 与 dense 是彼此盲区的精确补集:它命中 dense 漏掉的精确字形,却看不见 dense 看得见的语义相近——所以要 hybrid。
  3. 融合的是名次(Σ 1/(k+rank),k=60)。因为两路分数不可比(BM25 无上界、余弦有界),归一化对离群值和每次查询的分布脆弱;名次天然尺度无关,所以更稳健。代价是丢掉了置信度——碾压式第 1 和险胜第 1 被同等对待。
  4. 因为 hybrid 提的是召回(别漏),它用的 bi-encoder 分别编码 query 和 doc、两者从不照面,点积只能比"整体像不像",分不清细粒度相关性(否定、数值归属)。rerank 用 cross-encoder 联合编码、全词交互,专门补这道精度。召回和精度是两阶不同目标,缺一不可。
进阶挑战 · 刚好够不着

hybrid 召回高、rerank 也用了,答案却仍漏掉一个关键事实

你的助手已经上了 hybrid 检索 + cross-encoder 重排,大多数问题答得很好。但用户问"出差住宿上限",答案漏掉了"一线城市 800 元"这一档——而你确认手册里明明白白写着这个数字。第一反应是怪生成模型没说全。先别急着改 prompt:这个关键事实漏掉,更可能出在漏斗的哪一阶?怎么验证?

提示(卡住再展开)

rerank 只能对进入了 top-k 的候选重新排序——如果那个含"一线城市 800 元"的片段从未进入第一阶召回的候选名单,再强的重排也救不回它(无米下锅)。两个常见根因:(1) 那个数字被分块切碎了——表头"住宿费上限"和单元格"一线城市 800 元"被切进不同 chunk,单看任一 chunk 语义都不完整,dense 召不动、BM25 也对不上;(2) 它进了候选但因 chunk 主题混杂、向量被"平均"得不够锐利,排在 top-k 之外被截断掉了。验证方法:打印第一阶召回的原始候选列表,看那个片段在不在、排第几——这一步把"检索没捞到"和"生成没说全"区分开。这类"答案明明在语料里却没进候选"的问题,正是 03 章失败模式要系统拆解的。