AI多跳RAG折腾手记
比如:
- 第一跳:查"项目A的架构设计",得到微服务架构、事件驱动等信息
- 第二跳:基于上一跳结果,查"项目B的架构设计",得到类似结构
- 第三跳:对比两者差异,回答共同点和不同点
每一跳的查询都要基于前序跳数的结果,而不是重复查原始问题。为了更直观地展示两者的区别,看一张对比图:
问题到底在哪
先说说单跳RAG的特点:拿到问题后,先做一次检索,把最相关的几块内容塞给模型,让它基于这些内容回答。这种方式在"文档里有什么"这类问题上表现不错,因为答案通常集中在一两段话里。
但遇到需要跨文档推理的问题,它就不行了。比如下面这类:
- “项目A和项目B在架构设计上有什么共同点和不同点?”
- “为什么这个文档里提到了X,而另一个文档又说是Y?”
- “这三篇文档中,谁的观点最有说服力?”
这些问题需要模型"记住"第一跳的结果,再用这个结果去查第二跳、第三跳,最后把所有信息整合起来。单跳RAG做不到这个,因为它每轮都是独立检索,没有"记忆"。
简单说,单跳是查字典,多跳是拼图。
为了更直观地展示两者的区别,看一张对比图:

单跳RAG是一条线:问题→检索→答案。多跳RAG是一条带记忆的线:问题→检索→生成新查询→再检索→判断是否继续,每一步都带着前面的结果。
多跳RAG要解决什么
多跳RAG的核心问题不是"多查几次",而是"查什么、怎么查、什么时候停"。
查什么
第一跳通常和单跳RAG一样,根据原始问题做检索。但从第二跳开始,就要根据上一跳的结果动态生成新的检索查询。比如:
- 第一跳:查"项目A的架构设计",得到微服务架构、事件驱动等信息
- 第二跳:基于上一跳结果,查"项目B的架构设计",得到类似结构
- 第三跳:对比两者差异,回答共同点和不同点
每一跳的查询都要基于前序跳数的结果,而不是重复查原始问题。
这一步如果用流程图展示会更清楚:

关键在于"信息充足吗"这个判断节点,它决定了是继续下一跳还是生成答案。而这个判断要基于前面所有跳数累积的信息。
怎么查
这里的"怎么查"包括两个层面:检索策略和知识库组织。
检索策略上,有几种常见的做法:
- 链式推理:每一跳的结果作为下一跳的输入,线性推进
- 树状展开:一个查询拆分成多个并行查询,再合并结果
- 迭代优化:不断修正查询,直到得到足够信息
知识库组织上,如果要支持跨文档推理,还得考虑文档间的关联关系。比如同系列文档、引用关系、时间先后等,这些信息能帮助检索系统更聪明地选择下一步查什么。
什么时候停
多跳最尴尬的问题是"停不下来"。理论上可以无限跳下去,但成本会爆炸。需要设计一个停止条件,比如:
- 达到最大跳数(比如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)
这个方案的优点是开箱即用,不需要自己处理跳数控制和查询生成。但问题也很明显:
- 查询生成的逻辑比较固定,对复杂问题的适配性一般
- 每一跳的结果只是简单拼接,缺乏结构化的信息提取
- 停止条件主要靠硬编码的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
这个版本的改进点在于:
- 每一跳都会让模型判断是否需要继续,而不是硬编码跳数
- 查询生成更灵活,可以根据已收集信息动态调整
- 最终答案的生成基于所有跳数的结果,而不是简单拼接
踩过的坑
多跳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
坑二:查询生成质量
让模型生成查询听起来不错,但实际效果参差不齐。有时候生成的查询太泛,检索不到有用信息;有时候又太窄,错过关键内容。
尝试过两种优化方式:
- Few-shot prompting:给模型几个示例,教它如何生成查询
- 查询模板:对某些常见模式,使用预设的查询模板
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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。