Milvus 混合检索参数调优:ef / candidate_k / final_k 详解
一、核心概念
final_k--- 最终返回给调用方的结果条数,对应hybrid_search的limit参数candidate_k--- 每路 AnnSearchRequest 的候选条数,RRF 融合前的原始召回量ef(exploration factor,探索因子)--- HNSW 算法查询时的搜索范围,控制图遍历时探索的邻居节点数
二、实现原理
三者的层级关系
ef >= candidate_k >= final_k
示例:ef=40, candidate_k=20, final_k=5
结合代码理解数据流向
python
from pymilvus import MilvusClient, AnnSearchRequest, RRFRanker
from langchain_openai import OpenAIEmbeddings
# ---------- 配置(按需修改) ----------
MILVUS_URI = "http://localhost:19530"
COLLECTION_NAME = "my_col"
FIELD_DENSE = "dense_vector"
FIELD_SPARSE = "sparse_vector"
FIELD_TEXT = "text"
final_k = 5 # ① 最终返回给调用方的条数
candidate_k = 20 # ② 每路召回的候选量,建议 final_k 的 4~10 倍
ef = candidate_k * 2 # ③ HNSW 搜索范围,必须 >= candidate_k
client = MilvusClient(uri=MILVUS_URI)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# --------------------------------------
def hybrid_search(query: str) -> list[dict]:
# ④ 稠密路:embed_query 把 query 转成向量,召回 candidate_k 条候选
query_dense = embeddings.embed_query(query)
req_dense = AnnSearchRequest(
data=[query_dense],
anns_field=FIELD_DENSE,
param={
"metric_type": "COSINE",
"params": {"ef": ef} # ← ef 只对稠密路(HNSW)有效
},
limit=candidate_k, # ← 注意是 candidate_k,不是 final_k
)
# ⑤ 稀疏路:直接传原始字符串,Milvus 服务端内置 BM25 自动向量化
req_sparse = AnnSearchRequest(
data=[query],
anns_field=FIELD_SPARSE,
param={"metric_type": "BM25"}, # ← BM25 走倒排索引,不需要设置 ef
limit=candidate_k, # ← 同样是 candidate_k
)
# ⑥ RRF 融合:两路候选合并打分,截取 final_k 条返回
results = client.hybrid_search(
collection_name=COLLECTION_NAME,
reqs=[req_dense, req_sparse],
ranker=RRFRanker(),
limit=final_k, # ← 这里才截取到 final_k
output_fields=[FIELD_TEXT],
)
# ⑦ 解析原始结果(Milvus 结构 → 普通 dict)
return [
{
"text": hit.get("entity", {}).get(FIELD_TEXT),
"score": hit.get("distance"),
}
for hit in results[0]
]
# 调用示例
hits = hybrid_search("Go 语言的并发模型是什么?")
for i, h in enumerate(hits, 1):
print(f"[{i}] score={h['score']:.4f} {h['text']}")
为什么 candidate_k 要大于 final_k
若两路各取 5 条,真正相关的结果 X 在稠密路排第 6,RRF 融合时根本看不到 X,
导致好结果被漏掉。candidate_k 放大后,X 参与融合,最终得分提升,有机会进入 top-k。
ef 的作用机制
ef 在 AnnSearchRequest 的 param 里配置,只对稠密路(HNSW)有效:
python
param={
"metric_type": "COSINE",
"params": {"ef": candidate_k * 2} # ← 建议显式设置,默认值有时偏小
}
ef控制 HNSW 第 0 层精细搜索时维护的候选集大小ef必须>= candidate_k,否则 Milvus 报错或自动修正ef只在查询阶段 生效,efConstruction只在建索引阶段生效,二者互不影响
ef(exploration factor)底层原理:HNSW 图是怎么搜索的
ef 全称 exploration factor(探索因子) ,名称来自 HNSW 原作者的开源实现 hnswlib,后被 Milvus、Faiss 等向量数据库沿用。部分文档也写作 efSearch,含义相同。
HNSW(Hierarchical Navigable Small World)把所有向量组织成多层图结构:
第 2 层(最稀疏): A --------------------------- F ← 少量节点,用于快速粗定位
第 1 层(中等): A --- C --- E --- F --- H
第 0 层(最密): A-B-C-D-E-F-G-H-I-J ← 所有节点,用于精细搜索
查询过程分两阶段:
阶段一:逐层下降(粗定位)
从最高层入口节点出发
→ 贪心走向离 query 最近的邻居
→ 到达当前层局部最优后下降一层
→ 重复,直到抵达第 0 层
(这个阶段不受 ef 控制)
阶段二:第 0 层扩展搜索(精细搜索)
维护一个大小为 ef 的候选集
→ 不断从候选集里取出最近的节点,探索它的邻居
→ 若邻居比候选集中最远的节点更近,则替换进去
→ 候选集满 ef 个且无更近节点时停止
→ 从候选集里取 top candidate_k 返回
(ef 就在这里起作用)
ef 大小的影响:
ef = 10 → 候选集只维护 10 个节点 → 探索范围小 → 快,但容易漏掉真正最近的向量
ef = 100 → 候选集维护 100 个节点 → 探索范围大 → 慢,但召回更准确
与 efConstruction 的区别:
| 参数 | 作用阶段 | 控制什么 |
|---|---|---|
efConstruction |
建索引时 | 构建图时每个节点找邻居的搜索范围,越大图质量越好,入库越慢 |
ef |
查询时 | 检索时第 0 层的候选集大小,越大召回越准,查询越慢 |
两个参数互不影响,改查询时的 ef 不会重建图结构。
三、经验参考值
| 参数 | 推荐值 | 说明 |
|---|---|---|
final_k |
3 ~ 8 | 给 LLM 的上下文,太多反而干扰生成 |
candidate_k |
20 ~ 50 | 通常取 final_k 的 4~10 倍 |
ef |
candidate_k * 2 |
经验值,保证搜索范围充足 |
python
final_k = 5
candidate_k = 20 # 建议 final_k 的 4~10 倍
ef = candidate_k * 2 # 对应 AnnSearchRequest param 里的 ef
四、注意事项
- 三者成本递增 :
candidate_k越大召回越准,但每路向量计算量线性增长;ef越大图遍历越深,延迟越高 - 不要无脑调大 :
candidate_k=200, ef=400在数据量大时会显著拖慢查询 - 调参顺序建议 :先固定
final_k,再根据召回质量调candidate_k,最后对齐ef ef与efConstruction是两个不同阶段的参数,不要混淆- 稀疏路不需要设置
ef:BM25 走的是倒排索引,不是 HNSW 图结构,ef只对稠密路有效