Files
python-agent/docs/04-阶段四-工具生态与外围组件集成/03-RAG技术体系.md
T
2026-09-22 15:42:25 +08:00

23 KiB
Executable File
Raw Blame History

阶段四 · 小点 3:RAG 技术体系(Agent 知识底座)

所属:阶段四 工具生态与外围组件集成 定位:RAG(检索增强生成)是 Agent 的「知识底座」,让模型在没有训练过的情况下,也能基于你的私有文档回答并标明出处。这一讲把整条 RAG 流水线、四个生产级高级技术、以及「怎么把它封装成 Agent 工具」一次讲透。

精简大纲

  1. RAG 完整流程与各环节故障排查
  2. 高级 RAG:查询改写 / 多轮迭代检索 / 片段重排序 / 引用溯源
  3. 普通 RAG vs Agent 内部 RAG
  4. Agent-RAG 融合模式
  5. 总结:一句话理解 RAG + Agent

学习内容详情

1. RAG 完整流程

1.1 一张图看懂整条流水线

graph LR
    subgraph 入库(离线一次)
        A[原始文档] --> B[文档加载 Loader]
        B --> C[清洗降噪]
        C --> D[分块 Chunk]
        D --> E[向量化 Embedding]
        E --> F[(向量库)]
    end
    subgraph 在线(每次提问)
        G[用户提问] --> H[检索 Top-K]
        H --> I[把 问题+片段 拼进 Prompt]
        I --> J[LLM 生成 带引用答案]
    end
    F -.-> H
  • RAG 分两段:离线入库(加载→清洗→分块→向量化→存库)一段跑一次;在线检索生成(每次提问走一遍)。
  • 共 7 个环节:加载、清洗、分块、向量化、检索、生成(外加库)。每个环节都可能出故障。

1.2 各环节常见故障清单

环节 常见故障 排查方向
加载 乱码 / PDF 解析丢内容 换 Loader、检查编码
清洗 广告、页眉、HTML 标签混入 加正则 / 依赖库清洗
分块 把论点腰斩(跨块语义断裂) 调整 chunk_size / overlap,按语义断句
向量化 中文效果差 换中文 BGE 模型
检索 召回无关(top-K 里没有对的) 加混合检索 / Rerank
生成 幻觉(模型瞎编) 引用溯源 + 强制基于片段回答

记忆点:RAG 缓解幻觉、支持引用溯源,是客服 / 知识问答 Agent 的知识底座——它让模型「不靠记忆、而靠检索到的片段」作答。

1.3 分块与召回——RAG 最易翻车的一环

# 概念: 简单分块 + 重叠, 避免"把论点腰斩"
def split_into_chunks(text: str, chunk_size: int = 200, overlap: int = 40) -> list:
    """
    分块策略: 固定长度 + 前后重叠。
    - chunk_size: 每块字符数
    - overlap   : 相邻块重叠, 防止一句话正好卡在边界被腰斩
    生产往往按段落/标题/句号语义切, 而不是固定长度。
    """
    chunks = []
    start = 0
    while start < len(text):
        chunks.append(text[start : start + chunk_size])
        start += chunk_size - overlap   # 下一块往回叠 overlap 个字符
    return chunks

1.4 chunking 策略对比(生产选型)

固定长度只是「教学基线」,生产分块要按语料形态选策略:

  • 固定长度 + 重叠:实现最简单(即上面 split_into_chunks),问题是机械切割、割裂语义——关键论点可能被边界拦腰斩断。
  • 递归字符分块:按 段落 → 句子 → 字符 逐级回退——块太大先按段落切,段落还大再按句子切,兜底才按字符切。LangChain RecursiveCharacterTextSplitter 的默认推荐,通用语料首选。
  • 标题感知分块:按 Markdown / HTML 的标题结构切,并把「标题路径」写进 chunk 元数据(如 产品手册 > 安装 > 环境要求)——检索时可按标题过滤,生成时标题路径就是现成上下文。技术文档首选。
  • 语义分块:相邻句子算 embedding 相似度,相似度骤降处即语义断点——边界最自然,但每句都要过一次 embedding,开销最大。
  • 父子分块(small-to-big):小块用于检索(命中更准),命中后返回其所属大块供生成(上下文更全)——LlamaIndex 的 small-to-big 模式。
策略 边界质量 成本 适用场景
固定长度 + 重叠 差(易腰斩论点) 最低 基线 / 快速验证
递归字符分块 中 低 通用语料默认选择
标题感知分块 高(结构天然对齐) 低 Markdown / HTML 技术文档
语义分块 最高(边界最自然) 高(全量过 embedding) 高价值语料、长叙述文本
父子分块 高(检得准 + 上下文全) 中(存两套块) 检索准但生成缺上下文时

⚠️ 分块策略没有银弹:别靠感觉选——先拿 20 条真实 query,分别用候选策略跑 recall@5 对比召回率,数据说话再定。

🔬 深度理解 · RAG 全流程与检索质量评估(chunking / embedding / rerank):

  1. 本质:RAG 是一条**“离线建库 + 在线检索生成”的知识流水线,本质是把“模型没学过的私有事实”先切块、编码、存进库,查询时把“最相关的几段”塞进 prompt,让模型“照着现成的料作答”而不是凭空编。它解决的矛盾是:LLM 参数知识不是**最新/私有的,而问答又要依据事实。
  2. 机制(为什么每一步都可能掉链子):哪怕 Embedding 再好、Rerank 再准,都救不回“分块把论点腰斩”和“脏数据进库”——因为召回和排序只能在已入库的块上做。质量是链路“木桶”的短板决定,评估必须是端到端:准备一组真实测试 query 与其期望命中的文档(golden set),测 recall@k(正确答案有没有被召回到 top-k)看召回、再在 top-k 上测排序看 precision。chunking 用固定长度+重叠、embedding 用中文 BGE、再决定要不要 rerank,step-by-step 用数据对比,而不是拍脑袋某个单一环节。
  3. 为什么重要:它决定了答问的“可靠性天花板”。召回漏料→幻觉或答非所问,排序歪→给了错误依据,生成阶段再怎么写 prompt 也救不回来;没有评估口径,就永远在“调了好像变好”的玄学里打转。
  4. 易错点/常见误解:误解一「Rerank 能补齐召回的缺陷」——它是排序不是召回,候选里没有的捞不回来;误解二「只要 embedding 强就万事大吉」——再强的 embedding 对“被切碎的论点”也无能为力,分块不当全白搭;误解三「凭感觉评判好坏」——必须用 recall@k / 命中率这类可测指标,带 golden set 评估才是生产姿势。

2. 高级 RAG 技术(生产标配)

2.1 四件套总览

graph TD
    A[高级 RAG 四件套] --> B[查询改写 Query Rewriting]
    A --> C[多轮迭代检索]
    A --> D[片段重排序 Rerank]
    A --> E[引用溯源 + 缓存去重]
    B --> B1[补指代/拆多问/纠错<br/>iPhone→它]
  • 查询改写(Query Rewriting):检索前先用 LLM 把用户口语化问题改写成检索友好查询(补指代、拆多问、纠错别字)——「它多少钱」补全为「iPhone 17 多少钱」。
  • 多轮迭代检索:Agent 的重试循环天然实现——工具描述明确写「可重试」,模型自主决定查几轮、换什么词。
  • 片段重排序:检索内先召回 top-10/20 → Rerank 取 top-3(对照小点 2 的 Cross-Encoder)。
  • 引用溯源:返回附 source + prompt 强制「标注来源」——答案出自哪个文件哪一块,生产必备(可审计、可纠错、可信)。
  • 缓存去重:相同子问题命中缓存直接复用(生产加 TTL,如 10 分钟过期),省调用、省延迟。

🔬 深度理解 · 查询改写(Query Rewriting):

  1. 本质:查询改写的本质是**“先让模型把口语问题翻译成检索系统能听懂的查询,再去检索”**——它是用户口语与向量库检索之间的一层“语义接口”。用户说“它多少钱”,检索库压根没有“它”;不改写,top-K 必偏。
  2. 机制:用一个轻量 LLM 调用把原始 question 加工成更适合检索的 query:① 补指代——把“它”“这个”还原成前置实体(“它多少钱”→“iPhone 17 多少钱”);② 拆多问——把“请假和报销怎么办”拆成两个独立子查询,避免两个主题搅在一个向量里降低各自命中率;③ 纠错别字/补全——去口语冗余、纠正错别字。改写后的 query 再进 embedding 检索,命中率显著提升。本质上是用一次额外 LLM 调用(通常很便宜)换一次检索的高命中。
  3. 为什么重要:用户输入 → 可检索 query 之间的鸿沟,是普通 RAG 掉链子的高发源。它把“检索吃什么”与“用户怎么问”解耦,是高级 RAG 里性价比最高的环节之一。
  4. 易错点/常见误解:误解一「改写=答一遍」——改写只输出查询、不做回答,别把解答塞进检索词;误解二「改写会丢信息」——改写会“精简”也可能误删意图里的关键限定,所以要保留原始 query 兜底或用改写+原query 双路;误解三「每问必改写」——热词/命名清晰的简单问题直接检索即可,改写也有成本,按需启用。

2.2 查询改写 + 缓存去重 + 引用溯源的检索工具

# 生产级"知识库检索工具" = 改写 + 缓存 + 溯源 三件套
import time, hashlib, json

def query_rewrite(llm, raw_question: str) -> str:
    """① 查询改写: 让 LLM 把口语问题改写成检索友好查询"""
    prompt = (
        "请把下面用户的提问改写成更适合检索库的简洁查询。"
        "补全指代、拆开多问、纠正错别字, 只输出改写后的查询。\n提问: " + raw_question
    )
    return llm.invoke(prompt).strip()      # 例: "它多少钱" -> "iPhone 17 的价格"

_cache: dict = {}                          # ② 简单内存缓存(生产用Redis带TTL)

> ⚠️ 以下代码引用了阶段四 02 定义的 `embed_bge`、`query_rewrite` 及向量库对象 `vector_col`,需先补全这些依赖再整体运行(本片段演示链路,**非自包含**)。

def retrieve(question: str, vector_col, llm, ttl: int = 600) -> dict:
    """带 改写+缓存+溯源 的检索入口"""
    key = hashlib.md5(question.encode()).hexdigest()
    hit = _cache.get(key)
    if hit and time.time() - hit["ts"] < ttl:      # ② 缓存命中且未过期 -> 直接复用
        return {"answer": hit["answer"], "cached": True}
    rewritten = query_rewrite(llm, question)       # ① 先改写
    hits = vector_col.query(                       # 再检索 (内部可带Rerank)
        query_embeddings=[embed_bge(rewritten)], n_results=3,
    )
    context = "\n".join(
        f"[来源:{doc_id}] {text}"                  # ③ 给每段贴上来源标签
        for doc_id, text in zip(hits["ids"][0], hits["documents"][0])
    )
    answer = llm.invoke(
        f"仅根据以下资料回答, 并标注来源。\n{context}\n问题:{question}"
    ).strip()
    _cache[key] = {"answer": answer, "ts": time.time()}
    return {"answer": answer, "cached": False, "context": context}

⏳ 短期可不深究:上面内存字典 _cache + 哈希 key + 手写 TTL 这套缓存实现细节,第一遍看懂“有缓存去重这件事”即可——生产直接用 Redis(带 TTL、多进程共享),不必现在复刻这套手写逻辑。先把「同一问题别重复烧一次 LLM」这条收益记住。

2.3 多轮迭代检索与 Cross-Encoder Rerank(最小代码)

四件套里「多轮迭代检索」「片段重排序」两个只有文字描述,补上最小实现:

# ---- ① 多轮迭代检索: 检索 → 判据缺失 → 改写再检索, 直到判据齐或轮次用完 ----
def iterative_retrieve(question, vector_col, llm, max_rounds=3) -> list:
    query, evidence = question, []
    for round in range(max_rounds):
        hits = vector_col.query(query_embeddings=[embed_bge(query)], n_results=3)
        evidence += hits["documents"][0]
        missing = llm.invoke(          # 判据检查: 回答还缺什么关键信息?
            f"问题:{question}\n已检索到:{evidence}\n"
            "若信息已足够回答只输出 DONE, 否则输出一个改写后的检索查询。")
        if "DONE" in missing:
            break                      # 判据齐全, 提前收手(省 token)
        query = missing.strip()        # 缺什么就带什么改写再检索
    return evidence

# ---- ② Cross-Encoder Rerank: (query, chunk) 成对精算分, 排序取 top_k ----
def rerank(query: str, candidates: list, cross_encoder, top_k=3) -> list:
    """candidates 是召回的 top-20; cross_encoder 如 BAAI/bge-reranker 系模型"""
    pairs = [(query, chunk) for chunk in candidates]     # 每对 拼在一起精算
    scores = cross_encoder.predict(pairs)                # 相关性打分(准但慢)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [c for c, _ in ranked[:top_k]]                # 精排后只取 top-3
  • 多轮迭代检索在生产里通常由 Agent 的重试循环天然实现(工具描述写清「可重试」),上面的骨架是把它显式写成循环的样子。
  • Rerank 要点:召回阶段便宜粗筛 top-20,Cross-Encoder 只精排这 20 条(它逐对计算、慢)——即小点 2 的两段式检索。

⏳ 短期可不深究:上面那段“显式的手写多轮检索循环”第一遍看懂思路即可,不用照抄——生产场景通常交给 Agent 内置的重试循环(详见第 3 节),这里只是为了让“多轮迭代”这个抽象概念有个可见的最小形状。把「检索不到就改写再查,直到判据齐或轮次用完」的语义想明白就够。

3. 普通 RAG vs Agent 内部 RAG

类型 检索时机 特点
普通 RAG 用户提问直接检索一次 简单,但复杂问题一次检索不够
Agent-RAG 由 Agent 决策何时检索 可多轮动态检索、自主改写查询
sequenceDiagram
    participant U as 用户
    participant A as 普通RAG
    U->>A: "我要请假和报销, 各有什么流程?"
    A->>A: 一次性检索(可能只命中一个主题)
    A-->>U: 答案(另一个主题/来源缺失)

    participant B as Agent-RAG
    U->>B: "我要请假和报销, 各有什么流程?"
    B->>B: think 识别出是两个子问题
    B->>B: kb_search(请假流程) → 带来源
    B->>B: think
    B->>B: kb_search(报销流程) → 带来源
    B-->>U: 汇总(两条都带来源)
  • 普通 RAG 一次检索就挂的场景(含两个子问题),Agent-RAG 能走通:think → kb_search(子问题1) → think → kb_search(子问题2) → 汇总(两条都带来源)。
  • 判断标准:问题是否单一主题 + 是否需要多来源交叉。是 → 上 Agent-RAG,否 → 普通 RAG 足够。

🔬 深度理解 · 普通 RAG vs Agent-RAG(检索的控制权交给谁):

  1. 本质:二者的分野不是“谁的检索更高级”,而是**“检索时机与次数由谁决定”——普通 RAG 由固定流程在用户提问那一刻检索一次;Agent-RAG 把“检索”作为 Agent 可自主调用的工具(kb_search),由模型在推理循环里决定何时检、检几次、换什么关键词**。
  2. 机制:普通 RAG 是“一次 user query → 一次 top-K → 一次 generate”的直线;Agent-RAG 则套进 ReAct/规划循环:Agent 识别出“这个问题其实含两个独立主题”→ 调 kb_search(请假流程) 拿带来源的答案 → 再调 kb_search(报销流程),最后统一汇总。也就是说,普通 RAG 的“检索”是预置的一次性动作,Agent-RAG 的“检索”是推理循环里的可选择动作——天然支持多轮、改写、跨来源合并。
  3. 为什么重要:它决定了系统能处理的问题复杂度。单一主题单来源用普通 RAG 就够、省 token 省延迟;一旦输入含多主题、需要交叉多来源、或问题本身要逐步澄清,固定一次性检索就会漏料——Agent-RAG 用“把检索变成工具”把答案质量提上来,代价是更多轮次与延迟开销。
  4. 易错点/常见误解:误解一「Agent-RAG = 更好的 RAG」——它不是“更强”,是“更灵活也更贵”,用错场景反而更慢更抖;误解二「工具里塞了 RAG 就叫 Agent-RAG」——关键在于检索是否进入 Agent 的决策循环,只暴露一个傻瓜 kb_search 但不让它决定“何时检”,仍是可调接口而非主动检索;误解三「普通 RAG 做不出多主题」——能做,但需要硬编码“拆问题、依次检、合并”的流程,复杂度其实回了 Agent 手里。

4. RAG 与 Agent 融合模式

4.1 把知识库封装成 Agent 工具

graph LR
    A[QueryEngine<br/>向量库检索逻辑] -->|@tool 包装| B[知识库检索工具]
    B --> C[Agent 自己判断<br/>什么时候去检索]
    C --> D[可多轮动态检索]
    C --> E[可自主改写查询]
  • 知识库封装成 Agent 工具(@tool 包装 QueryEngine),Agent 自己判断什么时候去检索。
  • 检索工具设计要点:查询改写 + 缓存去重 + 引用溯源三者齐备(对照 2.2 的代码)。
from langchain_core.tools import tool

class QueryEngine:
    """封装向量库的检索对象(简版)"""
    def __init__(self, col): self.col = col
    def query(self, q: str): return retrieve(q, self.col, llm=None)["answer"]  # 简化

@tool
def kb_search(question: str) -> str:
    """从公司知识库检索答案。当用户问公司制度/流程/政策时调用。可重试。"""
    return engine.query(question)     # 内部已含 改写+缓存+溯源

4.2 检索强用 LlamaIndex、流程强用 LangGraph 的组合拳

  • LlamaIndex:专精「数据接入与检索」,Document/Node/Index/QueryEngine 四件套天生为 RAG 而生(对照阶段三 LlamaIndex 小节)。
  • LangGraph:专精「工作流编排」,负责 Agent 的多轮决策、分支、循环。
  • 黄金组合:LlamaIndex 负责 Ingestion + Retrieval(怎么把文档处理好、检索准),LangGraph 负责 Orchestration(什么时候检索、要不要重试、怎么汇总)。

5. 总结:一句话理解 RAG + Agent

graph LR
    A[RAG 解决] --> A1["'不知道'的问题: 私有知识、实时信息"]
    B[Agent 解决] --> B1["'不会做'的问题: 规划、调用工具、多步"]
    C[RAG + Agent] --> C1["'既不知道又要做'的问题<br/>客服、知识问答、研究助手"]
  • RAG 解决「不知道」:模型没训练过或过时的问题,靠检索到的资料作答。
  • Agent 解决「不会做」:需要规划、调用工具、多步推理的任务。
  • 二者结合:知识库当工具、Agent 当大脑,覆盖绝大多数生产场景(客服、知识问答、研究助手)。

本节自检

  • 能说清 RAG 七环节及每个环节的常见故障
  • 能实现带查询改写 + 缓存 + 溯源的知识库检索工具并接入 Agent
  • 能区分普通 RAG 与 Agent-RAG,并判断哪种问题该用哪种
  • 能说清 LlamaIndex(检索)与 LangGraph(编排)的分工

本节常见面试题(深度解析)

针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。

面试题 1:一个 RAG 系统“答得不对”,你会怎么排查?请从整条流水线讲。

  • 面试官想考察:是否把 RAG 当成一串要逐环排查的流水线,而不只是“一个检索+一个生成”;是否知道「排序/召回救不了分块和入库的锅」这个层次。
  • 专业作答(含深度):
    1. 先定位是召回问题还是生成问题:把 top-K 检索到的片段直接看——对、翻车很可能在召回(分块揉碎、embedding 差、检索策略偏);片段对但答案错,问题在生成(prompt 未强制据料作答、未约束引用)。
    2. 逐环节过一遍:① 加载/清洗——PDF 乱码、HTML 标签混入;② 分块——论点被腰斩则换递归/标题感知/父子分块;③ 向量化——中文语料用英文模型会差,换中文 BGE;④ 检索——top-K 里没有正确的就加混合检索+Rerank;⑤ 生成——幻觉说明 prompt 没强制基于片段作答或来源标注缺失。
    3. 用评估说话:别猜,用典型 query 集 + golden set 跑 recall@k;调整是数据驱动而不是拍脑袋。
  • 加分亮点 / 深度追问:可强调“Rerank 是排序不是召回,候选里没有的救不回来”,所以先解决‘召回有没有捞到’再谈排序;被追问“为什么同时用普通 RAG 和 Agent-RAG”时可答“单一主题、单来源走普通 RAG 省钱省时,多主题交叉才交给 Agent 决策”。

面试题 2:分块(chunking)为什么是 RAG 最易翻车的一环?你会怎么做选型与评估?

  • 面试官想考察:能否讲清“分块决定检索与生成的下限”,并对固定长度、递归、标题感知、语义、父子等策略有真实判断力。
  • 专业作答(含深度):
    1. 为什么关键:分块是离线入库时一次性定死的,之后的召回和排序都只能在“已入库的块”上做——把论点拦腰切碎的块,再强的 embedding 和 Rerank 也捞不完整,所以这是 RAG 质量木桶里的短板。
    2. 策略权衡:固定长度+重叠最简单但机械切、易切碎语义;递归字符分块按段落→句子→字符逐级回退,通用语料默认;标题感知分块按结构切并把“标题路径”写进元数据,技术文档首选;语义分块按 embedding 相似度断点切、边界最自然但开销大;父子分块(small-to-big)小块检索、返回大块生成,检索准+上下文全。
    3. 怎么选:不是凭感觉——拿 20 条真实 query + golden set,跑 recall@5 对比各策略,数据说话;同时留意 chunk 大小与 overlap 影响块间上下文连贯性和去重。
  • 加分亮点 / 深度追问:可主动补“分块还要考虑 embedding 的输入长度上限和 token 成本”;被追问“为什么招回了相关块却生成还错”时答“可能块太小丢上下文或 prompt 没把多块拼好,可用父子分块或加大 overlap 缓解”。

面试题 3:普通 RAG 和 Agent-RAG 的区别是什么?什么场景该用哪个?

  • 面试官想考察:是否真理解“检索控制权交给谁”而不是只看名字;能否给出可用的判断标准并说明各自的成本。
  • 专业作答(含深度):
    1. 核心区别:普通 RAG 是固定流程“提问一次→检索一次→生成一次”;Agent-RAG 把检索封装成 kb_search 工具、纳入 Agent 的推理循环,由模型自主决定“何时检索、检几轮、换什么词、怎么跨来源汇总”。
    2. 适用判断:问题是否单一主题、是否只需单来源——能满足就用普通 RAG(省 token、省延迟、更稳);一旦含多主题、需多来源交叉、或要分步澄清,就用 Agent-RAG(多轮动态检索、自主改写)。
    3. 成本权衡:Agent-RAG 更灵活,但多轮=更多调用与延迟、行为更多样,不适合固定、高频、低延迟诉求。
  • 加分亮点 / 深度追问:可补“Amazing:普通 RAG 想处理多主题也能做,但往往得硬编码‘拆问题、依次检、合并’的流程,复杂度兜了一圈又回到手写”;被追问“Agent-RAG 检索会不会发散”时答“判据机制(信息够就 DONE)和轮次上限能收敛,防止无限重试”。