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。我们将重点解决“搜索死循环”、“上下文污染”和“引用幻觉”等核心工程难题。

学习目标

  1. 架构建模:将 RAG 拆解为 Query -> Search -> Rank -> Read -> Synthesize 的 Pipeline。
  2. Effect 定义:定义 Search、Scrape、VectorDB 读写的 Algebra。
  3. 高级控制流:使用 Kleisli 组合实现 Multi-hop(多跳)检索和 Query Expansion(查询扩展)。
  4. 循环检测:基于语义向量的 Loop Detection,防止 Agent 在无效信息中空转。
  5. 可信约束:利用类型系统强制实现“无引用,不输出”。

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 为什么 searchm (Effect)?

  1. Latency:搜索通常需要 1s+,需要异步处理。
  2. Failure:网络波动、配额耗尽(429 Too Many Requests)。
  3. 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 模式:

  1. Step-by-Step (Sequential): analyzeUserIntent >=> generateKeywords >=> constructQuery

  2. 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 策略:

  1. Sliding Window:将长文切片。
  2. 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

  1. 检查 sourceId 是否在检索到的文档列表中。
  2. 检查 quote 是否真的存在于该文档中(模糊匹配)。
  3. 检查 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),它:

  1. 并发调用这两个搜索源。
  2. 如果有任意一个失败,忽略它,只返回另一个的结果。
  3. 如果都成功,合并结果并去重(根据 URL)。
  4. 如果都失败,返回错误。

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。 这需要两步推理:

  1. Query: "Who created ChatGPT?" -> Result: "OpenAI".
  2. 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 中传递。

参考答案核心逻辑

  1. State: List<{query: String, vec: Vector, urls: Set<Url>}>
  2. Check 1 (Vector): dotProduct(currentVec, oldVec) > 0.95
  3. Check 2 (Jaccard): intersection(currUrls, oldUrls).size / union(...).size > 0.8
  4. Effect: 如果检测到,抛出 LoopDetectedError 或者返回一个特殊的 StopInstruction 给 Planner。
习题 6:Citation Verifier (引用验证器)

题目:编写一个函数 verifyCitations(answer: String, sources: Doc[]) -> IO VerifiedAnswer。 该函数需要:

  1. 解析 answer 中的引用标记 [1], [2]
  2. 提取引用上下文的一句话。
  3. 调用 LLM (Judge) 检查:原文 sources[1] 是否支持该句话的断言。
  4. 如果不支持,移除该引用或标记为“未证实”。

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 的情况。