Milvus 混合检索与Reranker重排解析

Milvus 混合检索 实战:稠密+稀疏向量检索与 Rerank 重排全流程解析
前言
在企业级RAG知识库落地过程中,仅使用单一稠密向量检索或传统关键词检索,都会出现召回偏差问题:纯稠密向量擅长语义理解,但专业名词、专属术语召回失效;纯关键词检索能精准匹配字面词汇,却无法理解同义、引申语义。
一、为什么需要混合检索
1.1 单一路径检索存在的问题
- 仅稠密向量检索(Dense Search)
优势:基于向量余弦相似度理解文本语义,支持同义词、转述、模糊语义匹配;
缺陷:对专业术语、编号、专有名词、缩写识别弱,当用户输入精确关键词时,容易漏召回。 - 仅稀疏向量检索(Sparse Search,等价BM25)
优势:基于词权重精确匹配字面关键词,专有名词召回稳定;
缺陷:无语义能力,无法识别近义词,同义内容会被完全忽略。 - 单一检索通用痛点
召回结果分数值域不统一、排序逻辑单一,无法平衡「语义相似度」和「关键词匹配度」,最终送入大模型的上下文质量较差,问答准确率下降。
1.2 稠密检索工作原理
稠密向量检索依靠嵌入模型(BGE-M3/BGE-Large)将整段文本压缩为固定长度浮点数组(本文1024维),通过余弦距离COSINE计算向量之间的空间相似度。
文本语义越接近,向量在高维空间距离越近,检索时返回空间距离最近的Top-K文本块,核心解决「语义相似匹配」。
1.3 稀疏检索工作原理
稀疏向量由BGE-M3输出词权重字典 {单词下标:权重值},本质是增强版TF-IDF/BM25,使用内积IP计算关键词重合度。
文本命中查询关键词越多、关键词权重越高,匹配分数越高,核心解决「字面关键词精确匹配」。
1.4 Hybrid Search 整体思想
两路并行召回,结果融合重排:
- 同一用户查询文本,同时生成稠密查询向量、稀疏查询向量;
- 分别执行稠密语义检索、稀疏关键词检索,得到两份独立召回列表;
- 通过归一化统一两路分数值域,使用加权融合 / RRF倒数排名融合完成重排;
- 输出兼顾语义相似、关键词匹配的最优文本片段作为RAG上下文。
二、Milvus 混合检索整体架构
2.1 整体流程图
多路召回统一融合架构:
用户Query → BGE-M3向量化 → 稠密查询向量 + 稀疏查询向量
↓ ↓
稠密向量检索(ANN HNSW) 稀疏向量检索(倒排索引)
↓ ↓
稠密召回列表 稀疏召回列表
↘ ↙
分数归一化 + Rerank重排(加权 / RRF)
↓
最终融合Top-K结果 → 送入LLM生成答案
拓展多路场景:可接入ES关键词检索、MongoDB标量过滤,全部通过RRF统一融合。
2.2 数据流转流程
- 离线阶段:本地文档加载 → 文本分块Chunk → BGE-M3批量生成稠密/稀疏双向量 → Milvus批量入库;
- 在线查询阶段:用户提问 → 双向量生成 → 两路并行检索 → 重排融合 → 返回高相关文本块。
2.3 Hybrid Search 执行流程
- 对用户查询执行编码,获取dense query vector、sparse query vector;
- 分别调用Milvus
search接口,执行稠密、稀疏两路召回,各自取Top-N(通常取10~20,扩大召回池); - 对两路原始分数做值域归一化,消除COSINE、IP度量单位差异;
- 执行重排算法计算全局综合分数,重新排序;
- 截取融合后Top-K片段输出给上层业务。
2.4 Recall 与 Rerank 的关系
- Recall(召回):粗筛选阶段,尽可能多召回潜在相关文本,追求高召回率,允许混入低相关内容;
混合检索两路Recall就是为了扩大候选池,避免高价值文本被提前过滤。 - Rerank(重排):精细排序阶段,对召回池内所有文本重新打分排序,追求高精准率;
作用:平衡两路检索的打分逻辑,剔除无关文本,把最贴合用户问题的内容排在前列。 - 核心关系:
Recall负责「不漏」,Rerank负责「选优」,二者搭配是RAG高准确率的核心标准架构。
三、Milvus 双向量存储实现
Collection Schema
创建同时容纳稠密向量、稀疏向量、原文、元数据的集合结构,开启自增主键无需手动维护ID:
def build_mixed_schema():
schema = MilvusClient.create_schema(auto_id=True, description="稠密稀疏混合知识库")
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
# 稠密语义向量 1024维
schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=1024)
# 稀疏关键词权重向量
schema.add_field(field_name="sparse_vector", datatype=DataType.SPARSE_FLOAT_VECTOR)
# 存储文本块原文
schema.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=2000)
# JSON元数据:文件路径、页码、文档名称,支持标量过滤
schema.add_field(field_name="metadata", datatype=DataType.JSON)
return schema
Dense Vector 字段说明
- 字段名:
vector - 类型:
FLOAT_VECTOR,dim=1024,与BGE-M3输出维度严格对齐 - 度量方式:COSINE余弦相似度,值域[-1,1],越接近1语义越相似
- 索引:HNSW近似检索索引,平衡检索速度与精度
Sparse Vector 字段说明
- 字段名:
sparse_vector - 类型:
SPARSE_FLOAT_VECTOR,存储{词索引:权重}字典 - 度量方式:IP内积,值域[0,+∞),数值越大关键词匹配度越高
- 索引:SPARSE_INVERTED_INDEX稀疏倒排索引,专为关键词检索优化
Index 索引配置
创建集合时同步构建两套向量索引,无需后续单独建索引:
def build_mixed_index():
index_params = MilvusClient.prepare_index_params()
# 稠密向量HNSW索引
index_params.add_index(
field_name="vector",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200}
)
# 稀疏向量倒排索引
index_params.add_index(
field_name="sparse_vector",
index_type="SPARSE_INVERTED_INDEX",
metric_type="IP",
params={"drop_ratio_build": 0.2}
)
return index_params
四、文档加载与向量化
文档加载
基于LangChain加载Word/PDF/TXT等文档,示例以Word文档为例:
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
def load_document(file_path: str):
loader = UnstructuredWordDocumentLoader(file_path, mode="single")
return loader.load()
Chunk 切分
使用递归字符分割器,控制单段文本长度、重叠字符,避免语义断裂:
from langchain_text_splitters import RecursiveCharacterTextSplitter
def split_chunk(docs):
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ","]
)
return splitter.split_documents(docs)
BGE-M3 编码
BGE-M3一站式输出稠密、稀疏两套向量,无需分开模型推理:
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel("BAAI/bge-m3")
encode_res = model.encode(text_list, return_dense=True, return_sparse=True)
# 稠密向量列表
dense_vecs = encode_res["dense_vecs"]
# 稀疏词权重列表
sparse_vecs = encode_res["lexical_weights"]
批量写入
循环配对文本块、稠密向量、稀疏向量,组装匹配Schema的字典列表批量插入Milvus:
insert_list = []
for doc, dense, sparse in zip(chunks, dense_vecs, sparse_vecs):
insert_list.append({
"vector": dense,
"sparse_vector": sparse,
"text": doc.page_content,
"metadata": doc.metadata
})
client.insert(collection_name=coll_name, data=insert_list)
client.flush(coll_name) # 强制落盘持久化
五、Hybrid Search 两路召回
Dense Search 稠密检索函数
def dense_search(client, coll_name, query_dense, top_n=10):
return client.search(
collection_name=coll_name,
data=[query_dense],
anns_field="vector",
limit=top_n,
search_params={"metric_type": "COSINE"},
output_fields=["text", "metadata"]
)[0]
Sparse Search 稀疏检索函数
def sparse_search(client, coll_name, query_sparse, top_n=10):
return client.search(
collection_name=coll_name,
data=[query_sparse],
anns_field="sparse_vector",
limit=top_n,
search_params={"metric_type": "IP"},
output_fields=["text", "metadata"]
)[0]
Score 说明
- 稠密COSINE分数:范围[-1, 1],1代表完全同义,-1代表语义完全无关;
- 稀疏IP内积分数:范围[0, +∞),数值无上限,关键词命中越多分数越高;
- 核心冲突:两路分数值域、度量逻辑完全不同,无法直接相加比较,必须归一化处理。
查询流程图
用户Query文本
↓
BGE-M3编码 → query_dense / query_sparse
↓ ↓
dense_search sparse_search
↓ ↓
dense_hits(id,cos分数,文本) sparse_hits(id,ip分数,文本)
↘ ↙
归一化统一分数区间
↓
Rerank重排计算
六、Hybrid Search Rerank 重排(整章重写)
为什么需要 Rerank
- 两路检索分数值域不一致,原始分数不能直接融合;
- 单一检索排序逻辑片面,稠密只看语义、稀疏只看关键词;
- 合并候选池后需要统一标准打分,优先输出同时满足「语义相似+关键词匹配」的文本。
Score 为什么不能直接比较
- 稠密COSINE最大1,稀疏IP可能达到几十,数值量级差距巨大;
- 二者度量逻辑不同:一个衡量空间相似度,一个衡量词重合度,原始数值无对比意义;
- 解决方案:将两路分数统一映射到
[0,1]区间,完成归一化。
Rerank 整体执行流程
- 收集稠密、稀疏两路召回的全部文本ID,合并候选池;
- 归一化两路原始分数至0~1区间;
- 选择重排算法(加权融合 / RRF)计算每条文本综合得分;
- 按综合得分降序排序,截取Top-K作为最终检索结果。
Score Normalize 分数归一化
def normalize_score(hits, metric_type: str) -> dict[int, float]:
if not hits:
return {}
raw_scores = [hit["distance"] for hit in hits]
min_s, max_s = min(raw_scores), max(raw_scores)
id_norm_map = {}
for hit in hits:
raw = hit["distance"]
if metric_type == "COSINE":
# cos [-1,1] → [0,1]
norm = (raw + 1) / 2
else:
# IP [0,∞) 最小最大缩放至0~1
norm = (raw - min_s) / (max_s - min_s) if max_s != min_s else 0.0
id_norm_map[hit["id"]] = norm
return id_norm_map
Weighted Rerank 加权融合重排
原理
人工分配稠密、稀疏权重,自由偏向语义或关键词:总分数 = 归一化稠密分 × dense_weight + 归一化稀疏分 × sparse_weight
通用配置:语义优先0.7:0.3;专业文档关键词优先0.5:0.5。
def weighted_rerank(dense_hits, sparse_hits, dense_w=0.7, sparse_w=0.3, top_k=5):
# 归一化两路分数
dense_norm = normalize_score(dense_hits, "COSINE")
sparse_norm = normalize_score(sparse_hits, "IP")
# 缓存文本元数据
id_entity = {}
for hit in dense_hits:
id_entity[hit["id"]] = hit["entity"]
for hit in sparse_hits:
if hit["id"] not in id_entity:
id_entity[hit["id"]] = hit["entity"]
# 合并所有文档ID
all_ids = set(dense_norm.keys()) | set(sparse_norm.keys())
total_score = {}
for idx in all_ids:
d_score = dense_norm.get(idx, 0.0)
s_score = sparse_norm.get(idx, 0.0)
total = d_score * dense_w + s_score * sparse_w
total_score[idx] = round(total, 4)
# 排序截取topK
sorted_res = sorted(total_score.items(), key=lambda x: x[1], reverse=True)[:top_k]
# 组装返回结果
final = []
for doc_id, score in sorted_res:
final.append({
"id": doc_id,
"total_score": score,
"text": id_entity[doc_id]["text"],
"metadata": id_entity[doc_id]["metadata"]
})
return final
RRF 倒数排名融合重排
原理
无需手动设置权重,依靠检索名次自动计算综合分数,公式:
RRFscore(d)=∑i=1N1k+ranki(d)RRF_{score}(d) = \sum_{i=1}^N \frac{1}{k + rank_i(d)}RRFscore(d)=i=1∑Nk+ranki(d)1
- k:平滑系数,行业标准固定60;
- rank_i(d):文档d在第i路检索中的排名;
- 文档在越多检索队列排名靠前,RRF分数越高。
def rrf_rerank(dense_hits, sparse_hits, smooth_k=60, top_k=5):
# 构建id-排名映射
dense_rank = {hit["id"]: i+1 for i, hit in enumerate(dense_hits)}
sparse_rank = {hit["id"]: i+1 for i, hit in enumerate(sparse_hits)}
# 缓存文本信息
id_entity = {}
for hit in dense_hits:
id_entity[hit["id"]] = hit["entity"]
for hit in sparse_hits:
if hit["id"] not in id_entity:
id_entity[hit["id"]] = hit["entity"]
all_ids = set(dense_rank.keys()) | set(sparse_rank.keys())
rrf_score = {}
for idx in all_ids:
score = 0.0
if idx in dense_rank:
score += 1 / (smooth_k + dense_rank[idx])
if idx in sparse_rank:
score += 1 / (smooth_k + sparse_rank[idx])
rrf_score[idx] = round(score, 5)
# 排序截取topK
sorted_res = sorted(rrf_score.items(), key=lambda x: x[1], reverse=True)[:top_k]
final = []
for doc_id, score in sorted_res:
final.append({
"id": doc_id,
"rrf_score": score,
"text": id_entity[doc_id]["text"],
"metadata": id_entity[doc_id]["metadata"]
})
return final
两种算法流程图
- 加权融合流程
两路原始分数 → 归一化到0~1 → 自定义权重相乘求和 → 全局排序输出TopK - RRF融合流程
两路召回结果提取排名 → 套用1/(k+rank)公式累加 → 全局排序输出TopK
两种算法对比
| 重排算法 | 优点 | 缺点 | 适用业务场景 |
|---|---|---|---|
| Weighted加权融合 | 权重可控,可手动偏向语义/关键词,调试灵活 | 需要反复调参,多数据源扩展繁琐 | 业务稳定、知识库类型固定、有调参经验 |
| RRF倒数排名融合 | 零参数自动融合,天然支持多路数据源(向量库+ES+SQL),鲁棒性强 | 无法人工干预偏向语义或关键词 | 通用RAG、快速上线、多库混合召回场景 |
七、完整代码实战
完整整合代码(分层结构+详细注释+流程说明)
"""
Milvus稠密稀疏混合检索 + 加权/RRF双重排完整实战
环境依赖:
pip install pymilvus flagembedding langchain unstructured-documents torch
"""
from pymilvus import MilvusClient, DataType
from FlagEmbedding import BGEM3FlagModel
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from typing import List, Dict, Set
# ===================== 1. Milvus 客户端、Schema、索引工具 =====================
def get_milvus_client(uri: str = "http://localhost:19530") -> MilvusClient:
"""初始化Milvus本地客户端"""
return MilvusClient(uri=uri, token="")
def build_mixed_schema():
"""构建双向量混合存储表结构"""
schema = MilvusClient.create_schema(auto_id=True, description="稠密稀疏混合知识库")
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=1024)
schema.add_field(field_name="sparse_vector", datatype=DataType.SPARSE_FLOAT_VECTOR)
schema.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=2000)
schema.add_field(field_name="metadata", datatype=DataType.JSON)
return schema
def build_mixed_index():
"""构建稠密HNSW、稀疏倒排索引参数"""
index_params = MilvusClient.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200}
)
index_params.add_index(
field_name="sparse_vector",
index_type="SPARSE_INVERTED_INDEX",
metric_type="IP",
params={"drop_ratio_build": 0.2}
)
return index_params
def create_collection(client: MilvusClient, coll_name: str):
"""重置并创建混合向量集合"""
if client.has_collection(coll_name):
client.drop_collection(coll_name)
client.create_collection(
collection_name=coll_name,
schema=build_mixed_schema(),
index_params=build_mixed_index()
)
print(f"【集合创建完成】{coll_name},双向量索引自动构建成功")
# ===================== 2. 文档加载、分块、批量入库 =====================
def load_and_split_doc(file_path: str):
"""加载Word文档并递归切分文本块"""
loader = UnstructuredWordDocumentLoader(file_path, mode="single")
raw_docs = loader.load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ","]
)
chunks = splitter.split_documents(raw_docs)
print(f"【文档切分完成】共生成 {len(chunks)} 个文本块")
return chunks
def batch_insert_mixed_vector(client: MilvusClient, coll_name: str, chunks, embed_model: BGEM3FlagModel):
"""批量生成稠密+稀疏向量并插入Milvus"""
text_list = [doc.page_content for doc in chunks]
# BGE-M3批量编码,同时输出两套向量
encode_res = embed_model.encode(text_list, return_dense=True, return_sparse=True)
dense_vecs = encode_res["dense_vecs"]
sparse_vecs = encode_res["lexical_weights"]
# 组装入库数据
insert_data = []
for doc, dense, sparse in zip(chunks, dense_vecs, sparse_vecs):
insert_data.append({
"vector": dense,
"sparse_vector": sparse,
"text": doc.page_content,
"metadata": doc.metadata
})
# 批量插入并持久化
insert_res = client.insert(collection_name=coll_name, data=insert_data)
client.flush(coll_name)
print(f"【批量入库成功】插入主键ID:{insert_res['ids'][:5]}...")
return insert_res
# ===================== 3. 两路检索工具函数 =====================
def dense_search(client: MilvusClient, coll_name: str, query_dense, top_n: int = 10):
"""稠密语义检索,返回原始命中列表"""
return client.search(
collection_name=coll_name,
data=[query_dense],
anns_field="vector",
limit=top_n,
search_params={"metric_type": "COSINE"},
output_fields=["text", "metadata"]
)[0]
def sparse_search(client: MilvusClient, coll_name: str, query_sparse, top_n: int = 10):
"""稀疏关键词检索,返回原始命中列表"""
return client.search(
collection_name=coll_name,
data=[query_sparse],
anns_field="sparse_vector",
limit=top_n,
search_params={"metric_type": "IP"},
output_fields=["text", "metadata"]
)[0]
# ===================== 4. 重排核心:归一化、加权融合、RRF融合 =====================
def normalize_score(hits: List[dict], metric_type: str) -> Dict[int, float]:
"""两路分数归一化至[0,1]区间"""
if not hits:
return {}
raw_scores = [hit["distance"] for hit in hits]
min_s, max_s = min(raw_scores), max(raw_scores)
id_norm_map = {}
for hit in hits:
raw = hit["distance"]
if metric_type == "COSINE":
norm = (raw + 1) / 2
else:
norm = (raw - min_s) / (max_s - min_s) if max_s != min_s else 0.0
id_norm_map[hit["id"]] = norm
return id_norm_map
def weighted_rerank(dense_hits, sparse_hits, dense_w=0.7, sparse_w=0.3, top_k=5):
"""加权融合重排,支持自定义语义/关键词权重"""
dense_norm = normalize_score(dense_hits, "COSINE")
sparse_norm = normalize_score(sparse_hits, "IP")
# 缓存文本与元数据
entity_cache = {}
for hit in dense_hits:
entity_cache[hit["id"]] = hit["entity"]
for hit in sparse_hits:
if hit["id"] not in entity_cache:
entity_cache[hit["id"]] = hit["entity"]
all_doc_ids = set(dense_norm.keys()) | set(sparse_norm.keys())
score_map = {}
for idx in all_doc_ids:
d = dense_norm.get(idx, 0.0)
s = sparse_norm.get(idx, 0.0)
total = d * dense_w + s * sparse_w
score_map[idx] = round(total, 4)
# 降序排序截取topK
sorted_list = sorted(score_map.items(), key=lambda x: x[1], reverse=True)[:top_k]
result = []
for doc_id, score in sorted_list:
result.append({
"id": doc_id,
"total_weight_score": score,
"text": entity_cache[doc_id]["text"],
"metadata": entity_cache[doc_id]["metadata"]
})
return result
def rrf_rerank(dense_hits, sparse_hits, smooth_k=60, top_k=5):
"""RRF倒数排名融合重排,无权重自动平衡两路结果"""
dense_rank_map = {hit["id"]: i+1 for i, hit in enumerate(dense_hits)}
sparse_rank_map = {hit["id"]: i+1 for i, hit in enumerate(sparse_hits)}
entity_cache = {}
for hit in dense_hits:
entity_cache[hit["id"]] = hit["entity"]
for hit in sparse_hits:
if hit["id"] not in entity_cache:
entity_cache[hit["id"]] = hit["entity"]
all_doc_ids = set(dense_rank_map.keys()) | set(sparse_rank_map.keys())
rrf_score_map = {}
for idx in all_doc_ids:
s = 0.0
if idx in dense_rank_map:
s += 1 / (smooth_k + dense_rank_map[idx])
if idx in sparse_rank_map:
s += 1 / (smooth_k + sparse_rank_map[idx])
rrf_score_map[idx] = round(s, 5)
sorted_list = sorted(rrf_score_map.items(), key=lambda x: x[1], reverse=True)[:top_k]
result = []
for doc_id, score in sorted_list:
result.append({
"id": doc_id,
"rrf_score": score,
"text": entity_cache[doc_id]["text"],
"metadata": entity_cache[doc_id]["metadata"]
})
return result
# ===================== 主执行流程 =====================
if __name__ == "__main__":
# 1. 初始化全局资源
COLL_NAME = "mixed_knowledge_base"
milvus_client = get_milvus_client()
print("加载BGE-M3嵌入模型中...")
embed_model = BGEM3FlagModel("BAAI/bge-m3")
# 2. 创建混合向量集合
create_collection(milvus_client, COLL_NAME)
# 3. 文档处理+批量入库(替换为你的文档路径)
chunks = load_and_split_doc("assets/sample.docx")
batch_insert_mixed_vector(milvus_client, COLL_NAME, chunks, embed_model)
# 4. 用户查询,生成双向量
user_query = "Milvus混合检索如何同时兼顾语义和关键词匹配?"
query_encode = embed_model.encode([user_query], return_dense=True, return_sparse=True)
q_dense = query_encode["dense_vecs"][0]
q_sparse = query_encode["lexical_weights"][0]
# 5. 两路并行召回,各取Top10扩大候选池
dense_top10 = dense_search(milvus_client, COLL_NAME, q_dense, top_n=10)
sparse_top10 = sparse_search(milvus_client, COLL_NAME, q_sparse, top_n=10)
# 6. 方案1:加权融合重排结果
print("\n========== 【加权融合重排结果 dense:0.7 sparse:0.3】 ==========")
weighted_res = weighted_rerank(dense_top10, sparse_top10, dense_w=0.7, sparse_w=0.3, top_k=5)
for idx, item in enumerate(weighted_res):
print(f"{idx+1} 综合分数:{item['total_weight_score']}")
print(f"文本片段:{item['text'][:150]}...\n")
# 7. 方案2:RRF倒数排名融合重排结果
print("\n========== 【RRF自动融合重排结果】 ==========")
rrf_res = rrf_rerank(dense_top10, sparse_top10, top_k=5)
for idx, item in enumerate(rrf_res):
print(f"{idx+1} RRF分数:{item['rrf_score']}")
print(f"文本片段:{item['text'][:150]}...\n")
更多推荐




所有评论(0)