混合检索实战指南:关键词与向量的完美融合
① 混合检索核心概念与应用场景解析
先说说混合检索到底解决什么问题。
纯向量检索(也叫稠密检索)擅长理解语义------你搜"怎么退钱",它能找出讲"退款流程"的文档。但它有个硬伤:对精确关键词不敏感。搜"ModelArts"可能给你返回一堆"AI平台"相关的内容,就是找不到那个带"ModelArts"字眼的文档。
纯关键词检索(BM25这类)刚好反过来。它能把"ModelArts"精确匹配得死死的,但搜"怎么退钱"它只会找带"退"和"钱"的文档,完全不懂"退款"跟这俩词是一回事。
混合检索的思路很简单:两条路一起走,再把结果合并。关键词检索负责精确匹配,向量检索负责语义理解,互相兜底。
什么时候该用混合检索?
- 用户会搜专有名词(产品型号、代码函数名、人名),也会用口语描述
- 你的知识库里既有精确术语,又有同义表达
- 单一检索方式老是漏掉本该命中的文档
说白了,只要你的查询不是"永远精确"或"永远模糊",混合检索就值得上。
② 检索环境搭建与依赖库快速安装
先装好 Python 环境,推荐 3.10 及以上版本。
核心依赖包:
bash
# 向量检索用 FAISS
pip install faiss-cpu
# BM25 关键词检索
pip install rank-bm25
# LangChain 框架(方便组装)
pip install langchain langchain-community
# 文本分块和 embedding(后面会用到)
pip install sentence-transformers
如果打算用 OpenAI 的 embedding 模型,再加一句 pip install openai。想本地跑 embedding 的话,用 sentence-transformers 就行,不依赖外部 API。
项目目录结构建议这样搭:
hybrid_search_demo/
├── main.py
├── data/
│ └── knowledge_base.txt
├── faiss_index/
└── bm25_index.pkl
先把 knowledge_base.txt 准备好,每行放一条文档,或者按段落切好。后续所有的检索都基于这个文件。
③ 关键词检索模块配置与索引构建
关键词检索我们用 BM25,它根据词频和逆文档频率算分------在整个文档集里越罕见的词权重越高。
实现代码:
python
from rank_bm25 import BM25Okapi
import jieba # 中文分词用,英文可以不用
# 读取文档
with open('data/knowledge_base.txt', 'r', encoding='utf-8') as f:
docs = [line.strip() for line in f if line.strip()]
# 中文需要分词
tokenized_docs = [list(jieba.cut(doc)) for doc in docs]
# 构建 BM25 索引
bm25 = BM25Okapi(tokenized_docs)
# 查询时同样要分词
query = "ModelArts平台是做什么的"
tokenized_query = list(jieba.cut(query))
# 获取 BM25 分数
scores = bm25.get_scores(tokenized_query)
# 取 top-k
top_k_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:10]
如果你的文档是英文,直接 .split() 分词就行,不用 jieba。
BM25 索引建好后可以序列化存下来,避免每次重启都要重建:
python
import pickle
with open('bm25_index.pkl', 'wb') as f:
pickle.dump((tokenized_docs, bm25), f)
④ 向量嵌入模型选择与向量库初始化
向量检索的核心是把文本转成向量(一串浮点数),然后用余弦相似度找最接近的。
Embedding 模型怎么选?
- 本地跑 :用
sentence-transformers/all-MiniLM-L6-v2(384维,轻量)或BAAI/bge-small-zh(中文友好) - 调用 API :OpenAI 的
text-embedding-ada-002,或者国内厂商的 embedding 服务
新手建议从本地模型开始,不花钱、好调试。
向量库初始化(用 FAISS):
python
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
# 加载 embedding 模型
model = SentenceTransformer('all-MiniLM-L6-v2')
# 把所有文档转成向量
doc_embeddings = model.encode(docs)
# 构建 FAISS 索引
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatIP(dimension) # 内积,等价于余弦相似度(向量已归一化)
# 归一化向量,让内积等价于余弦
faiss.normalize_L2(doc_embeddings)
index.add(doc_embeddings)
# 查询向量化
query_embedding = model.encode([query])
faiss.normalize_L2(query_embedding)
# 检索 top-k
distances, indices = index.search(query_embedding, k=10)
FAISS 索引也可以持久化:
python
faiss.write_index(index, 'faiss_index/index.faiss')
⑤ 设计加权融合算法实现双路召回
两路检索都拿到结果了,怎么合并?
方法一:加权分数融合
把 BM25 分数和向量相似度归一化到同一尺度,然后加权求和。
python
def normalize_scores(scores):
"""把分数归一化到 [0, 1]"""
min_s = min(scores)
max_s = max(scores)
if max_s == min_s:
return [0.5] * len(scores)
return [(s - min_s) / (max_s - min_s) for s in scores]
# 获取两路结果
bm25_scores = bm25.get_scores(tokenized_query)
vector_distances, vector_indices = index.search(query_embedding, k=len(docs))
# 向量检索默认返回的是距离,需要转成相似度分数(距离越小越相似)
# 这里简化处理:对所有文档构造相似度分数
vector_scores = np.zeros(len(docs))
for i, idx in enumerate(vector_indices[0]):
vector_scores[idx] = 1 / (1 + vector_distances[0][i]) # 距离转分数
# 归一化
bm25_norm = normalize_scores(bm25_scores)
vector_norm = normalize_scores(vector_scores)
# 加权融合,alpha 控制权重
alpha = 0.5 # BM25 和向量各占一半
final_scores = [alpha * b + (1 - alpha) * v for b, v in zip(bm25_norm, vector_norm)]
# 排序取 top-k
results = sorted(enumerate(final_scores), key=lambda x: x[1], reverse=True)[:10]
alpha 的取值直接影响效果:
- 短查询(3个词以内):alpha 设 0.6-0.8,关键词权重高一点
- 长查询(5个词以上):alpha 设 0.3-0.5,语义权重高一点
- 专业领域(术语多):alpha 可以更低,0.2-0.4
方法二:RRF(倒数排名融合)
RRF 不看原始分数,只看排名。它的好处是不用归一化------BM25 的分数范围和向量距离的尺度完全不一样,硬凑在一起很别扭。
python
def rrf_fusion(bm25_ranking, vector_ranking, k=60):
"""
bm25_ranking: BM25 返回的文档 ID 列表(按分数从高到低)
vector_ranking: 向量返回的文档 ID 列表
k: 平滑常数,默认 60
"""
scores = {}
for rank, doc_id in enumerate(bm25_ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
for rank, doc_id in enumerate(vector_ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
RRF 在实践中更常用,因为它不依赖分数归一化,两路结果的排名直接融合。k 值默认 60,调小(比如 10)会让排名靠前的文档权重更大。
⑥ 编写完整代码执行混合查询流程
把前面所有模块串起来,跑一个完整的查询。
python
import pickle
import jieba
import numpy as np
import faiss
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
class HybridSearch:
def __init__(self, docs_path, embed_model_name='all-MiniLM-L6-v2'):
# 加载文档
with open(docs_path, 'r', encoding='utf-8') as f:
self.docs = [line.strip() for line in f if line.strip()]
# 构建 BM25
self.tokenized_docs = [list(jieba.cut(doc)) for doc in self.docs]
self.bm25 = BM25Okapi(self.tokenized_docs)
# 构建向量索引
self.embed_model = SentenceTransformer(embed_model_name)
embeddings = self.embed_model.encode(self.docs)
self.dim = embeddings.shape[1]
self.index = faiss.IndexFlatIP(self.dim)
faiss.normalize_L2(embeddings)
self.index.add(embeddings)
def search(self, query, alpha=0.5, top_k=10, fusion='weighted'):
tokenized_query = list(jieba.cut(query))
# 1. BM25 检索
bm25_scores = self.bm25.get_scores(tokenized_query)
bm25_ranking = sorted(range(len(bm25_scores)),
key=lambda i: bm25_scores[i], reverse=True)
# 2. 向量检索
q_embed = self.embed_model.encode([query])
faiss.normalize_L2(q_embed)
distances, indices = self.index.search(q_embed, top_k * 2)
# 构建向量分数(对所有文档)
vector_scores = np.zeros(len(self.docs))
for i, idx in enumerate(indices[0]):
vector_scores[idx] = 1 / (1 + distances[0][i])
vector_ranking = sorted(range(len(vector_scores)),
key=lambda i: vector_scores[i], reverse=True)
if fusion == 'weighted':
# 归一化
bm25_norm = self._normalize(bm25_scores)
vector_norm = self._normalize(vector_scores)
final = [alpha * b + (1 - alpha) * v for b, v in zip(bm25_norm, vector_norm)]
results = sorted(enumerate(final), key=lambda x: x[1], reverse=True)[:top_k]
return [(self.docs[i], final[i]) for i, _ in results]
else: # RRF
rrf_scores = {}
for rank, doc_id in enumerate(bm25_ranking[:top_k*2], 1):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (60 + rank)
for rank, doc_id in enumerate(vector_ranking[:top_k*2], 1):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (60 + rank)
results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
return [(self.docs[i], score) for i, score in results]
def _normalize(self, scores):
min_s, max_s = min(scores), max(scores)
if max_s == min_s:
return [0.5] * len(scores)
return [(s - min_s) / (max_s - min_s) for s in scores]
# 使用
searcher = HybridSearch('data/knowledge_base.txt')
results = searcher.search('ModelArts平台是做什么的', alpha=0.6, fusion='rrf')
for doc, score in results:
print(f"分数: {score:.4f}\n文档: {doc}\n")
这段代码把两路检索、归一化、融合都包进去了,复制就能跑。融合方式可选 weighted 或 rrf,建议先试试 RRF。
⑦ 结果重排序策略与相关性优化技巧
混合检索返回的结果未必完美,有时候需要再加工一下。
重排序(Rerank)的基本思路:用一个小而精的模型,对混合检索召回的一批候选文档(比如 Top-50)逐一计算和查询的相关性,重新排序。
python
from sentence_transformers import CrossEncoder
# 加载交叉编码器(专门做相关性判断)
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
# 对候选结果重排
def rerank(query, candidates, top_k=5):
pairs = [[query, doc] for doc, _ in candidates]
scores = reranker.predict(pairs)
reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [(doc, score) for (doc, _), score in reranked[:top_k]]
# 先用混合检索召回 Top-20,再重排取 Top-5
initial_results = searcher.search(query, top_k=20, fusion='rrf')
final_results = rerank(query, initial_results, top_k=5)
重排序的计算量比向量检索大,所以通常只对少量候选文档做。
没有重排模型怎么办? 有几个简单有效的优化手段:
- 调整权重:根据实际反馈调 alpha。如果发现关键词命中的文档更相关,就把 alpha 调高。
- 加黑名单扣分:某些高频词会污染结果,可以对包含这些词的文档做软扣分(不是硬过滤),让它们排名靠后。
- 去重:两条检索路可能返回同一篇文档,用 MMR(最大边际相关性)做去重,保证返回结果的多样性。
⑧ 典型报错分析与检索失败排查思路
报错1:ModuleNotFoundError: No module named 'faiss'
FAISS 没装上。CPU 版用 pip install faiss-cpu,有 GPU 用 pip install faiss-gpu。Windows 用户如果装不上,可以去 conda 环境装:conda install -c pytorch faiss-cpu。
报错2:向量维度不匹配
RuntimeError: dimension mismatch
存索引时的向量维度和查询时的向量维度不一样。检查 embedding 模型是不是同一个,all-MiniLM-L6-v2 是 384 维,bge-large 是 1024 维。换个模型就得重建索引。
报错3:BM25 返回全零分数
查询词在文档里一个都没出现。中文没分词直接喂给 BM25 也会这样------BM25 是按词匹配的,中文得先分词。
报错4:检索结果为空
- 向量检索阈值设太高了,把距离阈值调宽松或者干脆不设阈值
- 文档数量太少(少于 k 值),减少 top_k
- embedding 模型没正常工作,检查模型是否下载完整
排查思路:先单独测关键词检索和向量检索,看哪一路出问题。两路单独都能跑通,再查融合逻辑。
⑨ 性能调优参数设置与响应速度提升
混合检索跑两路,慢是正常的。几个优化方向:
1. 索引预热
启动时把常用查询的向量结果预加载到内存,第一次查询就不冷了。
2. 向量检索用 ANN 替代暴力搜索
FAISS 的 IndexFlatIP 是暴力搜索,数据量大了会慢。换 IndexIVFFlat 或 IndexHNSW,用近似最近邻(ANN)换速度。
python
# HNSW 索引(比暴力搜索快很多)
index = faiss.IndexHNSWFlat(dimension, 32) # 32 是连接数
index.hnsw.efConstruction = 200
index.add(embeddings)
# 查询时设置 efSearch
index.hnsw.efSearch = 64
3. 限制召回数量
别让 BM25 和向量检索都搜全部文档。各取 Top-100 再融合,比全量计算快得多。
4. 缓存高频查询
用一个简单的字典缓存:cache[query] = results,相同的查询直接返回。
5. 批处理
多个查询一起提交,向量检索可以批量计算,比一个个来快。
实际项目中,合理优化后复杂查询的响应时间能从秒级降到几百毫秒。
⑩ 从 Demo 到生产环境的部署注意事项
Demo 跑通了,要上线还得想清楚几件事。
数据量预估
文档少于 1 万篇,单机 FAISS + BM25 够用。超过 10 万篇,需要考虑分布式向量数据库(Milvus、Qdrant 等)。
索引更新
知识库变了怎么办?FAISS 和 BM25 索引都要重建。如果更新频繁,选支持增量更新的向量库(如 Milvus)。
监控与告警
生产环境至少盯三个指标:
- 检索延迟(P99 不超过多少毫秒)
- 空结果率(突然升高说明索引或模型可能出问题了)
- 向量检索服务的健康状态
降级策略
向量检索服务挂了怎么办?至少保证关键词检索能工作,返回结果(哪怕不完美)比完全不返回强。
测试环境先行
别直接把代码怼到生产。先在测试环境验证混合检索效果,确认比单一检索好,再逐步推上去。
依赖版本锁定
requirements.txt 里锁定所有依赖的具体版本,避免生产环境装了不兼容的新版本导致不可预知的问题。
WEB项目地址:演示地址
安卓APP下载地址:演示地址
上面这套代码和配置,从零开始搭一个可用的混合检索系统是够的。建议你拿自己的数据跑一遍,调一调 alpha 和 fusion 方式,看哪种组合在你场景下效果最好。检索这件事,理论和实践之间往往隔着一层数据------只有跑起来才知道。