为什么单一检索不够?

在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的地基,地基不牢,再强的生成模型也建不出高楼。