欢迎光临

Hybrid Search 混合搜索实战:融合 BM25 全文检索与 Dense 向量搜索的最佳架构方案

为什么需要混合搜索?

向量搜索(Vector Search)和全文检索(Full-Text Search)各有其不可替代的优势,但也存在天然的局限性。当我们在生产环境中构建搜索系统时,单纯的向量搜索往往会漏掉那些关键词精准匹配的结果,而纯关键词搜索又无法理解用户的语义意图。Hybrid Search(混合搜索)正是为了解决这一困境而生的架构范式——它将 BM25 等传统全文检索方法与基于嵌入向量的语义搜索相结合,通过精心设计的融合策略,在召回率和精准度之间达到最优平衡。

混合搜索架构示意图

理解两种搜索范式的本质差异

搜索引擎数据结构对比

BM25 全文检索:精准匹配的基石

BM25 是 Okapi BM25 算法的简称,它是 TF-IDF 的进化版本。与 TF-IDF 的线性关系不同,BM25 通过引入饱和度参数 k1 和文档长度归一化参数 b,对词频进行了非线性调节。其核心公式如下:


1
score(D, Q) = Σ IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D| / avgdl))

其中 k1 控制词频的饱和速度(典型值 1.2 – 2.0),b 控制文档长度归一化的程度(典型值 0.75)。BM25 在”精确短语匹配”和”关键词命中”方面表现出色——当用户搜索”Python 内存泄漏诊断”时,BM25 能保证包含这些确切关键词的结果排在前面,无论语义相似但表达不同的内容有多吸引人。

Dense 向量搜索:语义理解的利器

向量搜索的核心思路是将文本转化为高维空间中的嵌入向量(Embedding),然后通过向量距离度量(余弦相似度、点积、欧氏距离)来寻找语义上最接近的结果。以 HuggingFace 的 sentence-transformers 模型为例:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
documents = [
    "Python 垃圾回收机制使用引用计数和分代回收",
    "如何排查 Python 程序的内存使用异常",
    "Java 虚拟机内存模型详解"
]
query = "Python 内存泄漏怎么查"

doc_embeddings = model.encode(documents, normalize_embeddings=True)
query_embedding = model.encode(query, normalize_embeddings=True)

# 余弦相似度
scores = np.dot(doc_embeddings, query_embedding)
# 输出: [0.72, 0.89, 0.21]
# "如何排查 Python 程序的内存使用异常" 获得了最高分

向量搜索能理解”内存泄漏怎么查”与”排查内存使用异常”之间的语义等价关系——这是 BM25 难以做到的。但它的缺陷同样明显:当用户输入一个极为罕见的专业术语(如”GIL 全局解释器锁”)时,向量模型如果训练数据中该词出现频率不高,很可能把结果跑偏到语义相近但完全不同的概念上。

混合搜索的核心架构:两路召回 + 融合排序

架构示意图

生产级混合搜索的标准架构分为三个层次:

层级 组件 职责
召回层 BM25 检索器 + 向量检索器 各自独立召回 Top-K 候选结果
融合层 分数归一化 + 聚合算法 将两路结果合并为统一的排序列表
精排层(可选) Cross-Encoder 重排器 对融合后的 Top-N 结果进行精细重排序

1. 两路并行召回

当用户发起一次搜索请求时,系统同时执行两条路径:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 路径 A:BM25 全文检索(使用 Elasticsearch)
GET /articles/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "content": "Python 内存泄漏诊断方法" }},
        { "match": { "title": "Python 内存泄漏" }}
      ]
    }
  },
  "size": 50,
  "_source": ["id", "title"]
}

# 路径 B:向量语义搜索(使用 Elasticsearch dense_vector 或 Milvus)
{
  "field": "content_embedding",
  "query_vector": [...],
  "k": 50,
  "num_candidates": 200  # HNSW ef_search 参数
}

两路召回的 Top-K 通常设置为相同的数值(如 50)。这个值不宜过小(会丢失跨路结果),也不宜过大(增加融合层压力)。实践证明 50-100 是一个平衡区间。

2. 分数归一化(Score Normalization)

BM25 的得分范围和向量距离得分范围完全不在同一个量级——BM25 可能输出 0-30 的原始得分数值,而余弦相似度限制在 [-1, 1] 之间。直接相加或取平均都会导致某一方的偏差完全主导结果。业界普遍采用以下归一化方法:

Min-Max 归一化:将每一路的分数缩放到 [0, 1] 区间。


1
normalized_score(s) = (s - min_score) / (max_score - min_score + epsilon)

Softmax 温度归一化:对分数先做一次 softmax,引入温度参数 τ 来控制分布的锐利程度。


1
softmax_weight(s_i) = exp(s_i / τ) / Σ_j exp(s_j / τ)

温度参数 τ 在 0.01-0.1 之间会让高分的优势更突出,τ > 1 则让分数分布更加平滑。在实际项目中,我们通常对 BM25 和向量分数分别估计分布的均值和标准差,然后做 Z-Score 标准化到 N(0,1),再映射到 [0,1]。

3. 融合算法:RRF、加权和与学习排序

RRF(Reciprocal Rank Fusion):最简单且效果惊人的融合方法,完全不依赖分数,只依赖排名位置。


1
RRF_score(d) = Σ 1 / (k + rank_i(d))

其中 k 是平滑常数(通常设为 60),rank_i(d) 是文档 d 在第 i 路检索中的排名。RRF 的魅力在于:即使两路搜索的分数分布完全不同,仅靠排名就能产生稳定、可重复的融合效果。Elasticsearch 8.8+ 已经内置了 RRF 检索器的支持:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET /articles/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": { "match": { "content": "Python 内存泄漏" }}
          }
        },
        {
          "knn": {
            "field": "content_embedding",
            "query_vector": [...],
            "k": 50
          }
        }
      ],
      "rank_constant": 60
    }
  }
}

加权和(Weighted Sum)


1
final_score(d) = α × bm25_normalized(d) + (1 - α) × vector_normalized(d)

α 的取值直接决定了搜索的倾向性。在我们的线上实验中:

  • α = 0.7:偏向关键词精确匹配,适合代码搜索、文档搜索
  • α = 0.5:平衡模式,通用搜索推荐
  • α = 0.3:偏向语义匹配,适合内容推荐、知识问答

学习排序(LTR, Learning to Rank):当你有足够多的用户点击数据时,可以用 LambdaRank 或 LightGBM 来训练一个排序模型。特征包括 BM25 分数、向量相似度、文档长度、发布时间权重、点击率等多种信号。这是融合算法的天花板,但需要持续维护训练数据管道。

Unstructured Data 场景下的混合搜索最佳实践

稀疏向量(Sparse Vectors)的引入

在混合搜索中引入稀疏向量是对传统 Dense Vector 的有力补充。像 SPLADE(Sparse Lexical and Dimensional Entity Representation)这样的模型,能同时保留词汇匹配和语义扩展的能力。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 使用 SPLADE 生成稀疏向量表示
from transformers import AutoModelForMaskedLM, AutoTokenizer
import torch

model = AutoModelForMaskedLM.from_pretrained("naver/splade-cocondenser-selfdistil")
tokenizer = AutoTokenizer.from_pretrained("naver/splade-cocondenser-selfdistil")

def encode_sparse(text):
    tokens = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
    output = model(**tokens)
    # 使用 ReLU + log(1 + ReLU) 激活产生稀疏权重
    sparse_vector = torch.relu(output.logits).max(dim=1).values
    sparse_vector = torch.log(1 + sparse_vector)
    return sparse_vector[0]

SPLADE 生成的是一个高度稀疏的向量(通常 95% 以上的元素为 0),其中每个非零维度对应词典中的一个词。这种表示本质上结合了精确匹配和语义扩展:

  • 精确匹配:如果查询词”内存”直接出现在文档中,对应的 SPLADE 维度会有高权重
  • 语义扩展:”内存泄漏”的查询可能激活”内存溢出”、”OOM”、”GC”等相关词的维度

这种三路融合(BM25 + Dense + Sparse)已经逐渐成为业界领先的混合搜索方案。Elasticsearch 在 8.12+ 版本中已经原生支持稀疏向量存储和检索。

Cross-Encoder 重排序:最后一道质量关卡

经过 RRF 或加权融合后,通常保留 20-50 篇候选文档,然后由 Cross-Encoder 模型进行逐对评分:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from sentence_transformers import CrossEncoder

reranker = CrossEncoder('BAAI/bge-reranker-v2-m3')

# 融合后的候选结果
candidates = [
    "Python 中使用 tracemalloc 定位内存泄漏",
    "排查 Python 程序内存使用异常的全面指南",
    "Java GC 与 Python gc 模块对比分析",
    ...
]

# 生成 (query, document) 对
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs)

# 按重排分数排序
ranked_results = [doc for _, doc in sorted(zip(scores, candidates), reverse=True)]

Cross-Encoder 的计算开销远高于 Bi-Encoder(单次推理需要同时处理 query 和 document),因此它不适合直接替代第一阶段的检索。但作为精排层,它能在最后一步中过滤掉那些虽然语义相似但实际不相关的噪声结果——这是 RRF 和加权和都做不到的。

生产部署中的关键考量

延迟预算分配

混合搜索的端到端延迟由以下部分构成:

阶段 典型延迟 优化策略
文本嵌入推理 20-100ms GPU 推理 + 批处理 + 缓存已知查询
BM25 检索 5-20ms ES 集群、内存索引
向量检索 10-50ms HNSW 索引 + IVF + 量化
分数融合 <1ms 纯计算,轻量级
Cross-Encoder 重排 50-200ms 只对 Top-20 候选执行,用 ONNX/TensorRT 加速

在延迟敏感的场景下(如搜索建议、即时问答),建议省略 Cross-Encoder 阶段,仅使用 RRF 融合。对于知识库问答、研究性搜索等对时间不那么敏感但准确性要求高的场景,引入重排序层能显著提升体验。

索引策略:双索引 vs 单索引混合

Elasticsearch 8.8+ 支持在同一索引中同时存储全文字段和向量字段,使用独立的 retrieval 策略。而更经典的方案是为两种检索维护独立的索引系统(如 ES + Milvus),在应用层完成融合。

我们的实践经验表明:

  • 数据量在千万级以下时,使用 ES 单一集群即可(同时管理全文和向量),运维成本最低
  • 数据量超过 1 亿时,独立索引架构的优势明显——向量搜索的索引构建和内存消耗不会影响全文检索的实时写入能力

A/B 评估体系

混合搜索的评估不能离开业务指标。推荐建立以下评估框架:


1
2
3
4
5
6
7
8
9
10
11
# 离线评估指标
precision@k = 正确结果在 Top-K 中的比例
recall@k = 正确结果在 Top-K 中的召回率
mAP (Mean Average Precision) = 平均精度均值
nDCG@k = 归一化折损累计增益

# 上线后跟踪的指标
CTR = 搜索结果点击率
查看停留时间
零结果率(查询返回空结果的比例)
翻页率(用户需要翻到第几页才找到满意结果)

在实际项目中,我们发现将 BM25 权重 α 作为可配置参数暴露给业务方,配合 A/B 测试平台进行灰度实验,能最快地找到适合特定业务场景的最优融合策略。不同的业务场景下最优的 α 值差异很大——电商搜索需要精确的商品名称匹配(α 偏向 BM25),而知识问答系统则更需要语义理解(α 偏向向量)。

总结与展望

混合搜索不是”把两个搜索引擎拼在一起”那么简单,它涉及召回策略、归一化方法、融合算法、重排序以及延迟预算的系统性设计。在 2025 年的今天,随着 SPLADE、ColBERTv2、以及 Elasticsearch 原生混合检索支持的成熟,构建一个生产级的混合搜索系统已经不再是少数大厂的专利。

对于中小团队来说,一条清晰的路径是:先使用 ES 内置的 RRF 融合快速上线(α 设为 0.5),然后根据用户行为数据逐步调整融合权重,最后在有富余计算资源时引入 Cross-Encoder 重排序提升最终质量。逐步迭代、以数据为导向,远比一开始就追求”完美架构”要务实得多。

未来的混合搜索正在向端到端学习的方向演进——由统一的神经网络模型同时处理精确匹配和语义匹配,不再需要显式的两路召回和融合逻辑。Google 的 M2M(Multi-vector to Multi-vector)检索和 ColPali(视觉文档检索)都在这个方向上迈出了重要一步。但这个领域的成熟还需要时间,在此之前,BM25 + 向量搜索 + RRF 的经典混合架构组合,依然是生产环境中最成熟、最可靠的方案。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Hybrid Search 混合搜索实战:融合 BM25 全文检索与 Dense 向量搜索的最佳架构方案
分享到: 更多 (0)