Day 31 Hard Search BM25 + Vector Reranking Multi-stage

混合检索与重排序 — BM25 与向量的多阶段拼接Hybrid Search & Reranking: BM25 + Vector, Multi-stage Retrieval, RRF, Cross-encoder, Snippets

问题场景 + 需求约束

为一个 5000 万 SKU 的电商目录设计搜索:既要 iPhone 15 Pro 256GB 这种精确型号/SKU 命中,也要「适合露营的轻便保暖外套」这种自然语言意图命中。纯关键词搜索对第二类查询召回为零(用户没打「外套」就搜不到);纯向量搜索对第一类又会把「iPhone 14」「iPhone 15 标准版」混进来,丢掉精确性。

关键数字:peak 5k QPS、p99 < 150ms、每查返回 top-24、目录 50M 文档、向量 768 维。约束:召回不能漏(漏 = 用户买不到 = 直接损失 GMV),但精排预算有限(cross-encoder 很贵)。这逼出整套多阶段设计——便宜的召回拉宽,昂贵的精排收窄。

高层架构

graph LR
    Q["Query
纠错/分词/改写"] BM["BM25 召回
倒排索引 · 精确词"] VEC["向量召回
HNSW ANN · 语义"] FUSE["融合
RRF / 加权"] RANK["精排 Rerank
cross-encoder / GBDT"] BIZ["业务重排
价格/库存/广告/个性化"] SNIP["Snippet 高亮
query-biased"] R["Top-24"] Q --> BM --> FUSE Q --> VEC --> FUSE FUSE -->|top-200| RANK -->|top-50| BIZ --> SNIP --> R classDef q fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef rec fill:#0e2030,stroke:#5eead4,color:#e8eef5 classDef rank fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef out fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class Q q class BM,VEC,FUSE rec class RANK,BIZ rank class SNIP,R out

漏斗:召回拉到几百(高 recall、低成本)→ 精排收到几十(高 precision、高成本)→ 业务重排定最终序

核心思想:把「找全」和「排准」拆成不同阶段。召回层用两条便宜的通道(关键词 + 语义)并行拉候选,融合去重;精排层只对几百个候选跑昂贵模型;最后叠加价格、库存、广告等业务信号。每一阶段的延迟预算和候选数严格递减。

关键技术点

1. 混合检索:BM25 vs 稠密向量,为什么必须都要

核心 trade-off:词法精确 vs 语义泛化,两者的盲区正好互补

原理BM25(Okapi BM25,Robertson/Spärck Jones)是 TF-IDF 的概率改良版,按词频 + 逆文档频率打分,并对文档长度归一化、对高频词做饱和(一个词出现 10 次不等于 5 次的两倍价值)。它走倒排索引,对精确词、罕见词(SKU、型号、人名)、否定与布尔逻辑极强。稠密向量把 query 和文档各编码成一个 768 维向量(bi-encoder,如 BERT 类双塔),算余弦相似度,捕捉语义——「laptop」匹配「notebook computer」,「便宜」匹配「平价」。但它对精确 token 不敏感,且会「自信地编造相关性」。

Trade-off:
# 混合召回:两条通道并行,再融合
def retrieve(q):
    sparse = bm25_index.search(q.text, k=100)        # 倒排,精确词
    dense  = ann_index.search(embed(q.text), k=100)  # HNSW,语义
    return fuse(sparse, dense)        # 见技术点 3
现实案例:

2. 多阶段检索:召回 / 粗排 / 精排的漏斗

核心 trade-off:每阶段在「候选数」与「单条评估成本」之间做反向权衡

原理:不可能对 5000 万文档都跑昂贵模型。多阶段把检索建成漏斗:召回(recall)从全库拉几百个,目标是「别漏」,用最便宜的 BM25/ANN;精排(precision)只对这几百个跑重模型,目标是「排准」。关键在于每阶段成本 × 候选数 ≈ 常数——召回单条近乎免费但数量大,精排单条很贵但数量少,乘起来才落在延迟预算内。ANN(如 HNSW 分层可导航小世界图)就是召回阶段「用近似换速度」的代表:放弃精确 KNN,换 sub-ms 的对数级查找。

Trade-off(召回层 ANN 算法):
# 漏斗:候选数递减、单条成本递增
cand = hybrid_retrieve(q, k=200)        # ~亚毫秒/条,要全
cand = rerank_cross_encoder(q, cand)[:50]  # ~ms/条,要准
final = business_rerank(cand)[:24]      # 价格/库存/广告/个性化
现实案例:

3. 融合与重排序:RRF vs 加权,bi-encoder vs cross-encoder

核心 trade-off:融合解决「两套分数不可比」,重排序解决「双塔精度不够」,但 cross-encoder 慢两个数量级

原理:BM25 分数(无上界)和余弦相似度(0~1)量纲不同,直接加权平均会被某一路主导。RRF(Reciprocal Rank Fusion,Cormack 2009)只看排名不看分数:文档得分 = Σ 1/(k + rank),k 常取 60,天然解决量纲不可比问题,且无需归一化。融合后进精排:bi-encoder(召回用的双塔)query 和文档独立编码、可离线建索引;cross-encoder 把 query+文档拼接一起喂模型,让两者 token 充分交互,精度高得多,但必须在线现算、无法预存——这正是它只能放在精排(候选已缩到几十个)的原因。

Bi-encoder(双塔)Cross-encoder(交叉)
编码方式query / doc 各自独立编码query+doc 拼接联合编码
能否预建索引能(文档向量离线算好)不能(必须在线现算)
精度高(token 级交互)
成本低(一次向量比对)高(每对一次前向)
用在召回(百万级)精排(几十~几百)
# RRF:只用排名,免归一化
def rrf(lists, k=60):
    score = defaultdict(float)
    for ranked in lists:                 # 每条通道的有序结果
        for rank, doc in enumerate(ranked):
            score[doc] += 1.0 / (k + rank)
    return sorted(score, key=score.get, reverse=True)

# 精排:cross-encoder 现算(只跑融合后的 top-N)
scores = cross_encoder.predict([(q, d.text) for d in candidates])
融合 Trade-off:RRF 鲁棒免调参、对异常分数不敏感,但丢掉了分数幅度信息(强匹配和弱匹配同 rank 被等同);加权线性(α·norm(bm25)+(1-α)·cos)保留幅度、可调,但要做分数归一化、α 需按 query 类型调,易过拟合。生产里常默认 RRF,对高价值垂类再上加权或学习式融合。
现实案例:

4. Snippet 生成与高亮:让结果「看起来相关」

核心 trade-off:展示的是解释而非检索——选错片段,再准的排序用户也不点

原理:Snippet 是query-biased summarization:不是取文档前两句,而是选「最能体现与本次 query 相关」的窗口。经典做法滑动一个固定长度窗口,按窗口内 query 词命中数、词稀有度(IDF 高的词权重大)、词间距打分,取最高窗口并对命中词做 <em> 高亮。难点:高亮必须落在渲染后的原文 offset 上(分词、stemming、Unicode 归一化后位置会偏),错位会高亮错字符。

# query-biased 片段:选命中最密集、稀有词最多的窗口
def best_snippet(doc_tokens, q_terms, W=160):
    qset, best, lo = set(q_terms), (-1, 0), 0
    for i in range(0, len(doc_tokens)-W, 20):
        win = doc_tokens[i:i+W]
        # 命中数 + IDF 加权 + 覆盖不同 query 词的多样性
        s = sum(idf(t) for t in win if t in qset) \
            + 2*len({t for t in win if t in qset})
        if s > best[0]: best, lo = (s, i), i
    return highlight(doc_tokens[lo:lo+W], qset)  # 命中词包 <em>
Trade-off:词法高亮(命中词标黄)便宜、对精确查询直观,但对语义召回的结果可能「一个 query 词都不在片段里」(是向量召回的),显得不相关;抽取式 LLM 摘要能解释语义相关性,但贵、有延迟、可能产生幻觉。折中:精确命中走高亮,语义命中走简短抽取式摘要。
现实案例:Google/Bing 的搜索结果摘要长期用 query-biased 抽取 + 高亮;现代 RAG 系统则把 snippet 直接喂给 LLM 作为「带出处的上下文」,高亮变成 citation 锚点。

扩展与优化

常见陷阱 + 面试问题

1. 直接把 BM25 分和余弦相似度加权平均。 量纲不可比,结果被无上界的 BM25 主导。要么 RRF(只用 rank),要么先按 query 归一化再加权。
2. 用 cross-encoder 做召回。 它无法预建索引、必须在线现算,对全库跑 = 延迟爆炸。cross-encoder 只能在候选缩到几十~几百后用。
3. 只看 NDCG/precision 不看 recall。 漏召回在精排阶段无法补救——召回没捞到的文档,精排再强也排不出来。召回层的 recall@k 是上限。
4. 高亮 offset 错位。 分词/stemming/Unicode 归一化后位置漂移,CJK 尤其容易,必须在原始字节/字符串上回映射。

高频面试追问:

  1. 为什么不能只用向量搜索?给一个纯向量必漏、纯 BM25 必中的 query 例子,反之亦然。
  2. RRF 的 k 取 60 意味着什么?调大调小分别偏向头部还是尾部?
  3. bi-encoder 和 cross-encoder 的本质区别?为什么前者能建索引、后者不能?
  4. 5000 万文档、768 维向量,HNSW 索引大概占多少内存?怎么估?
  5. 召回阶段 recall@200 只有 85%,精排再好也救不回那 15%——怎么提召回?

深入资源

深入思考(点击展开答案)

1. 召回 recall@200=85%,精排 NDCG 很高,但线上 GMV 不涨。瓶颈在哪?怎么定位?

瓶颈极可能在召回,不在精排。精排只能在召回捞到的 200 个里重排——那漏掉的 15% 文档根本进不了候选集,精排再强也无能为力。NDCG 高只说明「捞到的排得好」,掩盖了「没捞到的看不见」。

定位方法:① 离线用人工标注/点击日志算 recall@k 上限——把已知应命中的文档拿去查召回,看进没进候选;② 拆 query 类型,大概率是语义型/长尾 query 召回差(向量没覆盖好)或精确型被向量噪声挤掉;③ A/B 把 k 从 200 提到 500,若 GMV 涨说明召回是瓶颈。修法:扩召回通道、提 ANN 的 efSearch、引入 SPLADE,而不是继续优化精排。核心洞察:多阶段漏斗里,上游的 recall 是下游一切的天花板。

2. RRF 的 k=60 调成 k=1 或 k=1000,排序行为各自怎么变?

RRF 得分 = Σ 1/(k+rank)。k 控制「排名差异被放大还是抹平」。

k 很小(如 1):1/(1+0)=1、1/(1+1)=0.5,头部名次之间差距巨大——极度偏向各通道的 rank-1,几乎是「谁家第一名谁赢」,对头部敏感、尾部几乎无贡献。k 很大(如 1000):1/1000 vs 1/1001 几乎相等,所有名次被压平,rank 信息被稀释,退化成「在多少个通道里出现过」的计数——更看重多通道共识而非单通道高排名。

k=60 是经验折中:既保留头部区分度,又让中段文档(在两条通道都中等排名的)能靠「共识」浮上来——这正是混合检索想要的「两边都觉得相关的更可信」。调参方向:想强调单通道强信号就减小 k,想强调跨通道一致性就增大 k。

3. 5000 万文档、768 维 float32 向量、HNSW,内存大概多少?怎么估?

向量本身:768 维 × 4 字节 = 3072 B ≈ 3 KB/条。5e7 × 3 KB ≈ 150 GB

HNSW 图结构:每个节点存 M 个邻居指针(每层),常见 M=16,每个邻居 ID 约 4–8 字节,含多层开销,经验上图额外占向量的 20%–60%。按 ~50% 估约 75 GB。

合计 ~200–230 GB,单机内存放不下/很贵。这就是为什么大规模要么分片(按 shard 拆到多机,查询 scatter-gather),要么用 PQ/量化把 3 KB 压到几百字节(IVF-PQ 可压 8–32 倍)换召回率,要么磁盘 ANN(DiskANN)把图放 SSD。面试官想看你会做数量级估算并据此选架构,而不是背 HNSW 公式。

4. 为什么 cross-encoder 精度高却不能用于召回?这背后是什么计算结构上的根本限制?

根本在能否预计算与摊销。bi-encoder 把 query 和 doc 独立编码成向量,文档向量可以离线算好存进 ANN 索引,在线只做向量比对——文档侧成本被一次性摊销。cross-encoder 把 query+doc 拼接后整体过模型,让两者的 token 在 attention 里逐对交互(这正是它精度高的来源),但也意味着结果依赖具体 query,无法离线预存——每个 (query, doc) 对都得在线现跑一次完整前向。

对 5000 万文档,召回阶段要评估的对数是「query × 全库」,cross-encoder 就是 5000 万次前向,秒级都打不住。所以它只能放在候选已缩到几十~几百的精排阶段。洞察:交互越充分精度越高,但交互让结果无法预计算——精度与可摊销性是一对根本矛盾,多阶段架构正是用它来分工。ColBERT 的「late interaction」就是想在两者间取中间态。

5. 向量召回会「自信地召回不相关结果」——它永远返回最近邻,哪怕全库没有真正相关的。怎么防?

这是稠密检索的结构性缺陷:ANN总能找到「最近」的 k 个,但「最近」不等于「相关」。query 落在嵌入空间的稀疏区域时,最近邻可能离得很远却仍被返回——表现为「搜一个库里根本没有的东西,也给你一堆不沾边的结果」。

防御手段:① 相似度阈值——余弦低于阈值的直接丢,宁可返回少或空也不返回噪声(但阈值随 query 漂移,难统一);② 用 BM25 兜底/校验——混合检索里若向量召回的文档一个 query 词都不含、BM25 分也极低,降权或剔除;③ cross-encoder 精排做守门——它能给出绝对相关性分数,把低分的滤掉;④ OOD/置信检测——监控 query 嵌入到最近邻的距离分布,异常远的判为「无好结果」走兜底文案。关键认知:向量检索给的是相对排序,不是绝对相关性判断——绝对判断要靠阈值或 cross-encoder 补。