AI多跳RAG折腾手记

比如:

  1. 第一跳:查"项目A的架构设计",得到微服务架构、事件驱动等信息
  2. 第二跳:基于上一跳结果,查"项目B的架构设计",得到类似结构
  3. 第三跳:对比两者差异,回答共同点和不同点

每一跳的查询都要基于前序跳数的结果,而不是重复查原始问题。为了更直观地展示两者的区别,看一张对比图:

问题到底在哪

先说说单跳RAG的特点:拿到问题后,先做一次检索,把最相关的几块内容塞给模型,让它基于这些内容回答。这种方式在"文档里有什么"这类问题上表现不错,因为答案通常集中在一两段话里。

但遇到需要跨文档推理的问题,它就不行了。比如下面这类:

  • “项目A和项目B在架构设计上有什么共同点和不同点?”
  • “为什么这个文档里提到了X,而另一个文档又说是Y?”
  • “这三篇文档中,谁的观点最有说服力?”

这些问题需要模型"记住"第一跳的结果,再用这个结果去查第二跳、第三跳,最后把所有信息整合起来。单跳RAG做不到这个,因为它每轮都是独立检索,没有"记忆"。

简单说,单跳是查字典,多跳是拼图。

为了更直观地展示两者的区别,看一张对比图:

单跳与多跳RAG的对比,左侧展示单跳的一次检索流程,右侧展示多跳的多轮检索与信息累积过程

单跳RAG是一条线:问题→检索→答案。多跳RAG是一条带记忆的线:问题→检索→生成新查询→再检索→判断是否继续,每一步都带着前面的结果。

多跳RAG要解决什么

多跳RAG的核心问题不是"多查几次",而是"查什么、怎么查、什么时候停"。

查什么

第一跳通常和单跳RAG一样,根据原始问题做检索。但从第二跳开始,就要根据上一跳的结果动态生成新的检索查询。比如:

  1. 第一跳:查"项目A的架构设计",得到微服务架构、事件驱动等信息
  2. 第二跳:基于上一跳结果,查"项目B的架构设计",得到类似结构
  3. 第三跳:对比两者差异,回答共同点和不同点

每一跳的查询都要基于前序跳数的结果,而不是重复查原始问题。

这一步如果用流程图展示会更清楚:

多跳RAG的决策流程,从原始问题开始,每一跳后都判断信息是否充足,决定继续检索还是生成答案

关键在于"信息充足吗"这个判断节点,它决定了是继续下一跳还是生成答案。而这个判断要基于前面所有跳数累积的信息。

怎么查

这里的"怎么查"包括两个层面:检索策略和知识库组织。

检索策略上,有几种常见的做法:

  • 链式推理:每一跳的结果作为下一跳的输入,线性推进
  • 树状展开:一个查询拆分成多个并行查询,再合并结果
  • 迭代优化:不断修正查询,直到得到足够信息

知识库组织上,如果要支持跨文档推理,还得考虑文档间的关联关系。比如同系列文档、引用关系、时间先后等,这些信息能帮助检索系统更聪明地选择下一步查什么。

什么时候停

多跳最尴尬的问题是"停不下来"。理论上可以无限跳下去,但成本会爆炸。需要设计一个停止条件,比如:

  • 达到最大跳数(比如3跳)
  • 模型判断已有足够信息回答
  • 连续两跳的置信度没有提升
  • 用户主动停止

尝试过的实现方案

这次折腾试了两种主要方案:LangChain的MultiHopRetriever和自建链式推理。

方案一:LangChain MultiHopRetriever

LangChain提供了现成的多跳检索实现,上手相对简单:

from langchain.retrievers.multi_hop import MultiHopRetriever
from langchain_community.embeddings import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

# 基础向量库
vectorstore = Chroma(
    embedding_function=OpenAIEmbeddings(),
    persist_directory="./chroma_db"
)

# 创建多跳检索器
retriever = MultiHopRetriever(
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    max_hops=3,
    verbose=True
)

# 使用
question = "项目A和项目B在架构设计上有什么不同?"
docs = retriever.get_relevant_documents(question)

这个方案的优点是开箱即用,不需要自己处理跳数控制和查询生成。但问题也很明显:

  1. 查询生成的逻辑比较固定,对复杂问题的适配性一般
  2. 每一跳的结果只是简单拼接,缺乏结构化的信息提取
  3. 停止条件主要靠硬编码的max_hops,不够智能

方案二:自建链式推理

后来决定自己实现一个更灵活的版本,核心思路是每一跳都让模型判断下一步该查什么:

from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate

class ChainHopRAG:
    def __init__(self, vectorstore, max_hops=3):
        self.vectorstore = vectorstore
        self.max_hops = max_hops
        self.llm = ChatOpenAI(temperature=0)

        self.query_gen_prompt = ChatPromptTemplate.from_messages([
            ("system", "你是一个专业的查询生成助手。根据已收集的信息,判断是否需要进一步检索,如果需要,生成最合适的检索查询。"),
            ("user", "原始问题:{question}\n已收集信息:{collected_info}\n\n请判断:1. 是否需要进一步检索?2. 如果需要,生成1-2个检索查询。")
        ])

        self.answer_prompt = ChatPromptTemplate.from_messages([
            ("system", "你是一个专业的问答助手。基于收集到的所有信息回答用户问题。"),
            ("user", "问题:{question}\n收集到的信息:\n{collected_info}")
        ])

    def run(self, question):
        collected_info = []
        current_queries = [question]

        for hop in range(self.max_hops):
            # 检索当前查询
            for query in current_queries:
                docs = self.vectorstore.similarity_search(query, k=3)
                collected_info.extend(docs)

            # 判断是否需要继续
            if hop < self.max_hops - 1:
                response = self.llm.invoke(
                    self.query_gen_prompt.format(
                        question=question,
                        collected_info="\n".join([d.page_content for d in collected_info[-6:]])
                    )
                )

                # 解析模型反馈
                if "不需要进一步检索" in response.content:
                    break

                # 提取新查询
                current_queries = self._extract_queries(response.content)
                if not current_queries:
                    break
            else:
                break

        # 生成最终答案
        final_answer = self.llm.invoke(
            self.answer_prompt.format(
                question=question,
                collected_info="\n".join([d.page_content for d in collected_info])
            )
        )

        return final_answer.content

    def _extract_queries(self, response_text):
        # 从响应中提取查询语句
        lines = response_text.split('\n')
        queries = []
        for line in lines:
            if line.strip().startswith('-') or line.strip().startswith('•'):
                queries.append(line.strip()[1:].strip())
        return queries

这个版本的改进点在于:

  1. 每一跳都会让模型判断是否需要继续,而不是硬编码跳数
  2. 查询生成更灵活,可以根据已收集信息动态调整
  3. 最终答案的生成基于所有跳数的结果,而不是简单拼接

踩过的坑

多跳RAG不像单跳那么直接,中间有不少坑。

坑一:信息累积问题

第一跳的检索结果可能包含噪声,如果直接带入第二跳,噪声会被放大。比如第一跳检索到一篇不相关的文档,第二跳基于这个文档生成的查询就会越来越偏。

解决这个问题的方式是在每一跳后做一次信息过滤:

def filter_relevant_docs(docs, question, threshold=0.7):
    """过滤相关性较低的文档"""
    relevant_docs = []
    for doc in docs:
        relevance = calculate_relevance(doc.page_content, question)
        if relevance >= threshold:
            relevant_docs.append(doc)
    return relevant_docs

坑二:查询生成质量

让模型生成查询听起来不错,但实际效果参差不齐。有时候生成的查询太泛,检索不到有用信息;有时候又太窄,错过关键内容。

尝试过两种优化方式:

  1. Few-shot prompting:给模型几个示例,教它如何生成查询
  2. 查询模板:对某些常见模式,使用预设的查询模板
QUERY_TEMPLATES = {
    "comparison": "对比{entity1}{entity2}{aspect}方面的差异",
    "reasoning": "为什么{entity}采用了{approach},基于哪些考虑",
    "timeline": "{entity}{time_period}的发展历程和关键节点"
}

坑三:成本爆炸

多跳最直接的问题是成本高。每一跳都要调用检索和推理,跳数多了成本会指数级增长。

实际的妥协方案是:

  • 限制最大跳数为3
  • 使用更便宜的模型做查询生成,比如gpt-4o-mini
  • 检索时限制返回的文档数量(比如每跳只返回top 3)
  • 加入缓存,相同问题不重复检索

最终效果

折腾一圈后,效果比预期好一些。

在跨文档推理这类问题上,多跳RAG的准确率明显比单跳高。比如在测试集中,有一类需要对比两个技术方案的题目,单跳RAG的准确率只有42%,多跳能到68%。

但代价也很明显:响应时间从平均1.2秒涨到4.5秒,成本增加了2-3倍。

所以现在实际部署时,做了一个简单的路由判断:

def should_use_multi_hop(question):
    """判断问题是否适合多跳RAG"""
    keywords = ["对比", "差异", "为什么", "关系", "影响"]
    return any(keyword in question for keyword in keywords)

如果问题明显需要跨文档推理,就用多跳;否则还是用单跳,保持响应速度和成本可控。

一些反思

多跳RAG不是万能药,它只解决一类特定问题。如果你的需求主要是"查文档"、“找信息”,单跳RAG就够用;但如果你需要回答"为什么"“怎么比较"“有什么关系"这类问题,多跳就值得折腾。

另一个感受是,多跳RAG的效果很大程度上取决于知识库的组织方式。如果文档之间本身就没有明确关联,多跳也很难"跳"出有价值的信息。从这个角度看,多跳RAG更像是在倒逼你把知识库整理得更有结构。

最后,多跳RAG的成本问题不能忽视。在实际项目中,成本、效果、速度三者之间总要取舍。目前看来,把多跳RAG用在低频但高价值的场景,比如复杂的技术分析、竞品对比、决策支持,是更务实的选择。


折腾到最后,多跳RAG就像给问答系统加了一个"记忆"能力。单跳是见一面说一嘴,多跳是聊着聊着,能把前面说过的都串起来。但记忆是要花钱的,你得想好哪些问题值得让它"记住”。

版权声明: 本文首发于 指尖魔法屋-AI多跳RAG折腾手记https://blog.thinkmoon.cn/post/376-ai-multi-hop-rag-single-multi-hop-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!