为什么单一检索不够?
在RAG(检索增强生成)系统中,检索质量直接决定了生成质量。许多团队在初次搭建RAG时选择纯向量检索,但在生产环境中很快遇到瓶颈:精确匹配查询失败、专业术语召回率低、长尾知识覆盖率不足。
问题的根源在于:向量检索擅长语义匹配,但不擅长精确匹配;BM25擅长关键词匹配,但不理解语义。 两者各有盲区,而混合检索正是取长补短的解决方案。
三阶段混合检索架构
架构总览
用户查询
│
├──→ BM25 检索(关键词召回)
│ ↓ Top-K1
├──→ 向量检索(语义召回)
│ ↓ Top-K2
│
└──→ 融合排序(RRF/加权融合)
↓ Top-N
重排序模型(Cross-Encoder)
↓ Top-M
最终上下文
第一阶段:双路召回
BM25:关键词召回的基石
from rank_bm25 import BM25Okapi
import jieba
class BM25Retriever:
def __init__(self, documents):
self.documents = documents
# 中文需要分词
self.tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
self.bm25 = BM25Okapi(self.tokenized_docs)
def search(self, query, top_k=20):
tokenized_query = list(jieba.cut(query))
scores = self.bm25.get_scores(tokenized_query)
# 获取Top-K结果
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [(idx, scores[idx]) for idx in top_indices]
BM25的优势在于:对精确关键词查询、产品型号、代码片段标识符等场景的召回率远高于向量检索。
向量检索:语义召回的主力
from sentence_transformers import SentenceTransformer
import numpy as np
class VectorRetriever:
def __init__(self, model_name="BAAI/bge-large-zh-v1.5"):
self.model = SentenceTransformer(model_name)
self.document_embeddings = None
self.documents = None
def index(self, documents):
self.documents = documents
self.document_embeddings = self.model.encode(
documents,
normalize_embeddings=True,
batch_size=64,
show_progress_bar=True
)
def search(self, query, top_k=20):
query_embedding = self.model.encode([query], normalize_embeddings=True)
# 余弦相似度(已归一化,直接点积)
scores = np.dot(self.document_embeddings, query_embedding.T).flatten()
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [(idx, scores[idx]) for idx in top_indices]
第二阶段:分数融合
双路召回后需要将结果融合。业界有两种主流策略:
倒数秩融合(RRF)
RRF不依赖原始分数,只使用排名,天然解决了不同检索器分值尺度不一致的问题:
def reciprocal_rank_fusion(bm25_results, vector_results, k=60):
"""
RRF融合:score = Σ 1/(k + rank_i)
k通常取60,来源于原始论文的经验值
"""
fused_scores = {}
for rank, (doc_idx, _) in enumerate(bm25_results):
if doc_idx not in fused_scores:
fused_scores[doc_idx] = 0
fused_scores[doc_idx] += 1.0 / (k + rank + 1)
for rank, (doc_idx, _) in enumerate(vector_results):
if doc_idx not in fused_scores:
fused_scores[doc_idx] = 0
fused_scores[doc_idx] += 1.0 / (k + rank + 1)
# 按融合分数排序
return sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
加权分数融合
当各检索器的分数可信度不同时,加权融合更灵活:
def weighted_fusion(bm25_results, vector_results, alpha=0.3):
"""
alpha: BM25权重, (1-alpha): 向量检索权重
分数需先归一化到[0,1]
"""
def normalize(scores):
if not scores:
return {}
max_s = max(s for _, s in scores)
min_s = min(s for _, s in scores)
if max_s == min_s:
return {idx: 1.0 for idx, _ in scores}
return {idx: (s - min_s) / (max_s - min_s) for idx, s in scores}
bm25_norm = normalize(bm25_results)
vec_norm = normalize(vector_results)
all_docs = set(bm25_norm.keys()) | set(vec_norm.keys())
fused = {}
for doc in all_docs:
fused[doc] = alpha * bm25_norm.get(doc, 0) + (1 - alpha) * vec_norm.get(doc, 0)
return sorted(fused.items(), key=lambda x: x[1], reverse=True)
第三阶段:重排序
重排序是混合检索的精度保证层。与双塔模型(Bi-Encoder)不同,Cross-Encoder将query和document拼接后一起输入Transformer,获得更深的交互特征:
from sentence_transformers import CrossEncoder
class Reranker:
def __init__(self, model_name="BAAI/bge-reranker-large"):
self.model = CrossEncoder(model_name, max_length=512)
def rerank(self, query, documents, top_k=5):
# 构造query-doc对
pairs = [(query, doc) for doc in documents]
scores = self.model.predict(pairs)
# 按重排序分数取Top-K
ranked = sorted(zip(range(len(documents)), scores),
key=lambda x: x[1], reverse=True)[:top_k]
return ranked
生产环境关键参数调优
各阶段召回数量配置
| 文档库规模 | BM25 Top-K | 向量 Top-K | 融合后 Top-N | 重排序 Top-M |
|---|---|---|---|---|
| <1万 | 20 | 20 | 20 | 5 |
| 1-10万 | 30 | 30 | 30 | 5 |
| 10-100万 | 50 | 50 | 40 | 5-10 |
| >100万 | 100 | 100 | 50 | 10 |
实测效果对比
在一份企业内部知识库(约5万文档)上的评测结果:
| 检索策略 | Recall@5 | MRR@10 | 平均延迟 |
|---|---|---|---|
| 纯BM25 | 62.3% | 0.51 | 15ms |
| 纯向量 | 71.8% | 0.63 | 45ms |
| RRF融合 | 78.5% | 0.69 | 55ms |
| 融合+重排序 | 86.2% | 0.81 | 180ms |
混合检索将Recall@5从71.8%提升到86.2%,代价是约135ms的额外延迟。对于大多数RAG场景,这个延迟是完全可以接受的。
工程踩坑指南
1. BM25中文分词陷阱
使用通用分词器(如jieba默认模式)处理专业领域文档时,术语往往被错误切分。建议加载领域自定义词典:
import jieba
jieba.load_userdict("domain_dict.txt")
# 领域词典示例:量子计算、联邦学习、提示注入
2. 向量索引的批量更新
生产环境中文档会持续更新。避免全量重建索引,采用增量策略:
# 使用支持增量插入的向量数据库
import faiss
index = faiss.IndexFlatIP(1024) # 内积(配合归一化即余弦相似度)
def add_documents(new_embeddings):
"""增量添加文档向量"""
index.add(new_embeddings.astype('float32'))
3. 重排序的长度截断
Cross-Encoder的max_length通常为512token,长文档会被截断导致信息丢失。解决方案是对长文档做滑动窗口切片后分别重排序,取最高分:
def rerank_long_document(query, document, reranker, chunk_size=400):
"""对长文档进行分段重排序"""
chunks = [document[i:i+chunk_size] for i in range(0, len(document), chunk_size)]
scores = reranker.model.predict([(query, chunk) for chunk in chunks])
return max(scores)
结语
混合检索不是简单的技术堆叠,而是需要在召回率、精度和延迟之间找到平衡。BM25+向量+重排序的三阶段架构已在大量生产环境中验证了其有效性。关键在于:理解每层架构解决的问题边界,针对自身数据特点调优参数,并建立持续评估的反馈闭环。检索是RAG的地基,地基不牢,再强的生成模型也建不出高楼。