老周是大学图书馆最资深的图书管理员,每年新生入学,他都像个活的“搜索引擎”。有学生来问:“老师,我要写一篇关于'苹果'的论文,有哪些书可以看?”老周有两手绝活。第一手,是他脑海里有一套“关键词卡片”——每本书入库时都登记了精确标签,学生说“苹果”,他就哗啦啦翻卡片,把标签上写着“苹果”的书全找出来。快、准,但死板:学生改口说“我想研究水果产业”,卡片上没写“水果”的苹果类书籍就被漏掉了。第二手,是他几十年间真把每本书都“读”过一遍,心里清楚每本讲的是什么“意思”。学生说“水果产业”,他能联想到种植、供应链、市场分析的书,哪怕封面没写“水果”——慢,但通达。老周真正的智慧,是把两招合起来:先翻卡片快速锁定一批,再用语义理解补全遗漏,合并去重,最后亲自把候选书翻看一遍、排好序递给学生。这套方法论,正是 RAG(检索增强生成)系统中检索器设计的核心哲学:稀疏检索 + 稠密检索 + 混合融合 + 重排序。

岗位关联

检索器是 RAG 系统的“引路人”,它决定了 LLM 最终能读到什么质量的上下文。掌握稀疏/稠密/混合三种检索器的设计,是每位 RAG 工程师迈向实战的必修课。

目录
  1. 可视化演示
  2. 检索器基础
  3. 稀疏检索
  4. 稠密检索
  5. 混合检索
  6. 重排序
  7. 评估优化
  8. 落地实践
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示RAG检索器设计的三种检索策略:稀疏检索(BM25关键词匹配)、稠密检索(向量语义相似度)与混合检索的融合机制,以及RAG管线中检索-增强-生成的完整流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

RAG管线架构流程 用户查询经检索器从知识库召回相关文档片段 检索结果作为上下文增强注入LLM提示词 LLM结合上下文与查询生成最终回答 RAG(q)=LLM(q,C),C=Retriever(q,K) 检索器质量决定RAG系统上限

检索器基础

老周接到学生的求助后,从来不会立刻冲向书架。他总会先问三个问题:“你要找什么主题?需要多少本?借期多久?”把模糊的需求翻译成明确的“找书指令”,这正对应 RAG 检索器设计中的任务定义——给定一个用户查询 q,从一个大规模文档库 $\mathcal{D}$ 中,快速找出最相关的 k 个文档片段。这个“先明确任务、再动手检索”的步骤,决定了整个系统的走向。

明确了任务之后,老周还得知道“书架上有哪些书”。在 RAG 系统中,这一步叫检索库构建:把企业知识库、产品文档等原始资料收集起来,清洗格式、去重合并。但原始文档往往又长又杂,一份上百页的 PDF 不可能整体塞给检索器打分,于是需要文档切块(Chunking)——像老周把大部头拆成一个个章节、按主题贴上标签。切块的大小直接决定检索粒度:块太大,噪声多、定位不准;块太小,语义不完整、上下文断裂。行业里常用 200~500 个 Token 的块大小,并让相邻块保留少量重叠,避免关键信息被拦腰截断。

将检索任务形式化,可以写成这样一个表达式:

检索器基础的每一环都像老周开工前的准备:任务定义是把学生的话记清楚,检索库构建是摸清馆藏,文档切块则是把厚书拆成方便查阅的章节。地基打得牢不牢,后面三种检索方式的效果都悬于其上。

R(q)=topk{score(q,di)∣di∈D}\displaystyle \mathrm{R}(q) = \mathrm{top}_{k} \left\{ \mathrm{score}(q, d_i) \mid d_i \in \mathcal{D} \right\}
这个公式说明:检索器本质上是一个“打分 + 排序”的过程——对文档库中每一篇候选文档计算与查询的相关性分数,再取最高的 k 个返回。
  1. 检索函数 $\mathrm{R}(q)$整体表示“对查询 q 执行检索”这个动作,输出一个有序文档列表,相当于老周最后递给学生的书目清单。
    R(q)\displaystyle \mathrm{R}(q)
  2. $\mathrm{top}_{k}$表示只取分数最高的 k 个结果,避免把整个文档库都抛给下游的 LLM。
    topk{⋅}\displaystyle \mathrm{top}_{k} \{\cdot\}
  3. $\mathrm{score}(q, d_i)$相关性打分函数,衡量查询 q 与第 i 篇文档 $d_i$ 之间的匹配程度。
    score(q,di)\displaystyle \mathrm{score}(q, d_i)
  4. 文档库 $\mathcal{D}$全体候选文档集合,即老周书架上的所有藏书。
    di∈D\displaystyle d_i \in \mathcal{D}

稀疏检索

老周的第一手绝活,是那套“关键词卡片”。每本书入库时,他都会手工登记一张卡片,上面写着这本书的标题、作者、主题词。学生来问,“苹果”相关的书?他翻开卡片盒,凡是卡片上写了“苹果”两个字的,抽出来就是候选。这套方法快得惊人,而且完全可解释——为什么推荐这本书?因为卡片上明明白白写着关键词。

这套“关键词卡片”放在计算机里,就是稀疏检索:用词频统计(Term Frequency,TF)记录一个词在文档中出现多少次,再用逆文档频率(Inverse Document Frequency,IDF)衡量这个词在全库中有多“稀有”。像“的”“是”这类词,几乎每篇文档都有,IDF 很低,对打分没什么贡献;而“苹果”只在少数文档出现,IDF 高,一旦命中就能大幅提升分数。把两者结合,最经典的实现就是BM25 算法,也是业界公认最稳健的稀疏检索基线。

BM25 的打分公式如下:

稀疏检索的优点和缺点一样鲜明:它精确、快速、可解释,但局限分析告诉我们,它对“同义词”“语义改写”完全无能为力。学生说“我想研究水果产业”,老周翻破卡片也找不到“苹果”书,因为卡片上没有“水果”这个标签。这种“词汇鸿沟”(Lexical Gap),就是稀疏检索最大的硬伤,也是稠密检索登场的理由。

BM25(q,d)=∑t∈qIDF(t)⋅f(t,d)⋅(k1+1)f(t,d)+k1⋅(1−b+b⋅∣d∣avgdl)\displaystyle \mathrm{BM25}(q, d) = \sum_{t \in q} \mathrm{IDF}(t) \cdot \frac{f(t,d) \cdot (k_1 + 1)}{f(t,d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\mathrm{avgdl}}\right)}
公式的含义是:查询 q 中每个词 t 对文档 d 的贡献,由“这个词有多罕见”和“它在文档里出现了多少次”共同决定,并受文档长度调节。
  1. 累加符号 $\sum_{t \in q}$把查询中每个词对文档的贡献逐一相加,得到总相关性分。
    ∑t∈q\displaystyle \sum_{t \in q}
  2. 逆文档频率 $\mathrm{IDF}(t)$词 t 在全库中越稀有,这个值越大。它衡量“词 t 的区分能力”。
    IDF(t)\displaystyle \mathrm{IDF}(t)
  3. 词频项 $\frac{f(t,d) \cdot (k_1 + 1)}{f(t,d) + k_1 \cdot \cdots}$词 t 在文档 d 中出现的次数 $f(t,d)$ 越多,贡献越大,但受 $k_1$ 调节,出现 10 次不会是出现 1 次的 10 倍——这就是“词频饱和”。
    f(t,d)⋅(k1+1)f(t,d)+k1⋅(1−b+b⋅∣d∣avgdl)\displaystyle \frac{f(t,d) \cdot (k_1 + 1)}{f(t,d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\mathrm{avgdl}}\right)}
  4. 文档长度比 $\frac{|d|}{\mathrm{avgdl}}$文档长度与全库平均长度的比值,参数 $b$ 控制长度惩罚力度。长文档中同样的词频不那么“值钱”。
    b⋅∣d∣avgdl\displaystyle b \cdot \frac{|d|}{\mathrm{avgdl}}

稠密检索

老周之所以能在图书馆封神,靠的不只是卡片,而是他那颗“读遍群书”的大脑。几十年来,他真的把每本书都翻过,心里形成了一张“语义地图”:《苹果的栽培技术》和《现代农业与果业经济》虽然字面上没有共同关键词,但老周知道它们讲的是同一片知识领域。学生说“水果产业”,他大脑里的语义地图自动点亮了这些看似无关的书——这就是稠密检索的原型。

在计算机里,“读过每本书”被替换成向量嵌入(Embedding):用深度神经网络(如 BERT、Sentence-BERT 等编码模型)把一段文本映射成一个高维向量(通常是 768 维或 1024 维),让语义相近的文本在向量空间中互相靠近。查询也被编码成向量,然后通过余弦相似度计算两个向量的“方向一致性”,得到相关性分数:

然而,稠密检索有一个现实问题:文档库可能有百万级甚至亿级文档,逐一对所有向量算余弦相似度太慢了,老周的大脑也扛不住。这时需要ANN 索引(近似最近邻搜索),主流方案是 HNSW(分层可导航小世界图),把向量组织成一张多层图,检索时从顶层快速“跳”到底层,用少量计算就能找到绝大多数近邻。稠密检索的优势在于强大的语义理解能力,能召回稀疏检索漏掉的同义词、改写句和跨语言表达;但它也有短板:对专有名词、数字、生僻词不敏感,且向量模型的质量直接决定检索上限。

稠密检索让老周的“人脑地图”变成了可计算的向量空间。它牺牲了一点可解释性和精确性,换来了对语言“言外之意”的捕捉能力。如果说稀疏检索是“按图索骥”,稠密检索就是“闻弦歌而知雅意”。

sim(q,d)=q⋅d∥q∥⋅∥d∥\displaystyle \mathrm{sim}(\mathbf{q}, \mathbf{d}) = \frac{\mathbf{q} \cdot \mathbf{d}}{\|\mathbf{q}\| \cdot \|\mathbf{d}\|}
余弦相似度衡量的是两个向量方向的接近程度:方向越一致,值越接近 1,说明语义越相关;取值与向量长度无关。
  1. 向量内积 $\mathbf{q} \cdot \mathbf{d}$把查询向量和文档向量的每一维对应相乘再求和,得到一个标量,初步反映两者“重叠”程度。
    q⋅d\displaystyle \mathbf{q} \cdot \mathbf{d}
  2. 模长乘积 $\|\mathbf{q}\| \cdot \|\mathbf{d}\|$用两个向量的长度做归一化,消除文本长短对分数的影响。
    ∥q∥⋅∥d∥\displaystyle \|\mathbf{q}\| \cdot \|\mathbf{d}\|
  3. 余弦比值内积除以模长乘积,结果落在 $[-1, 1]$ 区间,越接近 1 表示语义方向越一致。
    q⋅d∥q∥⋅∥d∥\displaystyle \frac{\mathbf{q} \cdot \mathbf{d}}{\|\mathbf{q}\| \cdot \|\mathbf{d}\|}

混合检索

老周带过的年轻馆员曾问他:“您到底是靠卡片,还是靠记忆找书?”老周笑了:“小孩子才做选择,我两样都用。”他会先翻关键词卡片锁定一批确定相关的书,再用语义记忆补上卡片漏掉的“隐形相关”书,最后把两边合并、去掉重复。这套“两手抓”的策略,正是混合检索的朴素智慧。

融合动机很直接:稀疏检索长于精确词匹配和专有名词,稠密检索长于语义泛化和同义改写,二者互补性极强。但这里有个技术难题——分数归一化:BM25 分数可能高达十几,余弦相似度却只有 0~1,两套分数的“量纲”完全不同,直接相加等于让一个大嗓门盖过所有听众。常见的做法有两种:一种是先分别做 Min-Max 或 Z-Score 归一化,再按权重配比加权求和:

另一种更稳健、更出名的方案叫 RRF(Reciprocal Rank Fusion,倒数排名融合),它不关心具体分数,只看文档在两个结果列表中的排名位置:

混合检索就像老周的“双通道找书法”:卡片通道保证“该有的都有”,记忆通道保证“意想不到的也能找到”,最后取并集让漏网之鱼最少。在实际的 RAG 系统中,混合检索往往能比单一检索方式提高 10%~20% 的召回率,是性价比极高的升级手段。

score(d)=α⋅s^sparse(d)+β⋅s^dense(d)\displaystyle \mathrm{score}(d) = \alpha \cdot \hat{s}_{\text{sparse}}(d) + \beta \cdot \hat{s}_{\text{dense}}(d)
RRF(d)=∑r∈R1k+rankr(d)\displaystyle \mathrm{RRF}(d) = \sum_{r \in \mathcal{R}} \frac{1}{k + \mathrm{rank}_r(d)}
RRF 的思路是:一篇文档在稀疏和稠密结果中都排名靠前,说明它“众望所归”,融合分数就高;排名越靠后,贡献越小。参数 k 通常取 60,防止排名为 1 的文档分数过于突出。
  1. 排名集合 $\mathcal{R}$这里包含了稀疏检索结果和稠密检索结果两个排名列表。
    r∈R\displaystyle r \in \mathcal{R}
  2. 倒数项 $\frac{1}{k + \mathrm{rank}_r(d)}$文档 d 在第 r 个列表中的排名越靠前,$\mathrm{rank}_r(d)$ 越小,分数越大。
    1k+rankr(d)\displaystyle \frac{1}{k + \mathrm{rank}_r(d)}
  3. 累加求和 $\sum$把文档在两个列表中的“排名荣誉”相加,得到融合后的最终得分。
    ∑r∈R1k+rankr(d)\displaystyle \sum_{r \in \mathcal{R}} \frac{1}{k + \mathrm{rank}_r(d)}

重排序

老周把候选书抱到办公桌上后,并不会直接全塞给学生。他会再花一点时间,把每本书翻开,看看目录、读读前言,甚至抽查几个章节,确认这本书到底跟学生的课题有多契合。然后按契合度从高到低重新排一遍——最切题的书放最上面。这个“亲自把关”的动作,就是 RAG 里的重排序(Rerank)。

为什么要重排?因为不管是 BM25 还是双塔向量模型,给文档打的都是“粗分”。向量模型为了检索速度,把文档预编码成独立向量,查询和文档之间没有深度交互(这叫双塔架构)。而重排序阶段可以动用更慢但更准的交叉编码器(Cross-Encoder):把查询和文档拼成一个序列,喂给模型做深度交互建模,让每个 Token 都能互相“看见”对方,从而计算更精细的相关性分数:

这种“检索取 Top-100 → 重排精排取 Top-10”的范式叫两阶段范式。它背后的延迟优化逻辑很清晰:粗排阶段用轻量模型从百万文档中快速筛到几百个候选,精排阶段用重型模型只对这几百个仔细打分,总延迟依然可控。老周也不会把全馆十万本书都抱到桌上翻——他只对卡片和记忆选出的二三十本做精读。重排序让 RAG 的上下文质量在“最后一公里”得到质的提升。

rel(q,d)=CrossEncoder[CLS](q,[SEP],d)\displaystyle \mathrm{rel}(q, d) = \mathrm{CrossEncoder}_{[\mathrm{CLS}]}\big( q, [\mathrm{SEP}], d \big)
交叉编码器输出的 CLS 向量经一个线性层映射为相关性分数(例如 0~1 的概率),它同时建模了查询与文档之间的词级交互和上下文关系。
  1. 交叉编码器 $\mathrm{CrossEncoder}$一个完整的 Transformer 模型,同时接收查询和文档两个输入序列。
    CrossEncoder[CLS]\displaystyle \mathrm{CrossEncoder}_{[\mathrm{CLS}]}
  2. 拼接操作 $q, [\mathrm{SEP}], d$把查询文本 q、分隔符 $[\mathrm{SEP}]$ 和文档文本 d 拼成一个长序列,喂给模型。
    q,[SEP],d\displaystyle q, [\mathrm{SEP}], d
  3. $\mathrm{rel}(q, d)$ 分数模型输出的相关性数值,作为最终排序依据。
    rel(q,d)\displaystyle \mathrm{rel}(q, d)

评估优化

老周每月末都会做一次“找书满意度盘点”:学生借走的书里,有多少真的派上了用场?有没有明明馆里有好书,自己却没推荐出去?这个复盘动作,对应 RAG 检索器的评估优化环节。没有评估,检索器就像一个闭着眼找书的图书馆员,好坏全凭感觉。

最核心的评估指标是召回率(Recall@k),它回答的问题是:所有“应该被找到”的相关文档中,检索器真的捞上来了多少?假设馆里有 10 本和“苹果产业”相关的书,老周只推荐了 6 本,召回率就是 60%。公式如下:

除了召回率,生产环境还要同时盯住命中率(用户是否点击了某个检索结果)、延迟控制(P95 延迟必须低于 300ms 量级)和线上调优(通过 A/B 实验调整混合检索的权重参数、切块大小等)。老周的盘点还有一个隐藏动作:他会把“没推荐出去的好书”记下来,反思是卡片出了问题还是记忆有盲区——对应到工程上,就是分析漏召回案例,是切块太大信息丢失,还是向量模型对领域术语理解不足,然后针对性更换模型或调整参数。

评估优化就像老周的月度复盘,让找书服务“越用越准”。检索器不是搭好就能一劳永逸的,它需要一套持续迭代的度量体系来驱动改进,这也是资深检索工程师和初级开发者的核心差距所在。

Recall@k=∣Rrel∩Rret,k∣∣Rrel∣\displaystyle \mathrm{Recall@k} = \frac{| \mathcal{R}_{\text{rel}} \cap \mathcal{R}_{\text{ret},k} |}{| \mathcal{R}_{\text{rel}} |}
分子是“检索结果中真正相关的文档数”,分母是“全部相关文档数”,比值越高说明漏掉的越少。
  1. 相关文档集合 $\mathcal{R}_{\text{rel}}$由人工标注或线上反馈确定的、与查询真实相关的全部文档。
    Rrel\displaystyle \mathcal{R}_{\text{rel}}
  2. 检索结果集合 $\mathcal{R}_{\text{ret},k}$检索器返回的前 k 个结果。
    Rret,k\displaystyle \mathcal{R}_{\text{ret},k}
  3. 交集大小 $| \mathcal{R}_{\text{rel}} \cap \mathcal{R}_{\text{ret},k} |$两个集合的交集越大,说明捞回来的相关文档越多。
    Rrel∩Rret,k\displaystyle \mathcal{R}_{\text{rel}} \cap \mathcal{R}_{\text{ret},k}

落地实践

老周快要退休了,馆领导请他把毕生找书心得写成一本《图书馆员操作手册》,让新人也能达到“老周级别”的找书水平。这份手册对应的,就是 RAG 检索器的落地实践——把前面讲的算法、指标、策略,封装成一个稳定、高效、可运维的真实系统。

落地架构搭建时,系统通常分为三层:离线数据管道负责吃进原始文档、切块、生成向量并写入索引库;在线检索服务接收用户查询,并行调用稀疏检索和稠密检索,做 RRF 融合后再交给重排序模型;最后把 Top-k 结果组装成上下文交给 LLM。索引管理方面,要考虑大规模文档支持增量更新:新文档进来只重建它对应的稀疏倒排项和稠密向量,而不是全量重建索引。缓存策略是降延迟的利器:热门查询的检索结果可以缓存一段时间,直接命中缓存就能把响应时间从几百毫秒压到几十毫秒。缓存命中率的计算公式很直观:

最后是监控运维:用面板实时展示检索延迟、召回率、缓存命中率、LLM 生成质量等指标,设置告警阈值,定期复盘线上 bad case。老周手册的最后一页写着:“永远不要停止观察你的读者。”对系统而言,持续观察与迭代,才是检索器长期稳定服务的关键。

CacheHit=Ncache hitNtotal\displaystyle \mathrm{CacheHit} = \frac{N_{\text{cache hit}}}{N_{\text{total}}}
命中率等于“缓存直接返回的请求数”除以“总请求数”,比率越高说明缓存越有效,系统负载越低。
  1. 缓存命中次数 $N_{\text{cache hit}}$查询在缓存中找到缓存结果的次数,就像老周直接记得答案、不用再翻书架。
    Ncache hit\displaystyle N_{\text{cache hit}}
  2. 总请求次数 $N_{\text{total}}$系统收到所有查询的总数。
    Ntotal\displaystyle N_{\text{total}}

AI 应用场景

企业知识库智能问答系统

阶段:RAG 检索阶段(检索器设计)

解决的问题:企业内部有几千份产品手册、FAQ 和政策文档,员工提问口吻五花八门。传统方式用关键词搜索,问“怎么退货”只能搜到含“退货”二字的文档;想搜“收到货不满意怎么办”就完全失灵。采用混合检索后,BM25 负责精确命中专有名词(如“SN 码”“退款时效”),稠密检索负责理解“不满意”“想退”这样的口语化表达,二者经 RRF 融合,检索召回率大幅提升。

具体案例:某电商客服系统接入 RAG 混合检索后,客服机器人首答命中率从 52% 提升到 74%,平均响应时间从 1.8 秒降至 0.6 秒(借助缓存策略)。遇到模糊提问时,重排序阶段自动把最相关的标准操作流程排到第一,客服直接复制即可回复。

代码智能搜索助手

阶段:检索器设计 + 重排序

解决的问题:开发者经常用自然语言描述“帮我找一个解析 JSON 并处理异常的例子”,传统代码搜索靠正则匹配函数名几乎找不到。稀疏检索可以精确匹配“json.loads“等 API 名,稠密检索可以理解”解析数据”“读取配置文件”这类语义描述。混合检索让两种能力协同,最后用交叉编码器对候选代码片段重排,把最贴合问法的实现排在最前。

具体案例:一款 IDE 插件内置 RAG 检索器,索引了开源社区的 500 万段代码。开发者输入“读取 Excel 并导出 CSV”,系统先通过稀疏检索命中含 openpyxl 关键词的代码段,同时由稠密检索召回描述性匹配的实现,RRF 融合后重排,最终推荐结果被开发者采纳的比例达到 68%。

Python 演示

python128 行
# -*- coding: utf-8 -*-import mathimport re  def _stable_hash_idx(ch, mod=64):    """稳定的字符哈希,避免内置 hash() 随机化影响结果"""    return (ord(ch) * 2654435761) % mod  class SparseSearch:    """稀疏检索:简化版 BM25 关键词打分"""     def __init__(self):        self.docs = {}        self.doc_len = {}        self.df = {}        self.avgdl = 0.0     def fit(self, corpus):        n = len(corpus)        total = 0        for doc_id, text in corpus.items():            tokens = re.findall(r'\w+', text.lower())            self.docs[doc_id] = tokens            self.doc_len[doc_id] = len(tokens)            total += self.doc_len[doc_id]            for term in set(tokens):                self.df[term] = self.df.get(term, 0) + 1        self.avgdl = total / n     def _idf(self, term):        n = len(self.docs)        df = self.df.get(term, 0)        return math.log(1 + (n - df + 0.5) / (df + 0.5))     def _score(self, query_tokens, doc_id):        k1, b = 1.5, 0.75        score = 0.0        for term in query_tokens:            tf = self.docs[doc_id].count(term)            if tf == 0:                continue            denom = tf + k1 * (1 - b + b * self.doc_len[doc_id] / self.avgdl)            score += self._idf(term) * tf * (k1 + 1) / denom        return score     def search(self, query, k=5):        q_tokens = re.findall(r'\w+', query.lower())        scored = [(doc_id, self._score(q_tokens, doc_id)) for doc_id in self.docs]        scored.sort(key=lambda x: x[1], reverse=True)        return scored[:k]  class DenseSearch:    """稠密检索:用字符频次哈希模拟语义向量(真实场景用BERT等编码器)"""     def __init__(self, dim=64):        self.dim = dim        self.doc_vec = {}     @staticmethod    def _char_freq(text):        freq = {}        for ch in text.lower():            if ch.isalpha():                freq[ch] = freq.get(ch, 0) + 1        return freq     def _embed(self, text):        vec = [0.0] * self.dim        for ch, cnt in self._char_freq(text).items():            vec[_stable_hash_idx(ch, self.dim)] += cnt        norm = math.sqrt(sum(v * v for v in vec))        return [v / norm for v in vec] if norm > 0 else vec     def fit(self, corpus):        for doc_id, text in corpus.items():            self.doc_vec[doc_id] = self._embed(text)     def search(self, query, k=5):        q_vec = self._embed(query)        scored = []        for doc_id, d_vec in self.doc_vec.items():            dot = sum(a * b for a, b in zip(q_vec, d_vec))            scored.append((doc_id, dot))        scored.sort(key=lambda x: x[1], reverse=True)        return scored[:k]  def rrf_fusion(sparse_res, dense_res, k_const=60):    """倒数排名融合:不看分数只看排名,量纲无关"""    fused = {}    for rank, (doc_id, _) in enumerate(sparse_res):        fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k_const + rank + 1)    for rank, (doc_id, _) in enumerate(dense_res):        fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k_const + rank + 1)    return sorted(fused.items(), key=lambda x: x[1], reverse=True)  def main():    corpus = {        0: "苹果是一款流行的智能手机",        1: "香蕉是黄色水果",        2: "苹果汁是常见饮料",        3: "华为手机同样很受欢迎",        4: "葡萄可以酿造葡萄酒",        5: "水果富含维生素和纤维",        6: "iPhone是苹果公司的产品",        7: "电脑和笔记本是办公电子设备",    }    sparse = SparseSearch()    sparse.fit(corpus)    dense = DenseSearch()    dense.fit(corpus)     query = "苹果 手机"    s_res = sparse.search(query, k=5)    d_res = dense.search(query, k=5)    merged = rrf_fusion(s_res, d_res)     print("稀疏检索:", [doc_id for doc_id, _ in s_res])    print("稠密检索:", [doc_id for doc_id, _ in d_res])    print("混合检索:", [doc_id for doc_id, _ in merged[:5]])  if __name__ == "__main__":    main()
目的:在一份 8 篇文档的小语料上,演示稀疏检索(简化 BM25)、稠密检索(哈希特征向量)和 RRF 融合三种策略的完整流程,帮助理解混合检索器的工程结构。
  1. SparseSearch.fit(corpus)统计词频、文档频率和平均长度,为后续 BM25 打分准备参数。
  2. _idf(term)计算逆文档频率,词汇在全库中越稀有,区分度权重越高。
  3. DenseSearch._embed(text)把文本映射为归一化特征向量,真实工程中此处替换为 BERT 等预训练编码器。
  4. search(query, k)对每个文档打分并返回 Top-k,两种检索器接口保持一致,方便上层融合。
  5. rrf_fusion(sparse_res, dense_res)按排名倒数加权融合,优雅解决两套分数量纲不一致的问题。
  6. main()用“苹果 手机”查询验证:稀疏命中 0、3、6 号,稠密通过语义邻近补全其他候选,混合后召回更全。

常见误区

误区“稀疏检索不就是看关键词在不在文档里吗?这种老掉牙的方法肯定不如深度学习检索,能换我就换掉。”

正解稀疏检索远不止字符串匹配。以 BM25 为代表的算法融合了词频饱和、逆文档频率和文档长度归一化,能捕捉词的重要性和分布规律,在精确术语、专有名词、代码片段场景中依然是最强基线。深度学习检索反而可能在这些场景“翻车”。老周的卡片系统虽然简单,但每张卡片都记录着精心整理的主题词——卡片不是废纸,是几十年的经验沉淀。

为何常见:因为教程和宣传总在强调“向量检索是未来”,很多人忽略了 BM25 在工业界的统治地位,容易产生“新的一定更好”的错觉。

误区“用了 BERT 做向量检索,同义词、语义、跨语言都能搞定,没必要再保留词法检索了。”

正解稠密检索对文本的整体语义敏感,却对数字、生僻专名、拼写错误和精确逻辑(如“价格大于 100 且小于 200”)极不敏感。例如检索"ISBN 978-7-121-45678-9",向量模型常常找不到精确匹配,而 BM25 一抓一个准。老周大脑再聪明,也记不住每个学生的学号——这时候还是得翻卡片。

为何常见:因为 demo 里向量检索“懂人话”的效果非常惊艳,让人忽视了它在精确匹配上的短板,而真实业务恰恰充满数字和专名。

误区“稀疏检索给个分,稠密检索给个分,加权求和不就行了吗?”

正解BM25 分数和余弦相似度量纲完全不同——前者可能高达十几,后者只在 0~1 之间,直接求和等于让 BM25 主导一切,稠密检索形同虚设。工业界更常用的 RRF 融合根本不看分数,只看排名倒数的和,天然免疫量纲问题。老周从不会把卡片张数和记忆清晰度直接相加,而是“先各自排个名,再综合看名次”。

为何常见:直觉上“两个分数相加”很自然,但没意识到模型输出的分数并没有统一的概率含义。

误区“搭好索引、上线服务之后,检索器就可以稳定跑下去了,主要精力应该放在调 Prompt 上。”

正解检索器需要持续评估迭代。文档库会增长、用户问法会漂移、业务术语会更新,向量模型也需要周期性评测和重训。老周每月都做满意度盘点,新书上架时也要更新卡片和记忆地图——不做运维的检索器会在三个月后悄悄“生锈”。

为何常见:因为早期 RAG 项目的瓶颈常在 LLM 生成环节,导致团队误以为检索侧“没什么可调的”,忽略了长期维护成本。

面试题

Q1. 下列哪项是稀疏检索的代表算法?

单选题 · easy

  • A.BM25算法← 正确答案
  • B.K均值聚类
  • C.贝叶斯分类
  • D.支持向量机

答案:A。BM25 是经典的词法统计检索算法,属于稀疏检索。K 均值、贝叶斯、SVM 都是机器学习方法而非文本检索算法。就像老周的“关键词卡片”是翻卡匹配,而不是让书自己聚类。

Q2. BM25 中 IDF(逆文档频率)的主要作用是什么?

单选题 · easy

  • A.衡量词的全库稀有度← 正确答案
  • B.统计词的出现次数
  • C.将单词转为向量
  • D.归一化最终得分

答案:A。IDF 衡量一个词在全文档库中是否稀有,“的”“是”这类常见词 IDF 低,没有区分度;“苹果”这类稀有词 IDF 高,命中后贡献更大。词频是 TF 的职责,向量转换是嵌入模型的职责。

Q3. 稠密检索的核心技术基础是什么?

单选题 · mid

  • A.统计词频并加权
  • B.将文本编码为语义向量← 正确答案
  • C.构建倒排索引表
  • D.基于规则做布尔匹配

答案:B。稠密检索依赖神经网络(如 BERT)把文本编码成高维语义向量,用向量距离近似语义相似度。词频加权和倒排索引属于稀疏检索阵营,规则匹配与神经网络无关。

Q4. RRF 混合融合策略的核心思想是什么?

单选题 · mid

  • A.基于倒数排名的加权← 正确答案
  • B.随机森林排序算法
  • C.递归相关度评估函数
  • D.检索结果重过滤机制

答案:A。RRF(Reciprocal Rank Fusion)根据文档在多个结果列表中的排名倒数求和,例如排名第 1 贡献 $1/(60+1)$ 分。它不关心原始分数大小,因此天然免疫不同检索器之间的分数量纲差异,是老周“综合名次”策略的数学版。

Q5. 稀疏检索存在哪些明显局限?

多选题 · mid

  • A.无法处理同义词问题← 正确答案
  • B.对拼写错误较为敏感← 正确答案
  • C.检索速度天生非常慢
  • D.需要人工标注大量数据

答案:AB。稀疏检索基于字面词匹配,同义词(“水果”vs“苹果”)无法互通,拼写错误直接导致匹配失败,因此 A、B 正确。它的倒排索引机制恰恰非常快,也不依赖标注数据,所以 C、D 错误。

Q6. 某文档在稀疏和稠密结果中均排第 1 名,RRF 融合(k=60)后该文档得分为多少?

单选题 · extreme

  • A.约1/61
  • B.约2/61← 正确答案
  • C.约1/60
  • D.约2/60

答案:B。RRF 对每个列表中排名第 1 的文档赋分 $1/(60+1)=1/61$,两个列表各一次,得分相加为 $2/61$。注意排名从 1 开始计数,且同一文档的分数要跨列表累加,这正是 RRF “重复出现得分叠加”的体现。

Q7. RAG 重排序阶段最常使用哪类模型?

单选题 · hard

  • A.交叉编码器模型← 正确答案
  • B.双塔编码器模型
  • C.词频统计法模型
  • D.布尔检索法模型

答案:A。重排序需要精细的查询-文档交互,交叉编码器把两者拼在一起做深度建模,精度最高。双塔编码器用于粗排阶段,因为查询和文档分别编码、交互不足;词频统计和布尔检索无法支撑精排。

Q8. 稠密检索相比稀疏检索的优势有哪些?

多选题 · hard

  • A.能够理解语义相似度← 正确答案
  • B.擅长处理同义词替换← 正确答案
  • C.编入向量无需任何索引
  • D.天然适合跨语言检索← 正确答案

答案:ABD。稠密检索把文本映射到语义空间,同义词和不同语言若语义相近,向量位置也接近,因此 A、B、D 都是其优势。但它同样需要 ANN 索引(如 HNSW)加速,并非“无需索引”,C 错误。

Q9. RAG 中文档切块(Chunking)粒度直接决定什么?

单选题 · easy

  • A.检索结果的粒度大小← 正确答案
  • B.生成文本的风格类型
  • C.语言模型的参数数量
  • D.采样温度的高低数值

答案:A。切块大小直接影响检索的定位精度——块太大噪声多,块太小语义不完整。生成风格、模型参数量和采样温度都与切块无关,它们属于 LLM 生成环节的配置。

Q10. 评估 RAG 检索器时应关注哪些指标?

多选题 · hard

  • A.Recall@k召回率← 正确答案
  • B.缓存命中率← 正确答案
  • C.困惑度PPL指标
  • D.延迟P95指标← 正确答案

答案:ABD。Recall@k 衡量检索召回能力,缓存命中率反映系统的成本与效率,P95 延迟保障用户体验,三者都是检索器核心指标。困惑度 PPL 用于评估语言模型的生成质量,不属于检索器评估范畴。老周的“满意度盘点”也要同时看推荐数、响应速度和效率,道理相通。