Files
2026-09-22 15:42:25 +08:00

314 lines
23 KiB
Markdown
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段四 · 小点 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 一张图看懂整条流水线
```mermaid
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 最易翻车的一环
```python
# 概念: 简单分块 + 重叠, 避免"把论点腰斩"
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 四件套总览
```mermaid
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 查询改写 + 缓存去重 + 引用溯源的检索工具
```python
# 生产级"知识库检索工具" = 改写 + 缓存 + 溯源 三件套
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(最小代码)
四件套里「多轮迭代检索」「片段重排序」两个只有文字描述,补上最小实现:
```python
# ---- ① 多轮迭代检索: 检索 → 判据缺失 → 改写再检索, 直到判据齐或轮次用完 ----
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 决策何时检索 | 可多轮动态检索、自主改写查询 |
```mermaid
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 工具
```mermaid
graph LR
A[QueryEngine<br/>向量库检索逻辑] -->|@tool 包装| B[知识库检索工具]
B --> C[Agent 自己判断<br/>什么时候去检索]
C --> D[可多轮动态检索]
C --> E[可自主改写查询]
```
- 知识库封装成 Agent 工具(`@tool` 包装 QueryEngine),Agent 自己判断什么时候去检索。
- **检索工具设计要点**:查询改写 + 缓存去重 + 引用溯源三者齐备(对照 2.2 的代码)。
```python
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
```mermaid
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)和轮次上限能收敛,防止无限重试”。