多路召回与融合排序:RRF + Rerank + 动态 TopK 工程实践
📚 知识库项目实战系列:
前言
在 RAG 系统中,单一检索通道往往难以覆盖所有相关文档——向量检索擅长语义匹配但可能遗漏关键词精确匹配的内容,关键词检索则相反。HyDE(Hypothetical Document Embedding)通过生成假设性答案扩展查询语义,Web 搜索则引入外部知识。如何将这些异构检索结果有效融合,是提升 RAG 系统召回质量的关键。
O-RAG 系统实现了一套完整的多路召回 → RRF 融合 → Rerank 精排 → 动态 TopK的检索流水线。本文将从算法原理、工程实现、性能优化三个维度,深入剖析这套方案的设计与实现。
一、多路召回架构
O-RAG 的查询工作流支持 4 路并行召回:
item_name_confirm ─┬→ search_embedding ────┐ 权重 1.0
├→ search_embedding_hyde ├→ RRF 权重 0.9
├→ web_search_tavily │ 权重 0.5
└→ query_kg ─────────────┘ 权重 0.8
每路召回有独立的检索策略和适用场景:
| 召回通道 | 检索策略 | 适用场景 |
|---|---|---|
search_embedding | 稠密+稀疏向量混合检索 | 语义相似度匹配 |
search_embedding_hyde | HyDE 扩展后向量检索 | 问题与答案语义差距大 |
web_search_tavily | Tavily API 网络搜索 | 引入外部知识 |
query_kg | Neo4j 知识图谱查询 | 实体关系推理 |
1.1 向量检索通道
向量检索是核心通道,使用 Qdrant 的稠密+稀疏混合检索:
# app/query_process/agent/nodes/node_search_embedding_qdrant.py
def node_search_embedding(state):
"""向量内容检索节点"""
rewritten_query = state.get("rewritten_query")
item_names = state.get("item_names")
# 生成稠密+稀疏向量
embeddings = generate_embeddings([rewritten_query])
# 构建过滤条件(按商品名过滤)
query_filter = None
if item_names:
query_filter = Filter(
must=[FieldCondition(
key="item_name",
match=MatchAny(any=item_names)
)]
)
# 解析要检索的集合列表(支持三模)
collections = _resolve_collections(search_type, source_types)
# 多集合并行检索
if len(collections) > 1:
response = multi_collection_hybrid_search(
client=qdrant_client,
collection_names=collections,
dense_vector=embeddings['dense'][0],
sparse_vector=embeddings['sparse'][0],
limit=5,
query_filter=query_filter
)
集合路由逻辑:根据 search_type 和 source_types 决定检索哪些集合
def _resolve_collections(search_type: str, source_types: list) -> list:
"""解析要检索的 Qdrant 集合列表"""
if source_types:
# 明确指定数据源类型
collections = []
for st in source_types:
source_type = SourceType(st)
col = SOURCE_TYPE_COLLECTION_MAP.get(source_type)
if col:
collections.append(col)
return collections
# 根据 search_type 解析
if search_type == SearchType.DOC:
return [qdrant_config.kb_docs_collection, qdrant_config.kb_yuque_collection]
elif search_type == SearchType.CODE:
return [qdrant_config.kb_code_collection]
elif search_type == SearchType.ALL:
return [kb_docs, kb_yuque, kb_code] # 全部集合
1.2 HyDE 检索通道
HyDE(Hypothetical Document Embedding)是一种查询扩展技术,通过 LLM 生成假设性答案,将"问题"转换为"答案",缩小与文档的语义差距:
# app/query_process/agent/nodes/node_search_embedding_hyde_qdrant.py
def step_1_create_hyde_doc(rewritten_query):
"""调用 LLM 生成假设性答案"""
llm = get_llm_client()
hyde_prompt = load_prompt("hyde_prompt", rewritten_query=rewritten_query)
messages = [HumanMessage(content=hyde_prompt)]
response = llm.invoke(messages)
hyde_doc = response.content
return hyde_doc
def step_2_search_embedding_hyde(rewritten_query, hyde_doc, item_names):
"""根据问题+假设性答案进行向量检索"""
# 拼接查询字符串:原始问题 + 假设性答案
query_str = rewritten_query + " " + hyde_doc
embeddings = generate_embeddings([query_str])
# Qdrant 混合检索
response = hybrid_search(
client=qdrant_client,
collection_name=qdrant_config.chunks_collection,
dense_vector=embeddings['dense'][0],
sparse_vector=embeddings['sparse'][0],
ranker_weights=(0.9, 0.1), # 稠密向量权重更高
limit=5
)
HyDE 的工作原理:
原始问题: "HAK 180 烫金机怎么操作?"
↓ LLM 生成
假设性答案: "HAK 180 烫金机的操作步骤如下:首先打开电源,然后设置温度..."
↓ 拼接查询
查询字符串: "HAK 180 烫金机怎么操作? HAK 180 烫金机的操作步骤如下..."
↓ 向量检索
检索结果: 更接近文档中的操作指南内容
1.3 商品名确认节点
在多轮对话场景中,用户的问题往往是模糊的。O-RAG 在进入检索之前,先通过 item_name_confirm 节点确认用户意图:
# app/query_process/agent/nodes/node_item_name_confirm_qdrant.py
def node_item_name_confirm(state):
"""确认用户问题中的核心商品名称"""
# 1. 获取历史对话
history_chats = get_recent_messages(session_id=state["session_id"], limit=10)
# 2. LLM 提取商品名 + 重写问题
item_names_and_rewritten_query = step_3_llm_item_name_and_rewrite_query(
state["original_query"], history_chats
)
item_names = item_names_and_rewritten_query.get("item_names", [])
rewritten_query = item_names_and_rewritten_query.get("rewritten_query", "")
# 3. Qdrant 向量检索确认商品名
if len(item_names) > 0:
query_qdrant_results = step_4_query_qdrant_item_names(item_names)
item_results = step_5_confirmed_and_optional_item_name(query_qdrant_results)
# 4. 根据确认结果决定流程
state = step_6_deal_list(state, item_results, history_chats, rewritten_query)
商品名确认的三种情况:
- 确定商品名:继续检索流程
- 可选商品名:返回引导性提问("您是想咨询以下哪个商品:XXX?")
- 无匹配商品:返回提示("没有匹配的内容,请重新提问!!")
评分规则:
def step_5_confirmed_and_optional_item_name(query_qdrant_results):
"""通过 Qdrant 查询结果确认商品名"""
confirmed_item_names = []
options_item_names = []
for item_name_meta in query_qdrant_results:
matches = item_name_meta.get("matches", [])
matches.sort(key=lambda x: x.get("score", 0), reverse=True)
high_score_matches = [x for x in matches if x.get("score", 0) >= 0.85]
middle_score_matches = [x for x in matches if x.get("score", 0) >= 0.6]
if len(high_score_matches) == 1:
# 单个高分匹配 → 确定
confirmed_item_names.append(high_score_matches[0].get("item_name"))
elif len(high_score_matches) > 1:
# 多个高分匹配 → 选择与提取名最接近的
same_name_item = next(
(item for item in high_score_matches
if item.get("item_name") == extracted_name),
high_score_matches[0]
)
confirmed_item_names.append(same_name_item.get("item_name"))
elif len(middle_score_matches) > 0:
# 中等分数 → 可选
for item in middle_score_matches[:2]:
options_item_names.append(item.get("item_name"))
二、RRF 融合算法
RRF(Reciprocal Rank Fusion)是一种经典的排序融合算法,核心思想是:一个文档在多个排序列表中的排名越靠前,它的综合得分越高。
2.1 算法原理
RRF 公式:
RRF_score(d) = Σ weight_i × (1 / (k + rank_i(d)))
其中:
d:文档rank_i(d):文档在第 i 个排序列表中的排名(从 1 开始)k:常数(通常取 60),用于平滑排名差异weight_i:第 i 个来源的权重
示例计算:
假设文档 A 在向量检索中排名第 1,在 HyDE 检索中排名第 3:
RRF_score(A) = 1.0 × (1 / (60 + 1)) + 0.9 × (1 / (60 + 3))
= 1.0 × 0.0164 + 0.9 × 0.0159
= 0.0164 + 0.0143
= 0.0307
2.2 工程实现
O-RAG 的 RRF 实现兼容 Qdrant 和 Milvus 两种返回格式:
# app/query_process/agent/nodes/node_rrf.py
def step_3_reciprocal_rank_fusion(
source_with_weight: List[Tuple[List[Dict], float]],
top_k: int = 5,
k_constant: int = 60
) -> List[Dict]:
"""RRF 融合算法"""
rrf_scores: Dict[str, float] = {} # chunk_id → RRF 得分
chunk_info: Dict[str, Dict] = {} # chunk_id → 详细信息
# 遍历每个来源
for source_list, weight in source_with_weight:
if not source_list:
continue
# 遍历该来源的每个结果
for rank, chunk in enumerate(source_list, start=1):
chunk_id = _extract_chunk_id(chunk) # 兼容多种格式
if not chunk_id:
continue
normalized = _extract_chunk_content(chunk)
# RRF 公式: score = weight × (1 / (k + rank))
rrf_score = weight * (1.0 / (k_constant + rank))
# 累加得分(同一 chunk 可能来自多个来源)
if chunk_id in rrf_scores:
rrf_scores[chunk_id] += rrf_score
else:
rrf_scores[chunk_id] = rrf_score
chunk_info[chunk_id] = normalized
# 按 RRF 得分排序,取 top-k
sorted_chunks = sorted(
rrf_scores.items(),
key=lambda x: x[1],
reverse=True
)[:top_k]
# 组装最终结果
result = []
for chunk_id, score in sorted_chunks:
info = chunk_info.get(chunk_id, {})
result.append({
"chunk_id": chunk_id,
"rrf_score": score,
"content": info.get("content", ""),
"title": info.get("title", ""),
"item_name": info.get("item_name", ""),
"score": info.get("score", 0)
})
return result
权重配置:
# 构建带权重的来源列表
source_with_weight: List[Tuple[List[Dict], float]] = []
if embedding_chunks:
source_with_weight.append((embedding_chunks, 1.0)) # 向量检索权重最高
if hyde_embedding_chunks:
source_with_weight.append((hyde_embedding_chunks, 0.9)) # HyDE 次之
if web_search_docs:
source_with_weight.append((web_search_docs, 0.5)) # Web 搜索权重较低
if cross_kb_docs:
source_with_weight.append((cross_kb_docs, 1.2)) # 跨库检索权重最高
权重设计原则:
- 跨库检索 1.2:精准匹配,权重最高
- 向量检索 1.0:核心通道,基准权重
- HyDE 0.9:扩展检索,略低于向量
- Web 搜索 0.5:外部知识,可能不够精准
2.3 格式兼容性
RRF 节点需要兼容 Qdrant 和 Milvus 两种不同的返回格式:
def _extract_chunk_id(chunk: Dict) -> Optional[str]:
"""兼容提取 chunk_id"""
# Qdrant 格式:直接 chunk_id 字段
if "chunk_id" in chunk:
return str(chunk["chunk_id"])
# Milvus 格式:直接 id 字段
if "id" in chunk:
return str(chunk["id"])
# Milvus entity 嵌套格式
entity = chunk.get("entity")
if entity and isinstance(entity, dict):
entity_id = entity.get("chunk_id") or entity.get("id")
if entity_id:
return str(entity_id)
return None
def _extract_chunk_content(chunk: Dict) -> Dict[str, Any]:
"""统一提取 chunk 内容"""
# Qdrant 格式(扁平结构)
if "chunk_id" in chunk and "content" in chunk:
return {
"chunk_id": str(chunk["chunk_id"]),
"content": chunk.get("content", ""),
"title": chunk.get("title", ""),
"score": chunk.get("score") or chunk.get("distance", 0)
}
# Milvus 格式(entity 嵌套)
entity = chunk.get("entity", {})
if entity:
return {
"chunk_id": str(entity.get("chunk_id") or chunk.get("id", "")),
"content": entity.get("content", ""),
"title": entity.get("title", ""),
"score": chunk.get("distance", 0)
}
三、Rerank 精排
RRF 融合后,O-RAG 使用 Rerank 模型进行精排,进一步提升排序质量。
3.1 Rerank 模型
Rerank 模型采用交叉编码器(Cross-Encoder)架构,直接计算 query-document 对的相关性分数:
# app/query_process/agent/nodes/node_rerank.py
def step_2_rerank_doc_list(doc_list, state):
"""使用 Rerank 模型进行精排"""
rewritten_query = state.get("rewritten_query") or state.get("original_query", "")
# 获取文本列表
text_list = [doc['text'] for doc in doc_list if doc.get('text')]
# 加载 Rerank 模型
rerank = get_reranker_model()
# 构造查询对
questions_pairs = [[rewritten_query, text] for text in text_list]
# 计算相关性分数
scores = rerank.compute_score(questions_pairs, normalize=True)
# 添加分数并排序
doc_list_with_score = []
for score, item in zip(scores, doc_list):
item['score'] = float(score)
doc_list_with_score.append(item)
doc_list_with_score.sort(key=lambda x: x.get("score", 0), reverse=True)
return doc_list_with_score
Rerank vs 向量检索的区别:
| 特性 | 向量检索(Bi-Encoder) | Rerank(Cross-Encoder) |
|---|---|---|
| 计算方式 | 分别编码 query 和 doc,计算向量相似度 | 联合编码 query-doc 对 |
| 速度 | 快(毫秒级) | 慢(百毫秒级) |
| 精度 | 中等 | 高 |
| 适用场景 | 粗筛(召回阶段) | 精排(排序阶段) |
3.2 多路数据合并
Rerank 之前需要合并 RRF 结果和 Web 搜索结果:
def step_1_merge_rrf_mcp(state):
"""合并 RRF 和 MCP(Web 搜索)数据"""
rrf_chunks = state.get("rrf_chunks", [])
web_search_docs = state.get("web_search_docs", [])
chunks_list = []
# 处理 RRF 本地数据
for chunk in rrf_chunks:
chunks_list.append({
"chunk_id": _extract_chunk_id(chunk),
"text": _extract_chunk_content(chunk),
"title": _extract_chunk_title(chunk),
"source": "local",
"url": chunk.get("url", "")
})
# 处理 Web 搜索数据
for doc in web_search_docs:
chunks_list.append({
"chunk_id": "", # Web 搜索无 chunk_id
"text": doc.get("snippet") or doc.get("content", ""),
"title": doc.get("title", ""),
"source": "web",
"url": doc.get("url", "")
})
return chunks_list
四、动态 TopK 与断崖检测
静态 TopK 存在一个问题:当候选文档质量参差不齐时,固定截取数量可能导致:
- 截取过多:引入低质量文档,影响生成质量
- 截取过少:遗漏相关文档
O-RAG 实现了动态 TopK + 断崖检测算法,根据分数分布自适应调整截取数量。
4.1 算法设计
# 全局常量
RERANK_MAX_TOPK: int = 10 # 最大 TopK
RERANK_MIN_TOPK: int = 2 # 最小 TopK
RERANK_GAP_RATIO: float = 0.4 # 相对断崖阈值
RERANK_GAP_ABS: float = 0.8 # 绝对断崖阈值
def step_3_topk_and_gap(rerank_score_list):
"""动态 TopK 筛选"""
max_topk = RERANK_MAX_TOPK
min_topk = RERANK_MIN_TOPK
gap_abs = RERANK_GAP_ABS
gap_ratio = RERANK_GAP_RATIO
# 确定初始 topk
topk = min(max_topk, len(rerank_score_list))
# 动态调整(断崖检测)
if topk > min_topk:
for index in range(min_topk - 1, topk - 1):
score_1 = rerank_score_list[index].get("score", 0.0)
score_2 = rerank_score_list[index + 1].get("score", 0.0)
gap = score_1 - score_2 # 绝对差距
# 防止除以零
denominator = abs(score_1) if score_1 != 0 else 1e-6
rel = gap / denominator if denominator > 0 else 0 # 相对差距
# 断崖判定:绝对差距 >= 0.8 或 相对差距 >= 40%
if gap >= gap_abs or rel >= gap_ratio:
logger.info("数据集合 %d 和 %d 位置发生断崖,截取前 %d 条" % (
index, index + 1, index + 1))
topk = index + 1
break
topk_doc_list = rerank_score_list[:topk]
return topk_doc_list
4.2 断崖检测示例
假设 Rerank 后的分数序列为:
[0.95, 0.92, 0.88, 0.45, 0.30, 0.25]
检测过程:
| 位置 | score_1 | score_2 | gap | rel | 是否断崖 |
|---|---|---|---|---|---|
| 0→1 | 0.95 | 0.92 | 0.03 | 3.2% | 否 |
| 1→2 | 0.92 | 0.88 | 0.04 | 4.3% | 否 |
| 2→3 | 0.88 | 0.45 | 0.43 | 48.9% | 是(rel >= 40%) |
检测到位置 2→3 发生断崖,截取前 3 条。
设计亮点:
- 双阈值判定:绝对差距 + 相对差距,避免单一阈值的局限性
- 最小保障:至少保留
min_topk条,避免误判导致无结果 - 最大限制:最多保留
max_topk条,控制 prompt 长度
五、降级策略
Rerank 节点实现了完善的降级策略,确保系统鲁棒性:
def node_rerank(state):
"""Rerank 节点(含降级策略)"""
try:
# 1. 合并多路数据
doc_list = step_1_merge_rrf_mcp(state)
if not doc_list:
state["reranked_docs"] = []
return state
# 2. Rerank 精排
rerank_score_list = step_2_rerank_doc_list(doc_list, state)
# 3. 动态 TopK 截取
final_doc_list = step_3_topk_and_gap(rerank_score_list)
state["reranked_docs"] = final_doc_list
except Exception as e:
logger.exception("Rerank 节点异常: %s" % str(e))
# 降级:返回 RRF 原始数据
rrf_chunks = state.get("rrf_chunks", [])
fallback = []
for chunk in rrf_chunks[:RERANK_MAX_TOPK]:
fallback.append({
"chunk_id": _extract_chunk_id(chunk),
"text": _extract_chunk_content(chunk),
"title": _extract_chunk_title(chunk),
"source": "local",
"score": 0.0
})
state["reranked_docs"] = fallback
return state
降级触发场景:
- Rerank 模型加载失败
- Rerank 打分异常
- 任何未预期的异常
降级行为:直接使用 RRF 融合结果,跳过精排,保证系统可用。
六、完整流水线示例
以一个实际查询为例,展示完整的多路召回与融合流程:
用户问题:"HAK 180 烫金机怎么操作?"
6.1 商品名确认
输入: "HAK 180 烫金机怎么操作?"
LLM 提取: item_names = ["HAK 180"], rewritten_query = "HAK 180 烫金机的具体操作步骤"
Qdrant 确认: 高分匹配 "HAK 180 烫金机" → 确定商品名
6.2 多路召回
向量检索(权重 1.0):
1. chunk_001 (score=0.85) - "HAK 180 操作面板说明..."
2. chunk_002 (score=0.82) - "HAK 180 温度设置指南..."
3. chunk_003 (score=0.78) - "HAK 180 安全注意事项..."
HyDE 检索(权重 0.9):
假设性答案: "HAK 180 的操作步骤:首先打开电源..."
1. chunk_001 (score=0.88) - "HAK 180 操作面板说明..."
2. chunk_004 (score=0.75) - "HAK 180 开机流程..."
Web 搜索(权重 0.5):
1. web_001 (score=0.70) - "烫金机使用教程..."
2. web_002 (score=0.65) - "热转印设备操作..."
6.3 RRF 融合
chunk_001: RRF = 1.0×(1/61) + 0.9×(1/61) = 0.0164 + 0.0148 = 0.0312 ← 两路召回,排名靠前
chunk_002: RRF = 1.0×(1/62) = 0.0161
chunk_004: RRF = 0.9×(1/62) = 0.0145
chunk_003: RRF = 1.0×(1/63) = 0.0159
web_001: RRF = 0.5×(1/61) = 0.0082
web_002: RRF = 0.5×(1/62) = 0.0081
排序后: chunk_001 > chunk_002 > chunk_003 > chunk_004 > web_001 > web_002
6.4 Rerank 精排
Rerank 打分:
chunk_001: 0.95
chunk_002: 0.91
chunk_003: 0.87
chunk_004: 0.82
web_001: 0.45 ← 断崖检测触发
web_002: 0.38
6.5 动态 TopK
断崖检测:
位置 3→4: gap = 0.82 - 0.45 = 0.37, rel = 45% ≥ 40% → 断崖!
截取前 4 条: chunk_001, chunk_002, chunk_003, chunk_004
6.6 答案生成
Prompt 构建:
[1][source=local][title=HAK 180 操作面板说明][score=0.95]
HAK 180 操作面板位于机器正前方...
[2][source=local][title=HAK 180 温度设置指南][score=0.91]
温度设置建议 110℃ 左右...
...
LLM 生成:
"HAK 180 烫金机的操作步骤如下:
1. 打开电源开关
2. 设置温度至 110℃
3. 等待预热完成
..."
七、性能优化建议
7.1 并行检索
多路召回可以并行执行,显著降低延迟:
# 在 LangGraph 中,通过添加多条边实现并行
builder.add_edge("node_item_name_confirm", "node_search_embedding")
builder.add_edge("node_item_name_confirm", "node_search_embedding_hyde")
builder.add_edge("node_item_name_confirm", "node_web_search_tavily")
builder.add_edge("node_item_name_confirm", "node_query_kg")
7.2 预召回倍数
Qdrant 混合检索使用 4 倍预召回,保证 RRF 融合质量:
dense_prefetch = Prefetch(
query=dense_vector,
using="dense",
limit=max(limit * 4, 20) # 预召回 4 倍
)
7.3 模型单例
BGE-M3 和 Rerank 模型使用单例模式,避免重复加载:
_bge_m3_ef = None
def get_bge_m3_ef():
global _bge_m3_ef
if _bge_m3_ef is not None:
return _bge_m3_ef
_bge_m3_ef = BGEM3EmbeddingFunction(...)
return _bge_m3_ef
八、总结
O-RAG 的多路召回与融合排序方案的核心设计:
- 多路并行召回:向量检索、HyDE、Web 搜索、知识图谱四路并行,覆盖不同语义空间
- RRF 融合算法:基于排名的融合,不依赖分数归一化,权重可配置
- Rerank 精排:Cross-Encoder 架构,计算 query-doc 对的精确相关性
- 动态 TopK:断崖检测自适应截取,避免低质量文档污染
- 降级策略:Rerank 失败时回退到 RRF 结果,保证系统可用
这套方案在召回率、精度、鲁棒性之间取得了良好平衡,为后续的跨库检索奠定了基础。下一篇将深入探讨 Neo4j 图数据库在跨库链路检索中的应用。
作者正在寻找 AI 工程方向的机会,欢迎交流。