Chapter 14 — Search API + RAG:构建检索增强的 RAG Agent
1. 开篇段落
在 Agent 的能力谱系中,检索增强生成(RAG) 占据着特殊的地位。如果说 Tool Use 是 Agent 的“手”,那么 Search/RAG 就是 Agent 的“眼”和“外脑”。
与计算器或日历等确定性工具不同,搜索(Search)是一个高度不确定、高噪声且昂贵的 IO 过程。当你执行 search("Apple") 时,你可能得到水果,也可能得到科技公司;可能得到 404 页面,也可能得到 SEO 垃圾农场。此外,RAG 不仅仅是一次 API 调用,它通常是一个递归的知识获取回路(Knowledge Acquisition Loop):查询 → 阅读 → 发现缺口 → 再查询。
本章深入探讨如何将 RAG 流程中的各个环节——查询生成、并发检索、结果清洗、重排序(Rerank)、分块阅读、证据合成——建模为可组合的 Kleisli Arrows。我们将重点解决“搜索死循环”、“上下文污染”和“引用幻觉”等核心工程难题。
学习目标:
- 架构建模:将 RAG 拆解为
Query -> Search -> Rank -> Read -> Synthesize的 Pipeline。 - Effect 定义:定义 Search、Scrape、VectorDB 读写的 Algebra。
- 高级控制流:使用 Kleisli 组合实现 Multi-hop(多跳)检索和 Query Expansion(查询扩展)。
- 循环检测:基于语义向量的 Loop Detection,防止 Agent 在无效信息中空转。
- 可信约束:利用类型系统强制实现“无引用,不输出”。
2. 文字论述
2.1 Search/RAG Agent 的定位与架构
普通的 LLM 是封闭系统,RAG Agent 是开放系统。这种开放性带来了巨大的状态空间复杂度。我们需将 RAG 视为一个动态的反馈控制系统,而不是线性的脚本。
2.1.1 典型架构流(The RAG Pipeline)
我们可以将 RAG 流程看作是一系列数据转换的组合:
[ User Goal ]
|
v
+------------------+
| Planner/Reason | <---(1) 决定需要搜索什么
+------------------+
|
v
[ Query Generator ] ---(2) 生成多个查询变体 (Query Expansion)
|
v
+-------+-------+
| Parallel Exec | ---(3) 并发执行 IO: Google / Bing / VectorDB
+-------+-------+
|
(Raw Documents)
|
v
[ Scrubber/Filter ] ---(4) 清洗: 去重、去广告、黑名单过滤
|
v
[ Reranker ] ---(5) 排序: Cross-Encoder 打分 (IO/GPU Bound)
|
v
[ Chunk Selector ] ---(6) 裁剪: 选取 Top-K 片段以适应 Context Window
|
v
[ Synthesizer ] ---(7) 合成: 生成带引用的答案
在 IO Monad 的视角下,上述每一个方框都是一个函数 a -> m b,我们可以用 >=> (Kleisli compose) 将它们串联起来。
2.2 把 Search 当作 Effect (The Search Algebra)
首先,我们需要定义“搜索”在我们的系统里到底意味着什么。它不应耦合具体的供应商(Google/SerpApi),而应是一个抽象的代数接口。
2.2.1 类型定义
-- 基础类型
type Url = String
type Snippet = String
type Score = Float -- 0.0 to 1.0
-- 文档模型
data SearchResult = SearchResult {
url : Url,
title : String,
body : String, -- 可能是摘要,也可能是全文
meta : Map String String -- 发布时间、作者等
}
-- 核心能力 (Algebra)
class Monad m => SearchAlgebra m where
-- 基础搜索:可能超时,可能失败
search :: Query -> Int -> m (Either SearchError [SearchResult])
-- 获取详情:爬取网页全文
scrape :: Url -> m (Either ScrapeError String)
-- 向量检索:从私有知识库获取
vectorSearch :: Vector -> Int -> m [SearchResult]
2.2.2 为什么 search 是 m (Effect)?
- Latency:搜索通常需要 1s+,需要异步处理。
- Failure:网络波动、配额耗尽(429 Too Many Requests)。
- Non-determinism:搜索引擎的排名算法随时在变,不可复现(除非录制回放)。
2.3 RAG 的 IO 事件与异常建模
在 RAG 流程中,我们需要处理比普通 API 更复杂的“软故障”和“硬故障”。
- 硬故障 (Hard Failures):
Timeout:搜索引擎未在规定时间内响应。QuotaExceeded:API Key 没钱了。-
处理策略:使用
CircuitBreaker(熔断器) 和BackoffRetry(指数退避重试)。 -
软故障 (Soft Failures / Data Events):
EmptyResult:搜了,但没结果。这不是异常,而是有效的数据状态。SpamDetected:结果全是内容农场。StaleContent:所有结果都是 3 年前的,无法回答“昨天发生了什么”。- 处理策略:这些应建模为
LogEvent或控制流的分支条件,触发QueryRewrite。
2.4 Query 生成策略:Kleisli 的串联与分叉
用户的问题通常不能直接拿去搜索。
- 用户问:“特斯拉最近那个车怎么样?”
- 直接搜:“特斯拉最近那个车” -> 效果极差。
- 需要转换:“特斯拉 Cybertruck 测评 2024”、“Model 3 Highland 驾驶体验”。
这里涉及两种 Kleisli 模式:
-
Step-by-Step (Sequential):
analyzeUserIntent >=> generateKeywords >=> constructQuery -
Fan-out (Parallel): 针对复杂问题,我们可能需要生成多个视角的 Query,并行搜索。
// TypeScript 伪代码:并发搜索 Effect
const searchEffect = (userQ: string): IO<Doc[]> => {
return generateQueries(userQ) // IO<Query[]>
.flatMap(queries =>
// Parallel Traverse: 对每个 query 执行 search,然后合并结果
IO.parTraverse(queries, q => searchService.search(q))
)
.map(results => flatten(results));
}
2.5 结果处理:去重、清洗与重排序
这是 RAG Pipeline 中最容易被忽视但最重要的环节。垃圾进,垃圾出(Garbage In, Garbage Out)。
2.5.1 去重 (Deduplication)
搜索结果往往包含大量冗余(不同 URL 指向同一篇文章,或转载)。
- URL Normalization:移除
utm_source等参数。 - Content Hashing:计算 Snippet 的 MinHash 或 SimHash。
- 这通常是一个纯函数步骤:
List Doc -> List Doc。
2.5.2 重排序 (Reranking)
搜索引擎的 Top 10 是基于 SEO 的,RAG 需要的是基于“语义相关性”的。
Rerank 是一个 Effect,因为它可能调用昂贵的 BERT/Cross-Encoder 模型。
-- Rerank 接口
rerank :: Query -> [SearchResult] -> m [SearchResult]
Rule of Thumb:
- 搜索阶段追求 Recall (召回率):拿回 50-100 个文档。
- 排序阶段追求 Precision (精确率):筛选出 5-10 个最相关的给 LLM。
2.6 阅读与证据抽取 (Reading & Extraction)
LLM 的 Context Window 是昂贵的。我们不能把整个网页塞进去。 我们需要一个 Selector 策略:
- Sliding Window:将长文切片。
- Extraction:让一个小模型(或廉价模型)先读一遍:“这篇文章里有没有提到 X?如果有,提取那一段。”
这个过程可以建模为 filter 操作:
docs.traverse(doc => analyze(doc, query))
2.7 循环检测 (Loop Detection) 与 预算控制
这是 RAG Agent 最大的风险点:思维反刍(Rumination)。 Agent 可能会陷入:
- 搜索 "A" -> 没找到 -> 搜索 "A 的同义词" -> 没找到 -> 搜索 "A 的英文" ...
- 或者在两个相关概念间反复跳跃。
2.7.1 实现 DetectLoop Effect
我们需要维护一个语义历史记录。
interface HistoryEntry {
query: string;
embedding: Vector; // 预计算的向量
resultCount: number;
}
const detectLoop = (history: HistoryEntry[], newQuery: string): IO<Decision> => {
return embed(newQuery).map(newVec => {
// 检查语义相似度 > 0.9 的历史查询
const similar = history.filter(h => cosineSim(h.embedding, newVec) > 0.9);
if (similar.length > 2) return Decision.Abort("Stuck in semantic loop");
if (similar.length > 0 && similar[0].resultCount === 0)
return Decision.Refine("Previous similar query yielded nothing");
return Decision.Proceed;
})
}
2.7.2 Budget 驱动的终止
RAG 必须是有预算的。
MaxCalls: 最多搜索 10 次。TokenLimit: 最多读取 50k tokens。TimeLimit: 最多运行 30 秒。
一旦 Budget 耗尽,Pipeline 必须强制通过 Left 或特定状态跳转到 Synthesize 阶段:“基于目前已有的信息,我只能回答到这里...”。
2.8 可信与安全:引用 (Citations)
在 RAG 中,幻觉(Hallucination) 是头号敌人。 我们可以通过型系统来缓解这个问题。
要求 LLM 的输出必须符合结构:
data Claim = Claim {
statement :: String,
sourceId :: DocId, -- 必须指向 Context 中存在的 ID
quote :: String -- 必须是原文的精确子串
}
在 Runtime,我们可以编写一个 Verifier:
- 检查
sourceId是否在检索到的文档列表中。 - 检查
quote是否真的存在于该文档中(模糊匹配)。 - 检查
statement是否被quote逻辑蕴含(NLI 模型)。
如果检查失败,这不仅是一个 Log,而是一个反馈信号,可以让 Agent 自我修正(Self-Correction)。
2.9 与 FRP 的关系:流式透明化
RAG 过程很慢。用户需要知道发生了什么。 利用 FRP(Functional Reactive Programming),我们将 RAG 建模为事件流:
Stream<Event>:Thinking: "Analysing user query..."Action: "Searching Google for 'React 19 release date'..."Data: "Found 5 relevant pages."Action: "Reading 'React Blog'..."Progress: "Reading 3/5 docs..."Output: "React 19 was released on..."
这种模式下,UI 只是这个 Stream 的消费者。IO Monad 负责执行,FRP 负责将执行过程广播出去。
3. 本章小结
- RAG 是 IO 密集型管道:它由查询生成、搜索执行、清洗、重排序、阅读、合成六大环节组成。
- Ranking 是关键 Effect:不要只依赖搜索引擎的原始排序,必须在本地(或通过模型 API)进行语义重排序(Rerank)。
- 语义循环检测:传统的字符串匹配无法检测“换词搜索”的死循环,必须引入 Embedding 相似度检测。
- 引用即类型约束:通过强制要求输出包含原文引用(Quote)和来源 ID,并将验证逻辑纳入 Pipeline,可以大幅降低幻觉。
- 拥抱不确定性:搜索可能为空、超时或被污染。Agent 必须具备处理
Either Error EmptyResult的分支逻辑,而不是假设总能搜到答案。
4. 练习题
基础题
习题 1:实现基础 Search Algebra
题目:定义一个简单的 Search 接口,并实现一个 MockSearchInterpreter。该解释器包含一个预定义的内存数据库(Map Query [Doc])。当查询命中时返回文档,未命中时返回空列表,当查询为 "error" 时模拟网络异常。
Hint:
- 使用
Map<String, List<Document>>作为 Mock 数据源。 - 返回值类型应为
IO<Either<Error, List<Document>>>。
参考答案:
// TypeScript 示例
type Doc = { title: string; content: string };
type SearchResult = { kind: 'Success', docs: Doc[] } | { kind: 'Error', msg: string };
class MockSearch {
private db: Record<string, Doc[]>;
constructor(db: Record<string, Doc[]>) { this.db = db; }
search(query: string): Promise<SearchResult> {
return new Promise(resolve => {
// 模拟网络延迟
setTimeout(() => {
if (query === 'error') {
resolve({ kind: 'Error', msg: 'Network Timeout' });
} else {
resolve({ kind: 'Success', docs: this.db[query] || [] });
}
}, 100);
});
}
}
习题 2:并发搜索引擎 (Parallel IO)
题目:假设你有两个搜索源 searchGoogle(q) 和 searchBing(q)。请编写一个函数 searchUnified(q),它:
- 并发调用这两个搜索源。
- 如果有任意一个失败,忽略它,只返回另一个的结果。
- 如果都成功,合并结果并去重(根据 URL)。
- 如果都失败,返回错误。
Hint:使用 Promise.allSettled (JS) 或 race/parMap (FP)。需要处理 Partial Failure。
参考答案:
// 伪代码思路
const searchUnified = async (q: string) => {
// 1. 并发执行
const [res1, res2] = await Promise.allSettled([google.search(q), bing.search(q)]);
let allDocs: Doc[] = [];
// 2. 收集成功的结果
if (res1.status === 'fulfilled') allDocs.push(...res1.value);
if (res2.status === 'fulfilled') allDocs.push(...res2.value);
// 3. 判空 (如果两个都挂了,且没有结果)
if (allDocs.length === 0 && res1.status === 'rejected' && res2.status === 'rejected') {
throw new Error("All providers failed");
}
// 4. 去重 (Lodash uniqBy)
return _.uniqBy(allDocs, 'url');
}
习题 3:简单的重试策略 (Retry Effect)
题目:为搜索函数添加重试逻辑。要求实现 retryWithBackoff 高阶函数:
- 最多重试 3 次。
- 每次重试间隔指数增长 (1s, 2s, 4s)。
- 只对特定的错误类型(如 "Timeout", "503")重试,对 "400 Bad Request" 不重试。
Hint:这是一个递归函数,接收一个 IO 动作和一个 Policy。
参考答案:
const retry = async <T>(
fn: () => Promise<T>,
attempts: number,
delay: number
): Promise<T> => {
try {
return await fn();
} catch (error) {
if (attempts <= 1 || !isRetryable(error)) throw error;
await sleep(delay);
return retry(fn, attempts - 1, delay * 2);
}
}
挑战题
习题 4:实现 Multi-hop Reasoning 的 Kleisli 管道
题目:构建一个能够回答“Who is the CEO of the company that created ChatGPT?”的 Agent。 这需要两步推理:
- Query: "Who created ChatGPT?" -> Result: "OpenAI".
- Query: "Who is CEO of OpenAI?" -> Result: "Sam Altman".
请设计一个
loop函数,它接收UserQuery,执行搜索,让 LLM 判断是“得到了最终答案”还是“需要搜集更多信息(生成新 Query)”。如果是后者,递归调用。
Hint:
定义一个 Sum Type: Result = FinalAnswer String | NeedMoreInfo String.
函数签名为 step :: Context -> IO Result。
使用 MonadRec 或简单的递归。
参考答案:
-- 伪代码
data StepResult = Answer String | NewQuery String
step :: Context -> IO StepResult
step ctx = do
-- LLM 决定下一步
decision <- llmDecide ctx
case decision of
"SEARCH": q -> do
docs <- search q
let newCtx = appendDocs ctx docs
step newCtx -- 递归 (注意: 真实实现需要 Loop Detect 和 Max Depth)
"ANSWER": ans -> return (Answer ans)
runAgent :: String -> IO String
runAgent query = step (initCtx query)
习题 5:基于语义向量的 Loop Detection
题目:实现 2.7 节描述的 detectLoop。
你需要模拟一个 embed(text) 函数(返回 float 数组)。
如果在历史记录中发现最近 5 步内有 >0.95 相似度的 Query,或者连续 3 次搜索结果(URL 集合)重叠度 > 80%,则触发中断。
Hint:
Jaccard Similarity 用于集合比较。Cosine Similarity 用于向量比较。
状态 State History 需要在 IO 中传递。
参考答案: 核心逻辑:
- State:
List<{query: String, vec: Vector, urls: Set<Url>}> - Check 1 (Vector):
dotProduct(currentVec, oldVec) > 0.95 - Check 2 (Jaccard):
intersection(currUrls, oldUrls).size / union(...).size > 0.8 - Effect: 如果检测到,抛出
LoopDetectedError或者返回一个特殊的StopInstruction给 Planner。
习题 6:Citation Verifier (引用验证器)
题目:编写一个函数 verifyCitations(answer: String, sources: Doc[]) -> IO VerifiedAnswer。
该函数需要:
- 解析
answer中的引用标记[1],[2]。 - 提取引用上下文的一句话。
- 调用 LLM (Judge) 检查:原文
sources[1]是否支持该句话的断言。 - 如果不支持,移除该引用或标记为“未证实”。
Hint:这是一个 Map 操作,把 RawAnswer 映射为 VerifiedAnswer。这也是一种 RAG 的 "Post-processing" effect。
参考答案:
// 伪代码
const verify = async (ans: string, docs: Doc[]) => {
const claims = extractClaims(ans); // 解析出句子和引用号
const verifications = await Promise.all(claims.map(async c => {
const sourceText = docs[c.refId].text;
const isSupported = await llmJudge(c.statement, sourceText);
return { ...c, verified: isSupported };
}));
// 重组答案,去掉验证失败的引用
return reconstruct(ans, verifications);
}
5. 常见陷阱与错误 (Gotchas)
5.1 关键词震荡 (Keyword Oscillation)
- 现象:Agent 搜索 "Python tutorial" -> 觉得太浅 -> 搜索 "Python guide" -> 觉得太浅 -> 搜索 "Python tutorial"。
- 原因:Planner 没记住自己搜过什么,或者对“同义词”没有概念。
- 解决:必须使用 Embedding 来对比历史 Query,而不是字符串匹配。强制 Agent 在重试时“改变搜索策略”(如:从搜“是什么”改为搜“怎么做”)。
5.2 知识截止时间 (Knowledge Cutoff) 混淆
- 现象:模型内部训练数是 2023 年的,RAG 搜到了 2024 年的数据。模型可能会根据自己的内部知识“纠正”RAG 的正确结果(产生幻觉)。
- 解决:在 System Prompt 中强力催眠:“你对 X 一无所知,必须完全依赖提供的 Context 回答。” 或使用 Context-Only Decoding 技术。
5.3 错误的上下文拼接
- 现象:直接把 HTML 扔进 Prompt,导致 Token 浪费在
<div>,<nav>上;或者截断时切断了关键句子。 - 解决:
- 使用专门的 HTML-to-Text 工具(如
readability.js)。 - Overlapping Window:切分时保留 10% 的重叠,防止关键信息刚好在切分点上。
5.4 忽视“负反馈”
- 现象:搜索返回空结果,Agent 直接崩溃或瞎编。
- 解决:“没有结果”也是极其重要的信息。它应该触发 Agent 的反思:“我是不是搜错词了?还是这个问题本身就是错的?” 这需要在 Kleisli 链中处理
Either Empty Result的情况。