从零学RAG0x0b:AdvancedRAG查询优化-Multi-recall(多路召回)

5 阅读7分钟

前言

书接上文(AdvancedRAG预检索-查询优化(一)),我们继续一起学习 AdvancedRAG 预检索中查询优化最后一种常用的技术手段:Multi-recall(多路召回)

Multi-recall(多路召回) 在 Advanced RAG 中主要解决 “词汇鸿沟”“语义不完整性” 的核心痛点。

  • 本质逻辑: “不要把所有鸡蛋放在一个篮子里”
  • 核心流程:
    1. 生成机制:通过特定的 Prompt 指令(如“请生成 3 个不同视角的查询”),LLM 会分析原始 Query 的语义,生成一系列相关的衍生问题。这些衍生问题可能包括:
    • 同义词替换(如“LLM” -> “大语言模型”)

    • 视角转换(如“怎么用” -> “使用教程和步骤”)

    • 细节补充(如“学习 Python” -> “Python 语法、学习路线、实战项目”)

    1. 检索与融合:每个生成的 Query 都会独立进行向量检索,召回一批文档。最后,系统会对所有召回的文档进行 去重融合,形成一个更全面的候选文档池

image.png

MultiQueryRetriever

MultiQueryRetriever 是Langchain中一个关键的查询优化组件

  • 核心:解决传统向量检索的一个痛点:用户提问的表述方式与知识库中文档的实际写法存在“词汇鸿沟”,导致单一查询容易遗漏相关信息。
  • 核心流程:
    1. 查询扩展:接收用户的原始查询(例如“怎么使用OpenClaw”),调用一个大语言模型(LLM),让其基于原问题生成多个(通常是3-5个)语义相同但表述各异的查询变体。

    2. 并行检索:将这些变体查询(如“OpenClaw安装步骤”、“OpenClaw部署到飞书、QQ等及时通讯工具”、“LLM平台选择和api申请流程”)同时提交给底层的向量数据库进行检索。

    3. 结果融合:收集所有检索返回的文档片段,进行去重、排序和合并,最终将最相关、最全面的文档集合作为上下文提供给后续的答案生成模块。

核心代码

# 引入日志组件查看llm在原查询的基础上生成的多个查询
import logging
logging.basicConfig()
logging.getLogger("langchain_classic.retrievers.multi_query").setLevel(logging.INFO)

retrieval_from_llm = MultiQueryRetriever.from_llm(
    retriever=retriever,
    llm=llm
)

我们发现,MultiQueryRetriever 默认生成3个query相关的问题。进入其源码发现默认的 Prompt 也是定义的3个。

image.png

image.png

num_queries

那么,我们如果想生成4个相关问题时怎么办?没错,就是自定义 Prompt。

# 自定义提示词模板,用于改变query生成的数量
# 关键:必须包含 {question} 作为输入变量,并要求输出按指定格式分隔。
custom_prompt_template = """
你是一名专业的研究助手。请针对以下问题,生成 4 个不同的搜索查询,以便从向量数据库中查找相关信息。每个查询应从不同的技术角度或应用层面切入。请确保查询具体、专业且与原始问题高度相关。

原始问题:{question}

请严格按照以下格式输出,每个查询单独一行,无需编号:
查询1
查询2
...
查询N
"""
# 创建 PromptTemplate 对象
custom_prompt = PromptTemplate(
    input_variables=["question", "num_queries"], # 必须包含这些变量
    template=custom_prompt_template
)

retrieval_from_llm = MultiQueryRetriever.from_llm(
    retriever=retriever,
    llm=llm,
    prompt=custom_prompt    # 通过自定义prompt修改生成相关问题的数量,但是只能在custom_prompt写死,而不能当做灵活变量
)

image.png

运行后,发现也确实生效了。但是问题又来了:这里是在Prompt写死了num_queries,实际开发中往往我们需要接收一个变量。我使尽浑身解数也没直接做出来😳😳😳。

MultiQueryRetriever 内部将“生成多个查询”和“执行检索”捆绑为一个固定步骤。LLMChain的提示词模板在初始化时就被确定,而 num_queries这类变量并非其标准输入变量。这是其设计模式(追求“开箱即用”)所决定的,也造就了其局限性(牺牲了部分灵活性)。

num_queries-曲线救国

哈哈,不过最终我想到了一个思路,并验证成功了:

  1. num_queries 在 Prompt 中定义
  2. Prompt 最终用在了 MultiQueryRetriever
  3. 那么,我从最最外部传入一个 num_queries,然后传给 PromptTemplate 不就行了

封装 create_multi_query_retriever,返回 MultiQueryRetriever.from_llm获取的 retrieval_from_llm

# ============ 曲线救国:通过封装来实现生成query数量的灵活控制 ============
def create_multi_query_retriever(num_queries=3):
    print("============ 曲线救国:通过封装来实现生成query数量的灵活控制 ============")
    custom_prompt_template = f"""
    你是一名专业的研究助手。请针对以下问题,生成 {num_queries} 个不同的搜索查询,以便从向量数据库中查找相关信息。每个查询应从不同的技术角度或应用层面切入。请确保查询具体、专业且与原始问题高度相关。

    原始问题:{{question}}

    请严格按照以下格式输出,每个查询单独一行,无需编号:
    查询1
    查询2
    ...
    查询N
    """
    # 创建 PromptTemplate 对象
    custom_prompt = PromptTemplate(
        input_variables=["question"], # 必须包含这些变量
        template=custom_prompt_template
    )

    retrieval_from_llm = MultiQueryRetriever.from_llm(
        retriever=retriever,
        llm=llm,
        prompt=custom_prompt  # 通过自定义prompt修改生成相关问题的数量,但是只能在custom_prompt写死,而不能当做灵活变量
    )

    return retrieval_from_llm
    
# 曲线救国:控制生成的query数目
retrieval_from_llm = create_multi_query_retriever(num_queries=2)

运行一下试试,果然也达到了我们的目的。

image.png

高阶玩法

确实以上的曲线救国解决了控制 num_queries 的目的。但是 Langchain V1.0 已经是一个成熟且🐂bi 的框架。那么在企业级生产环境中我们该怎么使用 MultiQueryRetriever 呢?

答案就是自定义实现 MultiQueryRetriever。我们知道 Langchain V1.0 后 MultiQueryRetriever 被划到了 langchain_classic 中。这又是一个兼容过度包,包括之前讲的很多函数也在这个包里。Langchain 新范式鼓励我们自己实现 MultiQueryRetriever(包括其他被划到langchain_classic的模块) 整个流程,这样才具备更高的灵活性和可控性。

# 自定义实现召回函数
async def advanced_multi_query_retrieve(question: str,
                                        llm,
                                        base_retriever,
                                        num_queries: int = 3) -> List[Document]:
    """
    多路召回核心函数
    参数完全可控,流程清晰可见
    """
    # 1. 动态生成多个查询
    generation_prompt = PromptTemplate.from_template("""
    请针对以下问题,生成正好{num}个不同角度的搜索查询。
    问题:{question}
    只返回{num}个查询,每行一个:
    """)
    chain = generation_prompt | llm
    result = await chain.ainvoke({"question": question, "num": num_queries})

    # 解析生成的查询列表
    generated_queries = [q.strip() for q in result.content.split('\n') if q.strip()]
    print(f"生成的查询: {generated_queries}")  # 完全可观测

    # 2. 并行执行所有查询(提升效率)
    tasks = [base_retriever.ainvoke(q) for q in generated_queries[:num_queries]]
    all_results = await asyncio.gather(*tasks)

    # 3. 结果去重
    seen_ids = set()
    unique_docs = []
    for doc_list in all_results:
        for doc in doc_list:
            # 基于内容哈希或ID去重
            doc_id = hash(doc.page_content[:200])  # 简单示例
            if doc_id not in seen_ids:
                seen_ids.add(doc_id)
                unique_docs.append(doc)
    return unique_docs[:20]  # 限制返回总数


# 同步调用封装
def multi_query_retrieve(question: str, num_queries: int = 3):
    import asyncio
    return asyncio.run(advanced_multi_query_retrieve(
        question, llm, retriever, num_queries
    ))
    

小结

优化方式核心逻辑典型技术实现典型应用场景
Enrich Question(查询丰富化)交互式补全意图识别 + 槽位填充 + LLM对话补全。常构建在智能体(Agent) 框架上,管理多轮状态。客服问答、任务型对话(订票、查询)、需明确多个约束条件的专业领域检索。
Muti-recall(多路召回)并行检索与融合查询改写 + 混合检索(稠密向量、稀疏词袋、BM25等) + 后期融合策略(如RRF)。开放域问答、复杂事实性问题、对查全率要求高的场景,确保不遗漏关键文档片段。
Question Decomposition(问题拆解)分而治之LLM思维链(CoT)提示 + 子查询生成 + 中间答案传递与合成。复杂推理问答、多步骤问题解决、涉及比较、因果推断等需要串联多个信息点的场景。

源码

github