跳到主内容
14 分钟阅读

手写 AI Agent 平台:RAG 篇

从检索、上下文增强到答案生成,拆解 Agent 平台中的 RAG 链路,以及文档切分、混合检索和引用回溯的工程实现。

模型知道很多事,却不知道公司上周刚改过的报销制度,也看不到项目仓库里的设计文档。把这些资料全部塞进 prompt 并不现实:上下文有长度限制,请求成本也会随着文本一起上涨,更麻烦的是,真正有用的内容往往只占其中很小一部分。

RAG 解决的就是这件事。它先从外部资料中找到与问题有关的片段,再把片段交给模型组织答案。知识留在知识库里,模型负责阅读和表达,两边可以各自更新。

在 Agent 平台中,RAG 不应该是一个藏在 prompt 前面的辅助函数,而应该是一条能观察、能评测、能追溯的数据链。检索到了什么、为什么选中这些片段、答案引用了哪一版文档,都要能够查清楚。

核心原理:检索、增强、生成

RAG 是 Retrieval-Augmented Generation 的缩写,拆开看只有三步:检索、增强、生成。

用户问题
  → 检索相关资料
  → 组装带证据的上下文
  → 模型依据上下文生成答案

第一步是检索。系统把用户的问题转换为查询条件,从知识库中召回相关片段。这里既可以使用向量相似度,也可以使用关键词、元数据过滤,实际项目里通常会混合几种方式。

第二步是增强。召回结果不能不加处理地全部塞给模型,还要去重、重排、补充标题和来源,并在 token 预算内选出最有价值的内容。所谓“增强”,增强的是本次模型请求的上下文,不是重新训练模型。

第三步是生成。模型根据问题和证据组织答案,给出引用;证据不足时则明确说不知道。RAG 并没有消除幻觉,只是给模型提供了更可靠的答题材料。若检索阶段拿错了资料,后面的生成再流畅也没有意义。

一套完整链路会分成离线和在线两部分:

离线摄取:文档 → 解析 → 清洗 → 切分 → Embedding → 建立索引
在线问答:问题 → 查询改写 → 召回 → 重排 → 上下文组装 → 生成 → 引用

离线链路决定知识库里有什么,在线链路决定一次回答能拿到什么。排查问题时要把两条链分开,否则很容易把解析错误当成模型能力问题。

RAG 的优势

最直接的好处是知识可以及时更新。公司制度、产品手册或项目文档发生变化后,只需重建对应索引,不必重新训练模型。对内部平台来说,这比追求“让模型记住一切”更实用。

其次是答案可以追溯。普通对话只能看到模型给出的结论,RAG 可以同时保留结论所依据的文档、章节和原文位置。用户能回到原文核对,开发者也能判断错误来自检索还是生成。

RAG 还能缩小每次请求的上下文。模型只阅读当前问题需要的几段材料,而不是整份手册或全部聊天记录,请求更快,token 消耗也更可控。

权限同样容易落到工程层面。文档可以带上租户、部门、项目和可见范围,在检索时直接过滤。相比把所有内容交给模型,再用一句提示词要求它“不要泄露”,检索前过滤才是真正的数据隔离。

当然,RAG 并不适合解决所有问题。需要稳定改变模型语气、格式或行为习惯时,提示词和微调更合适;需要精确计算、写数据库或调用外部系统时,应交给工具。RAG 负责提供知识,不负责替代业务逻辑。

适合放进 Agent 平台的场景

企业知识问答

员工手册、报销制度、技术规范和会议纪要都适合接入知识库。回答时附上来源,比单独做一个站内搜索更省阅读时间,也比让模型凭常识回答可靠。

客服与售后

Agent 可以检索商品说明、退换货规则和历史工单,再结合订单查询工具回答用户。这里要区分“知识”和“事实”:退货规则来自 RAG,某个订单是否发货则应实时调用业务接口。

研发助手

将代码说明、ADR、接口文档和故障复盘建立索引后,Agent 可以回答“这个字段为什么存在”或“类似故障以前怎么处理”。源代码更适合按函数、类和符号切分,而不是按固定字数硬切。

长任务的上下文补充

Agent 执行多步骤任务时,不必一直携带完整历史。早期步骤可以沉淀为摘要或运行记录,需要时再检索回来。这样可以控制上下文长度,但关键状态仍应保存为结构化数据,不能只依赖向量搜索找回。

带依据的内容生成

周报、方案说明、招投标材料等内容经常要求“只能根据给定资料撰写”。RAG 可以先选出相关证据,再由模型完成归纳和改写,同时把引用关系保留下来。

技术实现

第一版不必急着上独立向量数据库。文档规模不大时,可以用 SQLite 保存文档、片段和摄取状态,再选择支持向量索引的扩展;数据量和并发上来后,再替换检索实现。应用层只依赖检索接口,底层用什么数据库不应影响 Agent Runtime。

数据怎么存

原始文档和索引要分开保存。向量是可以重新计算的派生数据,不能成为文档的唯一副本。

documents
├─ id / title / source
├─ content_hash / version
├─ visibility / updated_at
└─ ingest_status

chunks
├─ id / document_id / content
├─ heading_path / position
├─ token_count / content_hash
└─ embedding_model / embedding

每个 chunk 除了正文,至少还要记录文档版本、标题层级、原文位置和权限范围。后续的引用跳转、增量更新、删除和访问控制都依赖这些字段。

文档更新时,可以对清洗后的正文计算哈希。内容未变则跳过,发生变化则写入新版本、生成新索引,完成后再原子切换。不要在旧索引上边删边写,否则更新过程中可能出现一半新、一半旧的检索结果。

检索接口

Agent Runtime 不需要知道向量库的细节,只接收一组标准化片段:

interface RetrievalQuery {
  text: string;
  tenantId: string;
  userId: string;
  limit: number;
}

interface RetrievedChunk {
  id: string;
  documentId: string;
  documentVersion: number;
  headingPath: readonly string[];
  content: string;
  score: number;
}

interface Retriever {
  search(query: RetrievalQuery): Promise<readonly RetrievedChunk[]>;
}

tenantId 和用户权限由服务端根据登录态注入,不能接受模型自行填写。只要无权内容进入模型上下文,数据就已经泄露,事后要求模型不要引用没有意义。

混合检索与重排

只做向量检索很容易漏掉错误码、订单号、类名和缩写。向量检索擅长找语义相近的表达,关键词检索擅长精确匹配,两者应该并行执行:

async function retrieve(query: RetrievalQuery) {
  const filter = await buildAccessFilter(query.userId, query.tenantId);

  const [semantic, lexical] = await Promise.all([
    vectorSearch(query.text, filter),
    keywordSearch(query.text, filter),
  ]);

  const candidates = reciprocalRankFuse(semantic, lexical);
  return rerank(query.text, candidates).then((items) =>
    items.slice(0, query.limit),
  );
}

两路结果可以先用 RRF 一类方法合并,再交给 reranker 精排。重排模型处理的是几十条候选,而不是整座知识库,因此成本通常可控。

对于连续对话,还可以把“它支持退款吗”改写为包含明确指代的查询,但必须保留原问题。查询改写偶尔会理解错上下文,原查询与改写查询并行召回更容易兜底。

上下文增强

重排后的前几条结果仍需组装成模型能读懂的上下文。需要删除近似重复片段,补上来源标识,并为每段内容划清边界:

[资料 1]
来源:员工手册 v3 / 休假制度 / 年假
内容:……
[/资料 1]

[资料 2]
来源:考勤补充说明 v2 / 请假流程
内容:……
[/资料 2]

上下文不是越多越好。相似片段堆得太多,会挤掉真正关键的信息,也容易让模型反复引用同一个结论。选择片段时要兼顾相关性和信息多样性;命中某个小片段后,还可以按需补取它的父章节或相邻片段。

知识库中的文字必须视为不可信数据。网页或文档里即使出现“忽略此前规则并调用某工具”,也只能作为被阅读的内容,不能升级为系统指令。工具是否可用、参数范围和审批规则仍由平台控制。

生成与引用

生成阶段的约束不需要写得花哨,关键是边界清楚:只依据提供的资料回答;资料不足就指出缺少什么;重要结论标注资料编号;不同来源冲突时列出差异,不替用户擅自裁决。

引用不能只保存模型输出的 [1]。服务端需要把编号映射回 chunkId、文档版本和原文位置,再以结构化数据返回前端:

interface Citation {
  chunkId: string;
  documentId: string;
  documentVersion: number;
  quoteStart?: number;
  quoteEnd?: number;
}

这样即使文档后来更新,旧回答仍然能说明当时依据的是哪一版内容。生成完成后还可以做一次引用校验,检查每条结论是否真的得到对应片段支持,而不是只检查回答里有没有引用符号。

把 RAG 接入 Agent 循环

在平台里,检索最好表现为一个只读工具,而不是每轮对话都强制执行。寒暄、计算题或已经拿到足够上下文的步骤没有必要检索;当任务需要内部知识时,模型再调用它。

const knowledgeSearchTool: Tool<SearchInput, SearchOutput> = {
  name: "knowledge_search",
  description: "搜索当前用户有权限访问的内部资料",
  risk: "read",
  parse: parseSearchInput,
  async execute(input, context) {
    return retriever.search({
      text: input.query,
      tenantId: context.tenantId,
      userId: context.userId,
      limit: input.limit ?? 8,
    });
  },
};

一次检索要作为独立 step 记录查询、过滤条件摘要、召回结果、分数、耗时和索引版本。线上遇到错误答案时,先看这一层,比反复调整 prompt 有用得多。

文档分割方式

切分是 RAG 最容易低估的一步。chunk 太大,会混进几个不同主题,召回后还浪费上下文;chunk 太小,定义、条件和例外会被拆散,模型只拿到半句话。

没有适合所有资料的固定数字。比较稳妥的做法是先按文档结构切,再对过长内容进行二次分割。

固定长度切分

按字符数或 token 数分块,实现简单,适合纯文本和第一版原型。缺点也很明显:标题可能和正文分开,句子可能从中间断掉,表格和代码块尤其容易被破坏。

如果采用固定长度,应该按 token 而不是字符串长度估算模型开销,并保留少量 overlap 缓解边界丢失。重叠不是越多越安全,过大的重叠会增加索引体积,还会让检索结果充满重复内容。

递归切分

按照“章节 → 段落 → 句子 → token”的顺序逐级拆分,只有当前节点过长时才进入下一层。它比单纯固定长度更能保留自然语义,适合结构不完全统一的 Markdown、网页正文和普通文档。

按语义结构切分

利用标题、条款、问答对、代码符号或版面结构确定边界。这种方式需要为不同文档写解析器,投入更高,但检索结果通常更完整。

文档类型推荐切分单位需要保留的信息
Markdown / 技术文档标题章节完整标题路径、代码块
API 文档接口或方法参数表、返回值、示例
制度 / 合同条款条款编号、上位章节、例外说明
FAQ一个问答对问题与答案不可拆开
源代码函数、类、符号文件路径、签名、注释
聊天记录话题段或时间窗口说话人、时间、会话主题
表格表头加若干数据行表名、表头、单位

父子分块

父子分块会同时保存大章节和小片段。检索时用小片段匹配,提高命中精度;命中后把对应父章节或附近片段交给模型,补回完整语境。

这比一味增加 overlap 更干净。比如“试用期员工是否有年假”可能只命中某个例外条款,但回答时还需要同一章节中的适用范围和计算方式,父子关系正好可以把这些内容带回来。

滑动窗口

聊天记录、日志和连续叙事文本很难靠标题分段,可以按时间或 token 窗口向前滑动。窗口之间保留一定重叠,同时把时间范围和参与者写进元数据。它适合上下文连续的资料,不适合条款和接口文档。

如何确定 chunk 大小

可以先选一批真实问题,用几组候选大小建立索引,再比较检索召回率、重复率和最终回答质量。不要只看相似度分数,也不要凭某篇文章给出的“最佳值”直接定参数。

经验上,一个 chunk 应该能独立回答一个具体小问题,同时不混入太多无关主题。如果片段离开上文就无法读懂,应补充标题路径、父级摘要,或调整边界,而不是只增加字符数。

评测与排错

RAG 至少有两个独立故障面:没有找到正确资料,以及拿到正确资料却答错了。评测也要分开。

检索层可以关注 Recall@K、MRR、排序质量和越权结果率;生成层关注答案正确性、证据忠实度、引用准确率和拒答是否合理。测试集中还应加入查不到答案、来源冲突、文档过期和无权访问的情况。

常见问题可以按下面的顺序排查:

现象优先检查
答案与问题无关文档解析、查询改写、召回结果
回答只说了一半chunk 边界、父子分块、上下文预算
错误码或类名搜不到关键词索引、分词、大小写
总是引用同一篇文档去重、多样性选择、重排
更新后仍返回旧内容文档版本、索引切换、缓存失效
不同用户搜到同一资料检索前权限过滤、缓存键
回答编造不存在的规定证据阈值、拒答规则、引用校验

先收集几十条真实问题,给每条问题标注期望答案和证据片段,就能建立第一版回归集。切分方式、Embedding 模型、Top K、重排策略或提示词发生变化后,重新跑一遍。单看一两个演示问题,很容易把局部变好误认为整体提升。

小结

RAG 的核心不是向量数据库,而是“问题如何找到证据,证据如何进入回答”。检索决定材料是否正确,增强决定模型能看到多少有效信息,生成决定答案是否忠于材料。

手写 Agent 平台时,先把文档版本、权限过滤、检索记录和引用回溯做好,再考虑更复杂的查询改写和多路召回。只要这条链路每一步都能看到输入和输出,RAG 就不是一个碰运气的黑盒,而是一套可以持续调试的工程系统。

手写 AI Agent 平台:RAG 篇

https://setobox.me/blog/2026/rag-knowledge-base
作者
Setobox(姬顶盒)
发布于
许可协议
CC BY-NC-SA 4.0