大家好,我是程序员天天困。
上一篇聊向量数据库原理,咱们把「文档切块 → Embedding 向量化 → HNSW/IVF 近邻检索」这条链路走通了。但向量库解决的是「存得下、搜得快」,真把 RAG 机器人搬到公司内部知识库上跑,「搜得准」的问题才刚露头,翻车来得比想象快。这篇就讲向量检索翻车怎么救——混合检索与召回优化,建议先收藏,写代码时对着抄。
一、纯向量检索的三道坎:编号、年份和内部黑话
先说结论:纯向量检索翻车,多半不是 Embedding 模型不行,而是关键词这类信息在向量化那一刻就被丢掉了。
拿一个最典型的场景来说:公司内部的 IT/HR 知识库问答机器人上线后,最先翻车的是这三类问题。
第一类,精确编号。用户问「OA 报销单提交报错 E-503 怎么处理」,向量检索回来的是《OA 系统常见问题》《报销审批操作指南》《财务流程制度说明》——篇篇都和 OA 报销相关,语义挑不出毛病,唯独那篇《E-503 报错处理手册》沉在下面,甚至没进 Top-5。
第二类,年份和数字。问「2026 年端午怎么调休」,回来的是 2024、2025、2026 三年的调休通知。「端午调休」这四个字的语义每年都一模一样,向量分不出新旧。
第三类,内部黑话。问「OC 校准会是什么流程」,OC 是公司内部对某类会议的缩写,Embedding 模型训练时压根没怎么见过这个词,只能猜个「开会」的语义,回来一堆会议管理制度。

你说它不会吧,相关文档它全给你捞回来了;你说它会吧,最关键的那篇就是找不到。
根子在于 Embedding 是一次有损压缩:一段话压成几百维向量,语义和意图被保留,字面 token 被「磨平」了。向量空间里「报错码 E-503 怎么处理」和「OA 登不上怎么办」离得很近——这正是它的优点;但「E-503」这串字符到底重不重要,向量表达不出来。
于是检索分成了两派,各管一摊:
| 维度 | 向量检索(稠密) | 关键词检索(稀疏) |
|---|---|---|
| 擅长 | 语义理解、同义改写、口语化提问 | 精确词项、编号型号、缩写黑话 |
| 底层手段 | Embedding 向量 + ANN 索引 | 分词 + 倒排索引 + BM25 打分 |
| 典型 query | 「OA 登不上咋办」 | 「报错码 E-503」 |
| 短板 | 编号、数字、低频黑话会丢 | 同义词、改写完全匹配不上 |
向量检索管「意思到了」,关键词检索管「字对上了」,生产级 RAG 两个都要。 这也是检索策略这些年的演进路线:

这篇文章的路线图就是这张图:先补关键词这一路(BM25),再把两路捏合(混合检索 + RRF),最后解决排序精度(重排序)。

二、BM25:关键词检索这一路怎么打分
BM25(Best Matching 25):信息检索领域经典的词项相关性打分函数,基于倒排索引统计词频给文档排序,Elasticsearch 等搜索引擎默认用它;你可以理解为「数关键词出现了几次,但越稀有的词越值钱」。
它不理解语义,只看三件事。
第一,词频,但有饱和上限。 一个词在文档里出现得越多,文档越相关——「E-503」出现 3 次的手册比出现 1 次的相关。但出现 100 次不等于相关 100 倍,可能只是文档啰嗦。BM25 对词频做了饱和处理:从 1 次到 3 次分数涨得快,再往上基本不涨了,防止长文档靠堆词刷分。
第二,稀有度,越稀有越值钱。 「OA」在知识库一半的文档里都出现,命中了也不稀罕;「E-503」只在一篇手册里出现,一旦命中,这篇极可能就是答案。BM25 用逆文档频率(IDF)量化这件事:一个词出现在越少的文档里,权重越高。
第三,文档长度,长文档要打折。 5000 字的《OA 操作大全》里出现「OA」的概率天然比 500 字的手册高,但这不代表它更相关。BM25 按文档长度做归一化,长文档扣分、短文档加分,大家站同一条起跑线。
拿「OA 报错 E-503」这条 query 走一遍:《E-503 处理手册》篇幅短,「E-503」词频适中且这个词全库只此一家,IDF 拉满,分数一骑绝尘;《OA 操作大全》虽然「OA」刷了十几次,但没出现「E-503」这个高权重词,长度还吃亏,分数直接垫底。这就是关键词检索的脾气——字对不上,它一分都不给。
好消息是不用自己造轮子。Milvus 从 2.5 版本开始内置全文检索:写库时声明一个 BM25 Function,原文进库后自动分词、自动生成稀疏向量,大多数场景不一定要再单独维护一套 Elasticsearch(本文代码基于 pymilvus 2.5+ 写法,升级前建议对照官方文档核对 API)。
三、混合检索:两路召回,RRF 按排名拍板
混合检索的关键不是「两路都查」,而是怎么把两路结果合成一份排名——直接加分数,是新手最容易踩的坑。
流程上很直白:一条 Query 进来,稠密路转向量查 ANN 索引,稀疏路直接传原文查倒排索引,各取 Top-N,最后融合成一份候选集:

1)为什么分数不能直接加
稠密路返回的是余弦相似度,文本检索里通常落在 0~1,比如 0.82;稀疏路返回的是 BM25 分,非负且没有固定上限,比如 11.6。这俩分数量纲都不一样,0.82 + 11.6 这种加法等于让 BM25 单方面碾压。
有人说那归一化到 0-1 再加权?实际也坑:假设稠密路 Top-10 的分数全挤在 0.80-0.85 之间,min-max 归一化会把这 0.05 的区间硬撑成 0~1,0.01 的噪声差就被放大成 0.2 的悬殊差距;反过来分数散的时候,真差异又被压平。分数分布每批 query 都在变,归一化参数根本调不住。
所以工程上最稳的做法是不看分数,只看排名。
RRF(Reciprocal Rank Fusion,倒数排名融合):多路检索结果的融合算法,不给各路分数做任何换算,只按每路排名投票,最终分为各路 1/(k + 名次) 之和,k 通常取 60;像选秀节目的评委投票——不看评委各自打了多少分,只看每位评委把选手排第几。
举个例子。稠密路排名 [D2, D1, D5, D6, D3],稀疏路排名 [D1, D7, D2, D5, D4],k=60:
| 文档 | 稠密路名次 | 稀疏路名次 | RRF 得分 |
|---|---|---|---|
| D1 | 2 | 1 | 1/62 + 1/61 ≈ 0.0325 |
| D2 | 1 | 3 | 1/61 + 1/63 ≈ 0.0323 |
| D5 | 3 | 4 | 1/63 + 1/64 ≈ 0.0315 |
| D7 | 未进榜 | 2 | 1/62 ≈ 0.0161 |
| D6 | 4 | 未进榜 | 1/64 ≈ 0.0156 |
最终排名 D1、D2、D5 稳居前三——这三篇在两路里都靠前;D7 只在稀疏路出现过,但也没被丢掉,照样进候选。k 是个平滑常数:k 越小,头部名次的优势越夸张;k 越大,各路排名越被「和稀泥」。60 是 RRF 原论文在大量数据集上试出来的默认值,几十名以内的名次差距影响温和,掉到几十名开外的候选基本没什么贡献——所以每路召回给到 top 几十就够用。
2)代码:pymilvus 建库
先建 Collection,注意三个字段的分工:text 存原文并开启中文分词,dense 存 Embedding 向量,sparse 交给 BM25 Function 自动填充,插入时不用传。
from pymilvus import MilvusClient, DataType, Function, FunctionType
client = MilvusClient(uri="http://localhost:19530")
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("text", DataType.VARCHAR, max_length=2048,
enable_analyzer=True,
analyzer_params={
"type": "chinese"}) # 中文建议指定,否则按单字切
schema.add_field("dense", DataType.FLOAT_VECTOR, dim=1024)
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR) # BM25 自动生成
schema.add_function(Function(
name="bm25_fn",
function_type=FunctionType.BM25,
input_field_names=["text"],
output_field_names=["sparse"]))
index_params = client.prepare_index_params()
index_params.add_index(field_name="dense",
index_type="AUTOINDEX", metric_type="COSINE")
index_params.add_index(field_name="sparse",
index_type="SPARSE_INVERTED_INDEX", metric_type="BM25")
client.create_collection("it_kb", schema=schema, index_params=index_params)
3)代码:三路检索对比
混合检索用 AnnSearchRequest 分别描述两路请求,再交给 hybrid_search 用 RRFRanker 融合。稀疏路有个反直觉的点:data 里直接传 Query 原文,不传向量——Milvus 内部会走 BM25 Function 实时算稀疏向量。
from pymilvus import AnnSearchRequest, RRFRanker
dense_req = AnnSearchRequest(
data=[query_vec], # 稠密路传 Embedding 向量
anns_field="dense",
param={
"metric_type": "COSINE", "params": {
"nprobe": 16}},
limit=30)
sparse_req = AnnSearchRequest(
data=[query_text], # 稀疏路传原文!
anns_field="sparse",
param={
"metric_type": "BM25",
"params": {
"drop_ratio_search": 0.2}}, # 过滤底部 20% 低分结果
limit=30)
results = client.hybrid_search(
collection_name="it_kb",
reqs=[dense_req, sparse_req],
ranker=RRFRanker(k=60),
limit=5,
output_fields=["text"])
想单独跑纯 BM25 路对比效果,用普通 search 传原文即可:client.search(..., anns_field="sparse", data=[query_text])。
你可以做个最小实验:往库里塞 6 条 IT/HR 文档,用「OA 报销单提交报错 E-503 怎么处理」跑三路对比,典型结果长这样(玩具数据,数字为示意,建议亲手跑一遍):
| 排名 | 纯向量检索 | 纯 BM25 | 混合检索(RRF) |
|---|---|---|---|
| Top-1 | OA 系统常见问题 | E-503 处理手册 | E-503 处理手册 |
| Top-2 | 报销审批操作指南 | (无结果返回) | OA 系统常见问题 |
| Top-3 | E-503 处理手册 | — | 报销审批操作指南 |
三个现象值得记住:BM25 高精度但不做语义兜底,上面代码里稀疏路开了 drop_ratio_search=0.2,词项不重叠的低分文档被直接过滤,整条路只返回 1 条命中;向量检索高召回,语义沾边的全捞回来,但《E-503 手册》只排到第三;混合检索里《E-503 手册》稀疏路排第 1、稠密路进了前三,RRF 得分约 0.032,而第二名只在稠密路有成绩、约 0.016,被甩开近一倍——两路都认可的结果,融合后优势会被放大,精确命中和语义兜底都拿到了。数据量上万以后,这个差距只会更明显。
四、重排序:召回管「别漏」,精排管「排对」
混合检索解决的是「找得全」,但大模型只看上下文里的前 3~5 条——顺序错了,照样答非所问。
打个招聘的比方:召回阶段像 HR 初筛,按岗位关键词把沾边的简历全捞进池子,宁可多捞一百份,不可漏掉一个合适的;重排序像用人部门主管逐份面试,把真正匹配的三五份排到最前面发 offer。

这里涉及两种编码器结构:
Bi-Encoder(双编码器):把 Query 和文档各自独立编码成一个向量,再算两个向量的相似度,Embedding 模型就是这种结构;像两个人各自填完一份问卷再比对答案。文档向量可以提前算好存进向量库,查询时只编码 Query 一次,所以快。
Cross-Encoder(交叉编码器):把 Query 和文档拼接成一段文本一起输入模型,让注意力机制在两者之间逐词交互,最后只输出一个相关性分数;像把两个人叫到一起当面聊一场,判断准,但每一对都得聊一次,速度慢得多。
主流的重排序模型(Reranker),比如 bge-reranker 和 Qwen3-Reranker,基本都是 Cross-Encoder 结构。两者的差别看这张对比图:

1)为什么不直接拿 Cross-Encoder 全库扫
既然它准,为什么还要两阶段?算笔账:知识库 100 万个 chunk,一次提问就要把 100 万个配对逐个过模型,就算 GPU 批量并行也得几十分钟,没人等得起。所以工程上一定是两阶段——粗排快但糙,全库扫出几十个候选;精排慢但准,只给这几十个候选重新打分:

可能有人会问:那重排序直接让大模型(LLM)来打分不行吗?
技术上可行,部分框架也支持,但贵:40 个候选就是 40 次大模型推理,每次都要带几千 token 的文档,延迟奔着秒级去,批量场景成本肉疼。专用 Reranker 是几亿参数的编码器,就干「判断相关性」这一件事,几百毫秒搞定。追求极致且预算无上限可以上 LLM 重排,绝大多数团队用专用模型就够。
2)Reranker 怎么选
截至 2026 年 8 月,常用的就这么几款:
| 模型 | 特点 | 适用场景 |
|---|---|---|
| bge-reranker-v2-m3 | 轻量、中文效果稳、可本地部署 | 预算敏感、想私有化 |
| Qwen3-Reranker(0.6B/4B/8B) | 2025 年 6 月发布,中文榜单第一梯队,支持指令定制 | 中文问答、复杂意图排序 |
| Cohere / Jina 商业 API | 开箱即用,多语言成熟 | 海外业务、不想运维模型 |
硅基流动等平台提供了开箱即用的 rerank 接口(格式与 Cohere rerank 一致),不用自己部署。代码就一段 HTTP 调用:
import requests
def rerank(query, documents, top_k=5,
model="BAAI/bge-reranker-v2-m3"):
resp = requests.post(
"https://api.siliconflow.cn/v1/rerank", # 以你用的平台文档为准
headers={
"Authorization": "Bearer <你的 API Key>"},
json={
"model": model, "query": query,
"documents": documents,
"top_n": top_k,
"return_documents": True},
timeout=30)
resp.raise_for_status()
return resp.json()["results"] # 每项含 index 和 relevance_score
返回结果已按 relevance_score 降序排好,取前 5 个 index 回原候选列表取文档就行。用 Qwen3-Reranker 时,部分平台还支持额外传 instruction 参数,比如「优先排序包含具体报错码和操作步骤的文档」,业务定制很顺手(具体字段以平台文档为准)。
再看个例子,Query「Mac 电脑怎么申请重装系统」(候选集是混合检索召回的几十条,这里只列重排前后各自的 Top-3):
| 阶段 | Top-1 | Top-2 | Top-3 |
|---|---|---|---|
| 混合检索重排前 | 办公电脑维修与报废流程 | Mac 重装系统申请指南 | 新员工设备申领须知 |
| Rerank 重排后 | Mac 重装系统申请指南 | 办公电脑维修与报废流程 | macOS 自助重装 U 盘借用说明 |
重排把最该进上下文的那条提到了第一,原本排在候选集中后部、带「自助重装」关键词的那条也升到了第三。注意重排只调整候选集内部的顺序,不会变出召回阶段没有的文档——这也是后面强调「先保召回」的原因。重排序的价值从来不是多找几篇,而是让前五名名副其实。
五、串成一条链路:参数怎么调、效果怎么评
把整条链路画成时序图,谁先谁后、每段多少数据、耗时多少,一目了然:

1)参数经验值
不知道怎么配的时候,从这组值起步,再按日志调:
| 参数 | 起步值 | 调整方向 |
|---|---|---|
| 稠密路 topK | 20~30 | 漏召回就加大,延迟敏感就减小 |
| 稀疏路 topK | 20 | 编号、型号类 query 多就加到 40 |
| BM25 drop_ratio_search | 0.2 | 低分噪声多就调大,想多留候选就调小 |
| RRF 的 k | 60 | 一般别动,两路结果波动大也保持 60 |
| Rerank 候选数 | 30~50 | 精度不够先加到 50,同时盯延迟 |
| 最终返回 K | 3~5 | 跟着 LLM 上下文窗口和塞 prompt 的成本走 |
2)调优顺序:先保召回,再调排序
这是我最想强调的一条:召回阶段漏掉的文档,Reranker 再强也变不出来。调检索的第一原则,是先保召回、再调排序。
我见过不少团队一上来就换 Reranker、换大模型,折腾两周没效果,最后发现根因是相关文档压根没进 Top-50——精排阶段连这篇文档的面都见不到,怎么可能把它排上来?钱花了,水里都不响。

正确的顺序分三步:
- 先看召回:从真实对话日志里挑 50~100 条 query,人工标注每条「最该命中的文档」,算 Recall@30——相关文档出现在两路融合后候选集里的比例。没到 90% 就先别动排序,去查切块策略、topK、中文分词,必要时上查询改写。
- 再看排序:Recall 达标后,盯 MRR(每个问题取第一个相关结果排名的倒数——排第 1 得 1 分、排第 5 得 0.2 分,再对所有问题求平均)和 nDCG@10(排名越靠前权重越高的排序得分)。这俩上不去,才是 Reranker 该解决的事。
- 最后看业务:答非所问率、用户追问率、人工兜底率,这是最终裁判。
3)选型决策
| 场景 | 推荐策略 |
|---|---|
| 数据量小、query 全是自然语言口语 | 纯向量检索,简单够用 |
| query 常带编号、型号、内部黑话 | 混合检索,补上关键词这一路 |
| 答案准确率敏感、能接受多一百毫秒 | 混合检索 + Rerank,效果上限最高 |
| 成本敏感、不想多调一个模型 | 混合检索不加重排,RRF 本身已经很能打 |
说白了,检索质量是 RAG 的天花板,大模型只是在天花板下发挥。向量数据库帮你解决了「存得下、搜得快」,而「搜得准」靠的是混合检索拓宽召回、重排序兑现精度——这也是向量检索工程化真正的分水岭。
我是程序员天天困,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你们的 RAG 项目,是栽在召回漏掉文档上,还是栽在排序把错的放第一上?