为一个 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、高成本)→ 业务重排定最终序
核心思想:把「找全」和「排准」拆成不同阶段。召回层用两条便宜的通道(关键词 + 语义)并行拉候选,融合去重;精排层只对几百个候选跑昂贵模型;最后叠加价格、库存、广告等业务信号。每一阶段的延迟预算和候选数严格递减。
核心 trade-off:词法精确 vs 语义泛化,两者的盲区正好互补。
原理:BM25(Okapi BM25,Robertson/Spärck Jones)是 TF-IDF 的概率改良版,按词频 + 逆文档频率打分,并对文档长度归一化、对高频词做饱和(一个词出现 10 次不等于 5 次的两倍价值)。它走倒排索引,对精确词、罕见词(SKU、型号、人名)、否定与布尔逻辑极强。稠密向量把 query 和文档各编码成一个 768 维向量(bi-encoder,如 BERT 类双塔),算余弦相似度,捕捉语义——「laptop」匹配「notebook computer」,「便宜」匹配「平价」。但它对精确 token 不敏感,且会「自信地编造相关性」。
# 混合召回:两条通道并行,再融合
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
核心 trade-off:每阶段在「候选数」与「单条评估成本」之间做反向权衡。
原理:不可能对 5000 万文档都跑昂贵模型。多阶段把检索建成漏斗:召回(recall)从全库拉几百个,目标是「别漏」,用最便宜的 BM25/ANN;精排(precision)只对这几百个跑重模型,目标是「排准」。关键在于每阶段成本 × 候选数 ≈ 常数——召回单条近乎免费但数量大,精排单条很贵但数量少,乘起来才落在延迟预算内。ANN(如 HNSW 分层可导航小世界图)就是召回阶段「用近似换速度」的代表:放弃精确 KNN,换 sub-ms 的对数级查找。
# 漏斗:候选数递减、单条成本递增
cand = hybrid_retrieve(q, k=200) # ~亚毫秒/条,要全
cand = rerank_cross_encoder(q, cand)[:50] # ~ms/条,要准
final = business_rerank(cand)[:24] # 价格/库存/广告/个性化
核心 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:展示的是解释而非检索——选错片段,再准的排序用户也不点。
原理: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>
高频面试追问:
瓶颈极可能在召回,不在精排。精排只能在召回捞到的 200 个里重排——那漏掉的 15% 文档根本进不了候选集,精排再强也无能为力。NDCG 高只说明「捞到的排得好」,掩盖了「没捞到的看不见」。
定位方法:① 离线用人工标注/点击日志算 recall@k 上限——把已知应命中的文档拿去查召回,看进没进候选;② 拆 query 类型,大概率是语义型/长尾 query 召回差(向量没覆盖好)或精确型被向量噪声挤掉;③ A/B 把 k 从 200 提到 500,若 GMV 涨说明召回是瓶颈。修法:扩召回通道、提 ANN 的 efSearch、引入 SPLADE,而不是继续优化精排。核心洞察:多阶段漏斗里,上游的 recall 是下游一切的天花板。
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。
向量本身: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 公式。
根本在能否预计算与摊销。bi-encoder 把 query 和 doc 独立编码成向量,文档向量可以离线算好存进 ANN 索引,在线只做向量比对——文档侧成本被一次性摊销。cross-encoder 把 query+doc 拼接后整体过模型,让两者的 token 在 attention 里逐对交互(这正是它精度高的来源),但也意味着结果依赖具体 query,无法离线预存——每个 (query, doc) 对都得在线现跑一次完整前向。
对 5000 万文档,召回阶段要评估的对数是「query × 全库」,cross-encoder 就是 5000 万次前向,秒级都打不住。所以它只能放在候选已缩到几十~几百的精排阶段。洞察:交互越充分精度越高,但交互让结果无法预计算——精度与可摊销性是一对根本矛盾,多阶段架构正是用它来分工。ColBERT 的「late interaction」就是想在两者间取中间态。
这是稠密检索的结构性缺陷:ANN总能找到「最近」的 k 个,但「最近」不等于「相关」。query 落在嵌入空间的稀疏区域时,最近邻可能离得很远却仍被返回——表现为「搜一个库里根本没有的东西,也给你一堆不沾边的结果」。
防御手段:① 相似度阈值——余弦低于阈值的直接丢,宁可返回少或空也不返回噪声(但阈值随 query 漂移,难统一);② 用 BM25 兜底/校验——混合检索里若向量召回的文档一个 query 词都不含、BM25 分也极低,降权或剔除;③ cross-encoder 精排做守门——它能给出绝对相关性分数,把低分的滤掉;④ OOD/置信检测——监控 query 嵌入到最近邻的距离分布,异常远的判为「无好结果」走兜底文案。关键认知:向量检索给的是相对排序,不是绝对相关性判断——绝对判断要靠阈值或 cross-encoder 补。