为什么需要混合检索?

上周我接到一个线上问题:用户反馈知识库问答的准确率突然下降了15%。排查后发现,单纯依赖向量检索在某些场景下确实存在明显短板。

举个真实案例:用户问"如何申请测试机",向量检索返回了"设备管理制度"相关文档,但压根没提到申请流程。原因是"测试机"和"设备"的语义相似度不够高,导致召回失败。

这时我意识到,单一路召回注定有盲区。向量检索擅长语义匹配,但在精确关键词、专有名词、ID匹配上表现很差。要想做到企业级准确率,必须上混合检索。

我的判断:2025年做企业知识库还只用向量检索,就是在偷懒。别跟我说"简单够用",用户会因为15%的准确率下降直接弃用你的产品。

混合检索的核心思路

简单说就是:多路召回 + Reranker重排

打个比方:多路召回就像HR初筛简历,向量检索、关键词检索、知识图谱检索三路并行,各自按自己的标准筛选候选人(文档),各自返回Top-K结果。这一步的目标是宁可错杀一千,不可放过一个——召回率优先,准确率靠后。

而Reranker就是最终面试官,HR初筛通过的候选人(候选文档)都来到终面,Reranker逐一面试(计算Query-Document相关性分数),最后决定谁被录用(返回Top-N给LLM)。

第一层:多路召回(HR初筛) —— 向量检索、关键词检索、知识图谱检索同时跑,各自返回Top-K结果

第二层:结果融合 —— 用Reranker模型(最终面试官)对所有候选文档重新打分排序

第三层:最终返回 —— 取重排后的Top-N送给LLM生成答案

这样做的好处是:各路召回互补短板,Reranker保证最终质量

技术架构设计

在我们的企业知识库项目中,混合检索架构如下:

用户Query(求职者)

HR初筛1 → 向量检索(Embedding + FAISS)"语义理解派"


HR初筛2 → 关键词检索(BM25 + Elasticsearch)"精确匹配派"


HR初筛3 → 知识图谱检索(Neo4j + Cypher)"关系挖掘派"

合并去重(所有HR初筛通过的候选人集合)

最终面试官(Reranker重排,Cross-Encoder)

录用Top-N(送入LLM)

第一路:向量检索实战(语义理解派HR)

向量检索就像HR初筛时的"感觉派"——不看精确匹配,看整体语义是否合适。我们用的是 sentence-transformers + FAISS。

先看完整代码

from sentence_transformers import SentenceModel
import faiss
import numpy as np

# 加载Embedding模型(把文字转成向量)
model = SentenceModel(
    'BAAI/bge-large-zh-v1.5'
)

# 向量化查询(用户问题变成512维向量)
query_embedding = model.encode(
    [query_text]
)[0]

# FAISS检索Top-50(在向量空间找最近的50个文档)
index = faiss.read_index(
    'knowledge_base.index'
)
scores, indices = index.search(
    query_embedding.reshape(1, -1),
    k=50
)

代码解读

SentenceModel 负责把文字转成数字向量(就像把简历转成能力画像)

faiss.read_index 加载预先构建好的向量库

index.search 在向量空间找最相似的50个文档

这里有个坑:召回数量要设大一点(50-100),因为后面Reranker会重新排序,前期宁可多召回一些,也别漏掉相关文档。

第二路:关键词检索(精确匹配派HR)

BM25就像HR初筛时的"硬核派"——只看关键词是否精确匹配,不care语义。对于专有名词、产品型号、精确匹配的查询效果拔群。我们用Elasticsearch实现:

完整代码

from elasticsearch import Elasticsearch

# 连接Elasticsearch(我们的文档搜索引擎)
es = Elasticsearch(
    ['localhost:9200']
)

# BM25检索(关键词匹配)
query_body = {
    "query": {
        "multi_match": {
            "query": query_text,  # 用户问题
            "fields": [
                "title^2",  # 标题匹配权重x2
                "content"    # 正文匹配
            ]
        }
    },
    "size": 50  # 返回Top-50
}

# 执行搜索
results = es.search(
    index='knowledge_base',  # 索引名
    body=query_body
)

代码解读

multi_match 同时在title和content两个字段搜索

title^2 表示标题匹配权重是正文的两倍(标题更重要)

size: 50 返回Top-50结果

实战技巧:Title字段加权(^2),因为标题匹配通常比正文匹配更重要。

第三路:知识图谱检索(关系挖掘派HR)

知识图谱就像HR初筛时的"背景调查派"——不仅看简历本身,还看候选人之间的关系(比如"这个人和那个项目什么关系?“)。对于实体关系类问题(比如"张三负责哪些项目”),知识图谱有天然优势。

我们的图谱结构:

实体类型 关系类型
员工、部门、项目 隶属、负责、参与
文档、知识条目 包含、引用、相关

检索示例(Cypher查询语言)

// 查询"测试机申请"相关实体
MATCH (d:Document)-[r:CONTAINS]->
     (k:Knowledge)
WHERE d.title CONTAINS '测试机' 
      OR k.content CONTAINS 
      '申请流程'
RETURN d, r, k
LIMIT 50

代码解读

MATCH 匹配图模式(文档-包含->知识条目)

WHERE 过滤条件(标题或内容包含关键词)

RETURN 返回匹配的文档、关系、知识条目

结果融合:去重与归一化(三个HR的打分尺度不一样)

三路召回的结果需要合并,但各路的分数尺度不同(向量相似度是0-1,BM25是任意正数,图谱是匹配数量),必须归一化。

类比:三个HR给候选人打分,HR1打60-90分,HR2打70-80分,HR3打1-5分。直接平均没意义,得先归一化到同一把尺子(0-1)上。

归一化 + 合并代码

# 第一步:归一化分数到[0,1]
def normalize_scores(results):
    """Min-Max归一化"""
    scores = [r['score'] for r in results]
    min_s, max_s = min(scores), max(scores)
    
    # 如果所有分数一样,统一设为1.0
    if max_s == min_s:
        return [{'doc': r['doc'], 
                 'score': 1.0} 
                for r in results]
    
    # Min-Max归一化
    for r in results:
        r['score'] = (
            (r['score'] - min_s) / 
            (max_s - min_s)
        )
    
    return results

# 第二步:合并三路结果(去重 + 平均)
def merge_results(vec_results, 
                   bm25_results, 
                   kg_results):
    # 先归一化各路分数
    vec_norm = normalize_scores(vec_results)
    bm25_norm = normalize_scores(bm25_results)
    kg_norm = normalize_scores(kg_results)
    
    # 按doc_id合并(去重)
    merged = {}
    for r in vec_norm + bm25_norm + kg_norm:
        doc_id = r['doc']['id']
        if doc_id not in merged:
            merged[doc_id] = {
                'doc': r['doc'],
                'scores': []
            }
        merged[doc_id]['scores'].append(
            r['score']
        )
    
    # 取平均分作为初步分数
    for doc_id in merged:
        scores = merged[doc_id]['scores']
        merged[doc_id]['avg_score'] = (
            sum(scores) / len(scores)
        )
    
    # 按平均分排序(降序)
    return sorted(
        merged.values(),
        key=lambda x: x['avg_score'],
        reverse=True
    )

代码解读

normalize_scores 把各路分数映射到0-1(三个HR的尺子统一)

merge_results 合并三路结果,按doc_id去重,取平均分

• 返回按平均分排序的候选文档列表(等待最终面试官Reranker拍板)

Reranker重排模型实战(最终面试官)

这是整个混合检索的核心。Reranker用Cross-Encoder架构,直接计算Query-Document的相关性分数,比Bi-Encoder(向量检索)准确得多。

我的观点:Reranker不是可选项,是必选项。80ms的延迟换15%的准确率提升,任何ToB场景都应该上。别拿"性能优化"当借口,用户要的是准确结果,不是快但错的答案。

我们用的是 BAAI/bge-reranker-large,用法非常简单:

from sentence_transformers import CrossEncoder

# 加载Reranker模型(最终面试官)
reranker = CrossEncoder(
    'BAAI/bge-reranker-large'
)

# 准备候选文档(多路召回合并后取Top-100)
candidates = merge_results(
    vec_results,
    bm25_results,
    kg_results
)[:100]

# 构造Query-Document对(给最终面试官的考题)
pairs = [[query_text, doc['content']] 
         for doc in candidates]

# Reranker打分(最终面试官逐一面试)
scores = reranker.predict(pairs)

# 按新分数排序(录用排名)
reranked = sorted(
    zip(candidates, scores),
    key=lambda x: x[1],
    reverse=True
)

# 最终返回Top-5(录用5人)
final_results = [doc for doc, score 
                in reranked[:5]]

代码解读

CrossEncoder 加载Reranker模型(最终面试官上岗)

pairs 构造Query-Document对(考题)

reranker.predict 计算相关性分数(面试官打分)

• 返回Top-5(录用前5名送给LLM生成答案)

性能优化技巧

问题:Reranker模型通常比较大(1B+参数),预测速度慢。如果每次查询都要对100个候选文档做Cross-Encoder,延迟会飙到500ms+。

解决方案:异步批量推理 + 模型量化

import torch
from torch.quantization import quantize_dynamic

# 1. 模型量化(INT8,把模型压小压快)
reranker.model = quantize_dynamic(
    reranker.model,
    {torch.nn.Linear},
    dtype=torch.qint8
)

# 2. 批量推理(一次面试32个候选人,提高效率)
def batch_rerank(pairs, batch_size=32):
    all_scores = []
    for i in range(0, len(pairs), batch_size):
        batch = pairs[i:i+batch_size]
        scores = reranker.predict(batch)
        all_scores.extend(scores)
    return all_scores

# 3. 异步执行(面试官加班,不阻塞招聘流程)
import asyncio
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=4)

async def async_rerank(pairs):
    loop = asyncio.get_event_loop()
    scores = await loop.run_in_executor(
        executor,
        batch_rerank,
        pairs
    )
    return scores

代码解读

quantize_dynamic 把模型从FP32量化到INT8(模型变小,推理变快)

batch_rerank 批量推理(一次处理32个,不是逐个处理)

async_rerank 异步执行(Reranker在后台工作,不阻塞主流程)

经过优化,Reranker延迟从500ms降到80ms,满足生产要求。

实战效果对比

我们在1000条真实用户查询上做了A/B测试:

方案 Recall@5 MRR 延迟
仅向量检索 68.2% 0.72 45ms
仅BM25 61.5% 0.68 30ms
混合检索(无Reranker) 75.3% 0.76 80ms
混合检索+Reranker 83.7% 0.84 160ms

结论:混合检索+Reranker把Recall@5从68.2%提升到83.7%,MRR从0.72提升到0.84。虽然延迟增加了115ms,但在企业知识库场景下,这个trade-off是完全值得的。

我的态度:别跟我说"延迟增加了115ms,用户会不耐烦"。用户要的是正确答案,不是快但错的答案。如果你做的产品是"快>准",那可能不适合上Reranker;但企业知识库这种场景,准确率优先。

踩坑记录

坑1:Reranker输入长度截断

bge-reranker-large的最大输入长度是512个token。如果文档内容超长,直接截断会导致关键信息丢失。

解决方案:滑动窗口切分长文档,每个窗口512token,Reranker对每个窗口打分,取最高分作为该文档的分数。

坑2:多路召回的重复文档

三路召回可能返回同一文档的多个片段(比如向量检索返回段落3,BM25返回段落5),合并时容易重复。

解决方案:按doc_id + chunk_id去重,而不是仅按doc_id

坑3:Reranker模型占用显存

如果把Reranker部署在GPU上,1B+参数模型要占4GB+显存。如果并发请求多,容易OOM。

解决方案:用ONNX Runtime量化模型,转成INT8,显存占用降到1GB以下。或者直接用CPU推理(我们的做法),虽然慢点但成本低。

经验总结
混合检索不是银弹,要根据业务场景权衡。如果你们的知识库主要是技术文档(术语多、精确匹配需求强),BM25权重可以调高;如果是通用问答,向量检索权重可以调高。最重要的是:用真实用户查询做评估,别拍脑袋调参数。

下一步计划

下一篇我们会讲知识库系统的缓存策略:如何用Redis + 语义缓存(Semantic Cache)把频繁查询的响应时间从160ms降到5ms。这个项目越来越有意思了。

预告:缓存策略是另一个"必选项"。160ms对于ToB场景还能接受,但如果是ToC场景(比如客服机器人),必须降到50ms以内。语义缓存就是为此而生。

系列进度:8/20 | 下一篇:缓存策略——Redis + 语义缓存实战

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐