02-多路召回与融合排序-RRF+Rerank+动态TopK工程实践

11 阅读14分钟

多路召回与融合排序:RRF + Rerank + 动态 TopK 工程实践

📚 知识库项目实战系列:

  1. RAG 系统架构设计:基于 LangGraph 的多源异构 RAG 系统
  2. 多路召回与融合排序:RRF + Rerank + 动态 TopK 工程实践 ← 本篇
  3. 跨库链路检索:Neo4j 图数据库桥接文档与代码
  4. RAG 检索链路实战:从 ItemName 过滤陷阱到引用去重的工程修复
  5. HyDE 假设性文档检索:原理、陷阱与工程化反思
  6. RAG 系统的可控性设计:从 HyDE 到联网搜索的按需启停架构
  7. 从知识库到信息流产品AI助手:多源数据血缘构建与全链路溯源
  8. 代码仓库 RAG 全链路:从 Gitee 抓取、AST 分块到跨库检索

前言

在 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_hydeHyDE 扩展后向量检索问题与答案语义差距大
web_search_tavilyTavily API 网络搜索引入外部知识
query_kgNeo4j 知识图谱查询实体关系推理

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)

商品名确认的三种情况

  1. 确定商品名:继续检索流程
  2. 可选商品名:返回引导性提问("您是想咨询以下哪个商品:XXX?")
  3. 无匹配商品:返回提示("没有匹配的内容,请重新提问!!")

评分规则

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_1score_2gaprel是否断崖
0→10.950.920.033.2%
1→20.920.880.044.3%
2→30.880.450.4348.9%是(rel >= 40%)

检测到位置 2→3 发生断崖,截取前 3 条。

设计亮点

  1. 双阈值判定:绝对差距 + 相对差距,避免单一阈值的局限性
  2. 最小保障:至少保留 min_topk 条,避免误判导致无结果
  3. 最大限制:最多保留 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 的多路召回与融合排序方案的核心设计:

  1. 多路并行召回:向量检索、HyDE、Web 搜索、知识图谱四路并行,覆盖不同语义空间
  2. RRF 融合算法:基于排名的融合,不依赖分数归一化,权重可配置
  3. Rerank 精排:Cross-Encoder 架构,计算 query-doc 对的精确相关性
  4. 动态 TopK:断崖检测自适应截取,避免低质量文档污染
  5. 降级策略:Rerank 失败时回退到 RRF 结果,保证系统可用

这套方案在召回率、精度、鲁棒性之间取得了良好平衡,为后续的跨库检索奠定了基础。下一篇将深入探讨 Neo4j 图数据库在跨库链路检索中的应用。


作者正在寻找 AI 工程方向的机会,欢迎交流。