语义搜索架构:召回、粗排、精排三阶段缺一不可
一、为什么只靠向量相似度搜索不够
向量检索(Vector Search)是语义搜索的基石。将查询和文档都编码为向量,通过余弦相似度或欧氏距离找最相近的 Top K 文档。这套方案的简洁性使得它在 RAG 系统中被大量采用。
但在实际使用中,向量检索有三个明显缺陷。第一,embedding 模型对语义的理解有限,长文本的全局向量可能丢失关键细节。第二,相似度最高的文档不一定是"最相关"的文档——向量空间中的近邻可能只是词汇层面相似。第三,跨领域的零样本泛化能力不足,在训练集未见过的领域,检索质量断崖式下降。
搜索系统的目标不是"找到最像的",而是"找到最相关的"。这两个目标在向量空间中不是完全重合的。弥补这一差距的方法,就是多阶段检索架构:用不同粒度、不同成本的方法,逐级筛选候选集。
见证奇迹的时刻是:当召回阶段用向量检索抓"海选",粗排阶段用轻量模型筛"候选",精排阶段用重量级模型定"最终排名"——三阶段配合后,检索准确率比单一向量搜索提升了 30%~50%。
二、三阶段检索架构的设计原理
graph TD A[用户查询] --> B[查询理解模块] B --> B1[查询改写与扩展] B --> B2[意图分类与实体抽取] B1 --> C[第一阶段: 召回] B2 --> C C --> C1[向量检索<br/>Top K=1000] C --> C2[关键词检索<br/>BM25, Top K=100] C --> C3[知识图谱检索<br/>实体链接] C1 --> D[第二阶段: 粗排] C2 --> D C3 --> D D --> D1[轻量 Cross-encoder<br/>Top K=50] D --> D2[规则特征: 时效性/热度/权威性] D1 --> E[第三阶段: 精排] D2 --> E E --> E1[重量级 Cross-encoder<br/>逐条精排] E --> E2[多维度特征融合<br/>相关性+时效+权威+多样性] E1 --> F[最终结果: Top K=10] E2 --> F style C fill:#e1f5fe style D fill:#fff3e0 style E fill:#e8f5e9 style F fill:#f3e5f5第一阶段:多路召回。不是单一的向量检索,而是向量、关键词和知识图谱三条通路同时召回候选文档。三条通路互为补充:向量检索捕捉语义相似性,关键词检索保证字面匹配的精确性,知识图谱检索利用结构化知识做实体关联。多路召回保证了高召回率(Recall > 95%),但精确率很低(候选集中 90% 以上是不相关的)。
第二阶段:粗排(Re-ranking Lite)。对召回的 1000+ 候选文档,用轻量级双塔模型(Query-Document BERT)快速打分,筛到 Top 50。粗排模型的速度要求很高——每条文档的处理时间必须在 1~5 毫秒级别。精度不如精排,但速度够快,可以过滤掉明显不相关的文档。
第三阶段:精排(Fine Ranking)。对 Top 50 候选文档,用重量级的 Cross-encoder(如交互式 BERT)逐条精排。精排模型将查询和文档拼接后送入模型做全注意力交互,精度最高,但速度最慢(每条 20~50 毫秒)。通过前两阶段的筛选,精排阶段只需要处理 50 条文档,总耗时在可接受范围内。
三、三阶段检索引擎的实现
以下代码展示了简化版的三阶段检索管道。
import numpy as np from typing import List, Dict, Tuple, Any from dataclasses import dataclass @dataclass class SearchResult: """搜索结果""" doc_id: str content: str score: float source: str # 召回来源: vector/keyword/kg class MultiStageSearcher: """三阶段语义搜索引擎 设计原因:将检索拆分为三个阶段,用逐渐增加的模型精度 换取逐渐缩小的候选集规模。三个阶段的精度-速度关系: 召回(高召回+低速度+大集)-> 粗排(中精度+高速+中集) -> 精排(高精度+低速+小集)。 """ def __init__(self, embedding_model, bm25_index, light_reranker, heavy_reranker): self.embedding_model = embedding_model self.bm25_index = bm25_index self.light_reranker = light_reranker self.heavy_reranker = heavy_reranker def search(self, query: str, top_k: int = 10) -> List[SearchResult]: """执行三阶段搜索""" # 阶段1: 多路召回 candidates = self._multi_recall(query) # 阶段2: 粗排 reranked = self._light_rerank(query, candidates) # 阶段3: 精排 final = self._heavy_rerank(query, reranked[:50], top_k) return final def _multi_recall(self, query: str) -> List[SearchResult]: """多路召回 设计原因:单路召回容易漏掉语义词相关但字面不匹配的文档。 多路召回通过不同机制的互补,大幅提升召回率。 不同召回源的结果使用统一的 SearchResult 格式归一化。 """ results = [] # 通路1: 向量检索 # 设计原因:对于语义模糊或抽象概念的查询, # 向量检索能捕捉到文本中不直接出现但语义相关的文档。 query_vector = self.embedding_model.encode(query) vector_results = self._vector_search(query_vector, top_k=1000) for doc in vector_results: results.append(SearchResult( doc_id=doc['id'], content=doc['text'], score=doc['score'], source='vector' )) # 通路2: 关键词检索 (BM25) # 设计原因:对于包含专有名词或精确术语的查询, # 关键词匹配的精确率远高于向量检索。 keyword_results = self.bm25_index.search(query, top_k=100) for doc in keyword_results: results.append(SearchResult( doc_id=doc['id'], content=doc['text'], score=float(doc['score']), source='keyword' )) # 按 doc_id 去重(保留最高分) seen = {} for r in results: if r.doc_id not in seen or r.score > seen[r.doc_id].score: seen[r.doc_id] = r return list(seen.values()) def _light_rerank(self, query: str, candidates: List[SearchResult]) -> List[SearchResult]: """轻量粗排 设计原因:使用双塔模型(Query 和 Document 分别编码后点积), 速度快(1~5ms/条),精度中等(NDCG@10 ≈ 0.75)。 目标是保留 Top 50 候选进入精排。 """ q_vec = self.light_reranker.encode_query(query) for candidate in candidates: d_vec = self.light_reranker.encode_document(candidate.content) # 将原始检索分数与粗排分数加权融合 # 设计原因:不单纯依赖粗排分数, # 防止粗排模型的偏差覆盖掉召回阶段的正确排序。 candidate.score = ( 0.3 * candidate.score + 0.7 * np.dot(q_vec, d_vec) ) candidates.sort(key=lambda x: x.score, reverse=True) return candidates[:50] def _heavy_rerank(self, query: str, candidates: List[SearchResult], top_k: int) -> List[SearchResult]: """精确重排 设计原因:Cross-encoder 将 Query 和 Document 拼接后做全注意力, 精度最高(NDCG@10 ≈ 0.85),但速度最慢(20~50ms/条)。 这就是为什么必须在精排之前通过粗排大幅缩小候选集。 """ for candidate in candidates: score = self.heavy_reranker.score(query, candidate.content) candidate.score = score candidates.sort(key=lambda x: x.score, reverse=True) return candidates[:top_k]四、单阶段与多阶段检索的权衡分析
多阶段检索的收益是明确的——用较低的总体计算成本获得较高的检索精度。但代价同样不容忽视。
| 维度 | 单阶段(仅向量检索) | 三阶段 |
|---|---|---|
| 检索延迟 | 50~100ms | 200~500ms |
| 准确率 (Recall@10) | 0.85 | 0.92+ |
| 系统复杂度 | 低 | 高 |
| 维护成本 | 1 个模型 | 3 个模型 + 2 个索引 |
| 资源占用 | 1 路 | 3 路(召回) + 2 路(排序) |
选择建议:
- 文档总量 < 10 万且召回质量要求不高:单阶段向量检索即可。
- 文档总量在 10 万~100 万:两阶段(召回 + 粗排)足够。
- 文档总量 > 100 万且对 Top 10 精度要求高:必须三阶段。
见证奇迹的时刻在于认识到:检索系统的问题不是"该用几阶段",而是"每阶段的定位"——召回是保证不漏(Recall 优先),粗排是快速清理噪音,精排是保证对(Precision 优先)。把一个阶段的职责分配给另一个阶段,是检索系统设计的根本错误。
五、总结
语义搜索的三阶段架构将检索任务分解为召回(高召回率、多通路)、粗排(中精度、高速度)和精排(高精度、低速度)三个层次。每阶段处理的候选集规模依次缩小(1000+ → 50 → 10),而精度依次提升。多路召回要求向量检索、关键词检索和知识图谱检索三通路互补,共同保证召回覆盖率。粗排模型优先使用双塔架构以保证速度,精排模型使用 Cross-encoder 以保证精度。三阶段架构的延迟代价约为 200500ms,相比单阶段增加了 35 倍,但检索准确率提升 30%~50%。文档规模越大,多阶段架构的收益越明显。