Agentic RAG 进阶:让 Agent 自己决定"查什么、查几次、怎么查"

举报
yd_288476769 发表于 2026/09/19 12:49:14 2026/09/19
【摘要】 作者:yumking | 2026 年 9 月 19 日 | 技术标签:Agentic RAG / 多跳检索 / GraphRAG / 查询规划 / 自反思 摘要第 4 篇搭了 RAG 基础流水线(查一次 → 拼 prompt → 生成),把准确率从 58% 拉到 92%;第 12 篇把检索从纯文本扩到图文多模态。但两篇共享一个隐含假设:一次检索就能拿全证据。真实世界里"对比 A、B 两公司...

作者:yumking | 2026 年 9 月 19 日 | 技术标签:Agentic RAG / 多跳检索 / GraphRAG / 查询规划 / 自反思


摘要

第 4 篇搭了 RAG 基础流水线(查一次 → 拼 prompt → 生成),把准确率从 58% 拉到 92%;第 12 篇把检索从纯文本扩到图文多模态。但两篇共享一个隐含假设:一次检索就能拿全证据。真实世界里"对比 A、B 两公司去年利润增长率"要查四次(两家 × 两年),“张三导师的学生都在哪些公司"要沿关系图走三跳——单跳 RAG 在这种问题上要么硬凑要么放弃。本文把检索从"被动的一次性调用"升级为"Agent 主动规划的迭代过程”:查询分解与路由、多跳迭代检索、GraphRAG 让关系可检索、自反思重写查不好就重查。实测多跳问题准确率从 34%(单跳 RAG)提升至 88%,关系类问题从 22% 提升至 91%,代价是平均检索轮数从 1.0 升到 2.7、延迟从 450ms 升到 1.8s——用"多查几轮"换"答得对"。


一、为什么"查一次"不够

1.1 第 4 篇 RAG 的隐含假设

第 4 篇的五阶段流水线,在线查询阶段是这样的:

用户提问 ──④向量检索──► Top-K 相关块 ──⑤LLM 生成──► 答案
                          ↑
                    一次性检索,K 通常取 5-10

这套流程背后藏着一个假设:用户的问题可以被一次检索覆盖。对于"公司年假政策是什么"这类单点事实问题,假设成立——查一次就够。但很多真实问题不是单点的。

1.2 三类"查一次"解决不了的问题

问题类型 例子 为什么查一次不行
多跳推理 “去年利润增长最快的部门负责人是谁” 要先查各部门利润(跳 1)→ 算增长排序(跳 2)→ 查负责人(跳 3)
对比聚合 “对比 A 和 B 产品的退货率差异” 要查 A 退货率 + B 退货率,两个独立子检索
关系遍历 “张三导师的学生都在哪些公司” 要查张三的导师(跳 1)→ 导师的学生(跳 2)→ 学生的公司(跳 3),每跳依赖上一跳结果

一个真实翻车案例:

用户问:"我们上季度收入最高的三个产品线,各自的负责人和汇报对象是谁?"
单跳 RAG:拿"收入最高 三个产品线 负责人 汇报对象"整句去检索
  → 知识库里没有哪篇文档同时写了这些信息
  → 召回一堆提到"收入"的零散片段
  → LLM 硬凑:"根据资料,可能是..."(幻觉)
实际需要四跳:
  ① 查上季度各产品线收入  ② 排序取前三
  ③ 查这三个产品线的负责人  ④ 查这些负责人的汇报对象
每跳的输出是下一跳的输入——这是检索的"链式调用"。

1.3 与第 4/12 篇的关系

第 4 篇(RAG 基础)
  → 解决"查文字知识",本文解决"复杂问题怎么查"
第 12 篇(多模态 RAG)
  → 扩展了检索的"模态宽度"(文+图+表),本文扩展"检索深度"(多跳+迭代)
第 7 篇(任务规划)
  → 本文的查询规划是任务规划在检索层的特化——把"怎么完成任务"具体到"怎么查资料"
第 9 篇(经验学习)
  → 本文的自反思重写会沉淀"哪种重写策略有效",喂回经验库

第 4 篇是"会查",第 12 篇是"查得全",本文是"查得深、查得准、查得聪明"。


二、查询规划:把"查什么"交给 Agent

2.1 从单次检索到检索计划

单跳 RAG 的检索是"无脑的一步":query 进去,Top-K 出来。Agentic RAG 在这一步前面加一个规划阶段——让 LLM 先看问题,决定要查几次、每次查什么、结果怎么组合。

单跳 RAG:
  query ──► retrieve ──► generate

Agentic RAG:
  query ──► plan ──► [retrieve₁ → retrieve₂ → ... → retrieveₙ] ──► synthesize
              ↑
        规划器决定 n、每次的 sub-query、路由到哪个数据源

规划器的输出不是答案,是一份检索计划——类似第 7 篇的任务分解,但粒度更细,专门针对检索。

2.2 查询分解:复杂问题拆子问题

# query_planner.py —— 查询规划器
from pydantic import BaseModel

class SubQuery(BaseModel):
    text: str               # 子问题文本
    source: str             # 路由到哪个数据源:vector / graph / sql / web
    depends_on: list[int]   # 依赖哪些前序子问题的结果(-1 表示无依赖)
    extract: str            # 从前序结果里提取什么填进自己的 query

class RetrievalPlan(BaseModel):
    sub_queries: list[SubQuery]
    synthesize: str         # 最终怎么组合子答案

PLANNER_PROMPT = """你是检索规划器。把用户问题分解成若干子检索。
规则:
1. 每个子检索必须能被一次向量/图/SQL 检索回答,不要把多跳塞进一个子检索
2. 用 depends_on 标注依赖:子检索 B 需要 A 的结果时,B.depends_on=[A 的序号]
3. extract 字段说明从前序结果提取什么:如 "上一步返回的三个产品线名称"
4. source 选最合适的源:关系问题选 graph,数值聚合选 sql,一般选 vector
5. synthesize 说明最终怎么组合

用户问题:{question}
可用数据源:{sources}
"""

class QueryPlanner:
    def __init__(self, llm, retrievers: dict):
        self._llm = llm
        self._retrievers = retrievers   # {"vector": vec, "graph": graph, "sql": sql}

    def plan(self, question: str, sources: list[str]) -> RetrievalPlan:
        resp = self._llm.complete(
            PLANNER_PROMPT.format(question=question, sources=sources),
            response_format=RetrievalPlan,
        )
        return resp.parsed

    def execute(self, plan: RetrievalPlan) -> dict:
        """按依赖顺序执行子检索,后一步可引用前一步结果"""
        results = {}
        for i, sq in enumerate(plan.sub_queries):
            # 把前序结果填入当前子问题
            resolved_query = self._fill_dependencies(sq, results)
            retriever = self._retrievers[sq.source]
            results[i] = retriever.search(resolved_query, top_k=8)
        return {"sub_results": results, "plan": plan}

    def _fill_dependencies(self, sq: SubQuery, results: dict) -> str:
        if not sq.depends_on:
            return sq.text
        # 简化:把依赖步的返回文本拼进 query;生产环境用 LLM 按 extract 字段精确提取
        deps = " ".join(
            results[d].summary for d in sq.depends_on if d in results)
        return f"{sq.text} 上下文:{deps}"

规划器的关键不在分解本身,在于依赖标注——depends_on 让子检索可以串成链,而不是并行各查各的。

2.3 查询路由:不同子问题去不同源

子问题特征 路由目标 例子
事实/概念/描述 向量库 “K8s 的 Pod 是什么”
实体间关系 知识图谱 “谁向谁汇报”、“A 依赖 B 吗”
数值聚合/统计 SQL 库 “上季度各产品线收入排序”
时效性强的外部信息 Web 搜索 “今天央行利率决议结果”

单跳 RAG 把所有问题都塞进一个向量库——查关系查不出、查统计查不准。路由的本质是让每种问题去它该去的地方。


三、多跳检索:链式追证据

3.1 迭代检索-生成循环

多跳检索不是"并行查 n 次",而是"查一次拿到一部分答案,根据这部分答案决定下一步查什么"——每跳的 query 由上一跳的结果动态生成。

跳 1:query₀ = "上季度收入最高的三个产品线"
       → 结果:[企业版, 国际版, 教育版]
跳 2:query₁ = "企业版/国际版/教育版 的负责人"   ← query 由跳 1 结果生成
       → 结果:{企业版: 张三, 国际版: 李四, 教育版: 王五}
跳 3:query₂ = "张三/李四/王五 的汇报对象"       ← query 由跳 2 结果生成
       → 结果:{张三→VP刘, 李四→VP陈, 王五→CEO赵}
合成:收入最高三线 = 企业版/国际版/教育版
      负责人 = 张三/李四/王五,汇报 = VP刘/VP陈/CEO赵

3.2 证据积累与终止判断

多跳循环要解决两个控制问题:什么时候停、怎么避免空转。

# multi_hop_retriever.py —— 多跳迭代检索器
import re

class MultiHopRetriever:
    """迭代检索:每跳根据已有证据生成下一跳 query,直到证据充分或触顶"""

    def __init__(self, llm, vector_store, max_hops: int = 5,
                 evidence_threshold: float = 0.82):
        self._llm = llm
        self._vs = vector_store
        self._max_hops = max_hops
        self._threshold = evidence_threshold

    NEXT_QUERY_PROMPT = """已有问题:{question}
已收集证据:
{evidence}
还有哪些信息缺失才能回答完整问题?如果证据已充分,返回 DONE。
否则返回下一个该查的子问题(一句话,能被一次向量检索回答)。
"""

    def retrieve(self, question: str) -> dict:
        evidence = []
        trace = []
        for hop in range(self._max_hops):
            # ① 决定这一跳查什么
            if hop == 0:
                next_query = question
            else:
                next_query = self._decide_next(question, evidence)
                if next_query == "DONE":
                    trace.append({"hop": hop, "action": "terminate",
                                  "reason": "evidence_sufficient"})
                    break

            # ② 执行检索
            docs = self._vs.search(next_query, top_k=6)
            evidence.extend(docs)
            trace.append({
                "hop": hop, "query": next_query,
                "retrieved": len(docs),
                "cumulative_evidence": len(evidence),
            })

            # ③ 证据充分性自评
            if self._sufficiency(question, evidence) >= self._threshold:
                trace.append({"hop": hop, "action": "terminate",
                              "reason": "threshold_met"})
                break

        return {"evidence": evidence, "hops": len(trace) - 1,
                "trace": trace}

    def _decide_next(self, question: str, evidence: list) -> str:
        ev_text = "\n".join(d.summary for d in evidence[-4:])
        resp = self._llm.complete(
            self.NEXT_QUERY_PROMPT.format(
                question=question, evidence=ev_text))
        return resp.strip()

    def _sufficiency(self, question: str, evidence: list) -> float:
        """让 LLM 自评:当前证据够不够回答问题,返回 0-1"""
        resp = self._llm.complete(
            f"问题:{question}\n证据:{[d.summary for d in evidence]}\n"
            f"证据是否充分回答问题?只回 0-1 的数字。")
        m = re.search(r"([01](?:\.\d+)?)", resp)
        return float(m.group(1)) if m else 0.0

三个设计要点:

① max_hops 硬顶:防止 LLM 永远觉得"还差一点"无限循环
② sufficiency 自评:每跳后让 LLM 打分,够分就停,避免多余检索
③ trace 全程留痕:每跳的 query、召回数、终止原因都记下,
   接第 23 篇可观测性——多跳检索的每跳是一个 span

3.3 一个多跳案例的完整轨迹

问题:"我们去年离职率最高的团队,其空缺岗位的招聘进度如何"

跳 0  query="去年各团队离职率"
      retrieved=6  sufficiency=0.4  → 继续
跳 1  query="离职率最高的团队名称"(从跳 0 证据里提炼)
      retrieved=5  sufficiency=0.6  → 继续
跳 2  query="该团队当前空缺岗位"
      retrieved=4  sufficiency=0.85 → 阈值 0.82 达标,停
合成:3 跳,总耗时 1.4s,召回 15 篇,答案有据可查

单跳 RAG 在同样问题上:拿整句检索 → 召回一堆提到"离职率"的片段 → 幻觉。


四、GraphRAG:让关系可检索

4.1 向量检索的盲区:关系

向量检索擅长"语义相似",但有个结构性盲区——查不了关系:

向量检索能查:
  "K8s 是什么" → 找语义相似的文档 ✓

向量检索查不好:
  "谁向 CEO 汇报" → 汇报关系是结构化边,不是语义相似
  "订单服务依赖哪些下游" → 依赖关系是图边,散落在多篇文档里
  "张三导师的学生" → 三跳关系链,向量空间里没有这种路径

根因:向量空间把文档压成点,点之间只有距离没有边。关系是图的结构,要用图来查。

4.2 知识图谱构建:实体抽取 + 关系抽取

GraphRAG 在向量库旁边再建一个知识图谱——离线索引阶段,让 LLM 从每篇文档里抽实体和关系:

# graph_rag.py —— GraphRAG:向量 + 图混合检索
from dataclasses import dataclass

@dataclass
class Entity:
    name: str
    type: str          # person / team / product / service ...

@dataclass
class Relation:
    src: str           # 实体名
    dst: str
    type: str          # reports_to / depends_on / member_of ...

EXTRACT_PROMPT = """从以下文档抽取实体和关系,输出 JSON。
实体类型:person/team/product/service/concept
关系类型:reports_to/depends_on/member_of/owns/uses
只抽文档明确陈述的,不要推断。
文档:{doc}
"""

class GraphRAG:
    """向量召回 + 图遍历的混合检索"""

    def __init__(self, llm, vector_store, graph_store):
        self._llm = llm
        self._vs = vector_store
        self._gs = graph_store       # 网络/Neo4j 等

    def index_doc(self, doc: str) -> None:
        """离线索引:文档同时进向量库和图"""
        self._vs.add(doc)                         # 向量索引
        entities, relations = self._extract(doc)  # LLM 抽实体关系
        for e in entities:
            self._gs.add_entity(e)
        for r in relations:
            self._gs.add_relation(r)

    def _extract(self, doc: str) -> tuple[list, list]:
        resp = self._llm.complete(
            EXTRACT_PROMPT.format(doc=doc))
        return resp.entities, resp.relations

    def search(self, query: str, top_k: int = 5,
               max_hops: int = 2) -> dict:
        """混合检索:向量召回种子 → 图扩展相关实体"""
        # ① 向量检索召回种子文档
        seed_docs = self._vs.search(query, top_k=top_k)
        seed_entities = self._extract_entities_from_query(query)

        # ② 图遍历扩展:从种子实体出发走 max_hops 跳,收相关实体
        expanded = set()
        for ent in seed_entities:
            for hop in range(1, max_hops + 1):
                neighbors = self._gs.neighbors(ent, hop=hop)
                expanded.update(neighbors)

        # ③ 用扩展到的实体再回向量库拉相关文档
        extra_docs = []
        for ent in list(expanded)[:10]:
            extra_docs.extend(
                self._vs.search(ent.name, top_k=2))

        return {
            "seed_docs": seed_docs,           # 直接语义相关
            "graph_entities": list(expanded), # 图扩展到的实体
            "extra_docs": extra_docs,         # 因关系而相关的文档
        }

    def _extract_entities_from_query(self, query: str) -> list[Entity]:
        """从 query 里识别已知实体名,作为图遍历起点"""
        resp = self._llm.complete(
            f"识别这句话里的实体名(只回名字,逗号分隔):{query}")
        names = [n.strip() for n in resp.split(",") if n.strip()]
        return [self._gs.find_entity(n) for n in names
                if self._gs.find_entity(n)]

GraphRAG 的精髓在第二步:向量检索找到种子,图遍历沿关系扩展,再回向量库拉文档。三个步骤形成一个"召回-扩展-再召回"的闭环。

4.3 向量 vs GraphRAG 的适用边界

问题类型 向量 RAG GraphRAG 原因
“K8s Pod 是什么” ✓✓✓ ✓✓ 纯语义,向量就够
“订单服务依赖哪些下游” ✓(碰运气) ✓✓✓ 依赖是图边
“张三导师的学生” ✗ ✓✓✓ 三跳关系链
“总结这篇架构文档” ✓✓✓ ✓ 语义总结,图无优势

不是所有问题都要 GraphRAG——纯语义问题用向量更快更准。GraphRAG 的价值在关系密集型问题,所以查询路由(2.3 节)会把关系问题专门导向 graph 源。


五、自反思 RAG:查不好就重查

5.1 检索质量自评

前几节的检索都假设"查回来的东西是对的"。但检索会失败——query 表述不清、术语不匹配、知识库里那篇文档用了不同措辞。自反思 RAG 在检索后加一道质量评估,查得不好就重写 query 再查。

普通 RAG:  query → retrieve → generate
自反思 RAG:query → retrieve → 评估 → 不够好?→ 重写 query → retrieve' → ...
                              ↓ 够好
                          generate

5.2 query 重写策略

重写不是瞎改,是针对检索失败的原因对症下药:

失败原因 重写策略 例子
术语不匹配 换同义词/口语化 “K8s” → “Kubernetes”
query 太长太杂 拆成聚焦的短 query 一长句 → 三个关键词
召回太泛 加限定词收窄 “部署” → “生产环境部署流程”
召回太窄 去限定词放宽 “Java 11 Spring Boot 2.7” → “Spring Boot”
语义偏离 用文档语料里的原话重述 把用户口语换成文档术语
# reflective_rag.py —— 自反思检索
REWRITE_PROMPT = """原始问题:{question}
检索结果(前 3 条摘要):
{snippets}
检索结果与问题相关吗?如果不相关,重写一个更可能命中的检索 query。
如果相关,原样返回 question。只返回 query 文本,不要解释。
"""

class ReflectiveRAG:
    def __init__(self, llm, retriever, max_rewrites: int = 2):
        self._llm = llm
        self._retriever = retriever
        self._max_rewrites = max_rewrites

    def retrieve(self, question: str) -> dict:
        query = question
        for attempt in range(self._max_rewrites + 1):
            docs = self._retriever.search(query, top_k=8)
            if self._is_relevant(question, docs):
                return {"docs": docs, "query_used": query,
                        "rewrites": attempt}
            # 不相关 → 重写
            query = self._rewrite(question, docs)
        return {"docs": docs, "query_used": query,
                "rewrites": self._max_rewrites, "note": "max_rewrites_hit"}

    def _is_relevant(self, question: str, docs: list) -> bool:
        if not docs:
            return False
        snippets = "\n".join(d.summary for d in docs[:3])
        resp = self._llm.complete(
            f"问题:{question}\n结果:{snippets}\n相关吗?只回 yes/no")
        return resp.strip().lower().startswith("yes")

    def _rewrite(self, question: str, docs: list) -> str:
        snippets = "\n".join(d.summary for d in docs[:3])
        return self._llm.complete(
            REWRITE_PROMPT.format(question=question, snippets=snippets))

5.3 与第 9 篇经验学习的对接

重写不是每次从零想——第 9 篇的经验库可以沉淀"哪种重写模式有效":

经验条目示例:
  场景:query 含 "K8s" 召回为空
  有效重写:替换为 "Kubernetes"
  命中率提升:+41 个百分点
  → 下次见到 "K8s" 直接替换,不用 LLM 现想

自反思的副产品是经验数据的持续积累——每次重写是否有效都是一条样本,喂回第 9 篇的经验库,让重写策略越用越准。


六、实测数据

在某企业内部知识库(1.2 万篇文档 + 3800 实体 / 1.1 万关系边)上实测 200 个问题(含 60 个多跳、40 个关系类、100 个单跳):

指标 单跳 RAG(第 4 篇基线) Agentic RAG(本文)
单跳问题准确率 92% 93%(持平,规划开销不划算)
多跳问题准确率 34% 88%
关系类问题准确率 22% 91%(GraphRAG 主贡献)
对比聚合问题准确率 41% 84%(查询分解主贡献)
平均检索轮数 1.0 2.7(多跳问题 3.4 轮)
P50 端到端延迟 450ms 1.8s(多跳 3.2s)
自反思重写命中率 — 重写后召回率 +23pp(首次召回失败时)
幻觉率 17% 4%(证据更充分,硬凑空间更小)

两个值得说的点:

① 单跳问题别用 Agentic RAG:规划 + 自评的延迟开销 ~1.3s,
   对单跳问题准确率没提升(92%→93%),纯属浪费。
   → 查询路由第一步就判:简单问题走单跳快通道,复杂问题才走 Agentic。
② 延迟 1.8s 在"Agent 任务流"里可接受:
   Agent 一个任务本来就要多步推理 + 工具调用,秒级是常态。
   用 1.8s 换 54 个百分点的多跳准确率,ROI 极高。

七、实施难度评估与落地建议

7.1 难度评估

模块 难度 说明
查询规划器 ★★★☆☆ Pydantic 结构化输出 + 依赖标注,难点在让 LLM 稳定输出合规计划
多跳迭代检索 ★★★☆☆ 循环 + 终止判断逻辑清晰,难点在 sufficiency 自评的校准(LLM 自评易乐观)
GraphRAG 构建 ★★★★☆ 实体/关系抽取的准确率决定一切,需大量 prompt 调优 + 人工抽检
GraphRAG 查询 ★★★☆☆ 图遍历本身简单,难点在"从 query 识别种子实体"的准确率
自反思重写 ★★☆☆☆ 概念简单,但 max_rewrites 要控好,否则延迟翻倍
与既有可观测性对接 ★★☆☆☆ 每跳/每次重写记 span,第 23 篇框架现成

7.2 四步落地路线

第一步(1 周,收益最大):
  上查询规划器 + 查询路由
  —— 不做多跳、不建图,只把"复杂问题拆子问题 + 路由到不同源"
  多跳问题准确率就能从 34% 拉到 60% 左右,延迟代价最小。

第二步(2 周):
  加多跳迭代检索 + sufficiency 自评
  —— 接第 23 篇 span 追踪,每跳可观测
  多跳准确率到 80%+,关键是调好 sufficiency 阈值(建议从 0.8 起)。

第三步(3-4 周,关系密集场景才做):
  建 GraphRAG:离线实体关系抽取 + 图遍历混合检索
  —— 先小规模试点(一个业务域的文档),抽准了再扩
  关系类问题准确率到 90%+,但构建成本高,按需投入。

第四步(1 周,按需):
  自反思重写 + 经验沉淀
  —— 接第 9 篇经验库,重写策略越用越准
  首次召回失败时的兜底,命中率提升 20+ pp。

7.3 一个判断原则收尾

不是所有问题都需要 Agentic RAG。判断标准很简单:如果用户的提问里出现了"对比"、“各自”、“通过”、"的…的"这类多跳信号词,或者涉及实体间关系,就走 Agentic;否则走单跳快通道。第 4 篇的单跳 RAG 没有过时——它仍然是简单问题的最优解,Agentic RAG 是它在复杂问题上的补充,不是替代。把两套用查询路由接起来,让每个问题走对的路,才是完整方案。


下一篇预告:上下文窗口是比 GPU 显存更稀缺的资源——第 25 篇把检索做深后,召回的证据怎么塞进有限窗口?历史对话怎么压缩、工具结果怎么裁剪、记忆怎么调度,"上下文工程"值得单独一篇深潜。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。