AI Agent 知识获取管道:RAG 检索增强生成实战与优化

发布时间:2026/9/29 11:54:25
AI Agent 知识获取管道:RAG 检索增强生成实战与优化 1. 为什么知识获取管道是 AI Agent 落地的第一道分水岭做 AI Agent 的人十有八九会在某个时刻撞上同一堵墙模型本身很聪明但你问它公司内部的报销标准、上周刚改的接口文档、某个客户的特殊约定它要么一本正经地胡说要么干脆说不知道。这不是模型不行而是它的知识边界被训练数据锁死了。知识获取管道要解决的就是把这个边界打开——让 Agent 在回答问题之前先去外部知识源里把相关材料捞回来再基于材料作答。这套机制的核心就是RAG检索增强生成。我在实际项目里见过太多团队模型选型纠结了两个月Prompt 调了几十版最后卡在答不准上回头一看问题根本不在模型而在检索这一环。检索没做好后面生成再强也是空中楼阁。所以我把 RAG 放在走进 AI Agent系列的第四篇因为它是从玩具 Demo迈向能用的产品的关键一跳。这篇文章适合谁看如果你已经能跑通一个基础的 Agent 对话循环但对怎么让 Agent 用上自己的知识还没有清晰路径那这篇就是给你写的。我会用TypeScript作为主要实现语言因为它在 Agent 工程化落地里越来越主流——类型系统能帮你在管道拼接时少踩很多坑。全文不堆概念重点讲清楚三件事RAG 的每个环节到底在干什么、为什么这么设计、以及我在实操中踩过的那些坑。先说一个反直觉的结论RAG 的瓶颈几乎从来不在生成而在检索。很多人第一次搭 RAG把 80% 的精力花在调 Prompt 和换模型上结果命中率死活上不去。真相是只要检索回来的内容是对的哪怕用中等能力的模型答案质量也能接受反过来检索回来的是一堆似是而非的片段再强的模型也只能垃圾进垃圾出。理解了这一点你就知道该把力气往哪儿使了。2. RAG 管道的四个环节拆开看每一步在做什么2.1 从文档到可检索单元的切分逻辑RAG 的第一步是把原始知识源PDF、Markdown、网页、数据库记录变成一段段可以被检索的文本块也就是Chunk。这一步看着简单实则决定了整个管道的上限。我见过最粗暴的做法是按固定字数硬切比如每 500 字一刀结果一句话被拦腰截断检索出来的片段语义残缺模型读得一头雾水。合理的切分要兼顾两个目标语义完整性和检索粒度。语义完整性指的是尽量在段落、标题、列表项这些自然边界处切检索粒度指的是块不能太大也不能太小——太大一块里混了好几个主题检索时噪声多太小上下文不足模型拼不出完整答案。我的经验值是中文技术文档单块控制在 300 到 800 字之间比较舒服同时保留 10% 到 20% 的重叠Overlap防止关键信息正好卡在切口上。重叠的意思是相邻两块共享一部分内容比如块 A 是 1 到 500 字块 B 就从 400 字开始这样跨边界的信息不会丢。用 TypeScript 写一个带重叠的递归切分器核心思路是先按标题层级切再按段落切最后才按字数兜底interface Chunk { id: string; text: string; metadata: { source: string; heading?: string; index: number }; } function splitByHeadings(text: string): string[] { // 按 Markdown 标题切分保留标题作为上下文 const sections text.split(/\n(?#{1,3}\s)/); return sections.filter((s) s.trim().length 0); } function splitWithOverlap(text: string, maxLen 600, overlap 100): string[] { if (text.length maxLen) return [text]; const chunks: string[] []; let start 0; while (start text.length) { const end Math.min(start maxLen, text.length); chunks.push(text.slice(start, end)); if (end text.length) break; start end - overlap; } return chunks; }这里有个细节值得说metadata 比文本本身还重要。每个 Chunk 都要带上来源文件、所属标题、在原文中的位置。为什么因为检索回来之后你需要给模型提供这段话出自哪里的线索模型才能判断可信度同时前端展示引用来源时也靠它。我早期偷懒没存 metadata后来做引用溯源时全部返工血的教训。2.2 向量化把语义变成可计算的坐标切好块之后要把每块文本转成向量Embedding也就是一串浮点数。这串数字的妙处在于语义相近的文本向量距离也近。这样检索就变成了找距离最近的几个向量数学上非常干净。选 Embedding 模型时别只盯着排行榜。我实际用下来要综合考虑三点语言支持中文场景必须选中文语料训练充分的、维度成本维度越高存储和计算越贵768 或 1024 维通常是性价比甜点、是否支持本地部署涉及敏感数据时这是硬要求。热词里提到的本地知识库场景基本都要求 Embedding 能在内网跑这时候开源模型就是首选。向量化本身没什么玄机但有个坑必须提醒查询和文档必须用同一个模型。我见过有人文档用 A 模型编码查询时手滑换成了 B 模型结果检索结果乱七八糟排查了半天才发现是模型不一致。向量空间都不一样距离计算毫无意义。async function embedBatch(texts: string[]): Promisenumber[][] { // 批量编码注意控制单次请求的文本数量避免超限 const BATCH_SIZE 32; const results: number[][] []; for (let i 0; i texts.length; i BATCH_SIZE) { const batch texts.slice(i, i BATCH_SIZE); const vectors await embeddingClient.encode(batch); results.push(...vectors); } return results; }批量处理时要注意单次请求的 token 上限。我一般把批大小设在 16 到 64 之间太大容易触发限流太小则吞吐上不去。这个值没有标准答案得根据你用的服务实测调整。2.3 向量库选型别一上来就上重型武器存向量需要一个向量数据库。市面上的选择从轻到重排一排内存数组、SQLite 扩展、FAISS、Milvus、Qdrant、Pinecone 等等。新手最容易犯的错是一上来就部署一套分布式向量库结果数据量才几千条纯属杀鸡用牛刀。我的建议是按数据规模分档数据规模推荐方案理由1 万条以内内存 余弦相似度零依赖启动快够用1 万到 100 万FAISS 或 SQLite 向量扩展单机性能强运维简单100 万以上Qdrant / Milvus支持分布式、过滤、持久化对于大多数内部知识库场景数据量其实就在几万条这个量级用 FAISS 单机跑完全没问题。等真的到了百万级再考虑上分布式别提前优化。用 TypeScript 做内存检索其实很简单核心就是算余弦相似度function cosineSimilarity(a: number[], b: number[]): number { let dot 0, normA 0, normB 0; for (let i 0; i a.length; i) { dot a[i] * b[i]; normA a[i] * a[i]; normB b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } function topK(query: number[], vectors: number[][], k 5) { return vectors .map((v, i) ({ index: i, score: cosineSimilarity(query, v) })) .sort((x, y) y.score - x.score) .slice(0, k); }这段代码在几千条数据下毫秒级返回完全够用。等数据涨到十万条以上再换成 FAISS 的 WASM 版本或者外部服务。2.4 检索与重排把差不多变成就是它最朴素的检索就是向量最近邻 Top-K但实际用起来你会发现Top-5 里经常混进一两条不相关的。这时候就需要重排Rerank。重排的思路是先用向量检索快速召回一批候选比如 20 条再用一个更精细的模型对候选逐条打分挑出真正相关的几条。为什么两阶段因为向量检索快但粗重排模型准但慢。先用快的把范围缩小再用准的精选是典型的工程折中。热词里提到的RAG 命中率hit rate很大程度上就靠重排来提升。除了重排还有两个提升命中率的实用手段混合检索和查询改写。混合检索是把向量检索和关键词检索BM25的结果融合因为有些查询靠关键词匹配更准比如查一个具体的错误码。查询改写则是让模型先把用户的口语化问题改写成更适合检索的形式比如把报销咋弄改写成差旅报销流程和标准。async function hybridRetrieve(query: string, k 5) { const [vectorHits, keywordHits] await Promise.all([ vectorSearch(query, k * 4), bm25Search(query, k * 4), ]); // 用 RRF倒数排名融合合并两路结果 const scores new Mapstring, number(); const RRF_K 60; vectorHits.forEach((hit, rank) { scores.set(hit.id, (scores.get(hit.id) ?? 0) 1 / (RRF_K rank)); }); keywordHits.forEach((hit, rank) { scores.set(hit.id, (scores.get(hit.id) ?? 0) 1 / (RRF_K rank)); }); return [...scores.entries()] .sort((a, b) b[1] - a[1]) .slice(0, k); }RRF 这个融合算法我很推荐它不需要调权重对两路结果的分数尺度不敏感实测下来比手动加权省心得多。3. 把管道接进 Agent检索结果怎么喂给模型3.1 上下文拼装不是塞得越多越好检索回来 5 条 Chunk怎么塞进 Prompt新手常见的做法是全塞进去觉得信息越多越好。但上下文窗口是有限资源塞太多无关内容反而会稀释关键信息让模型抓不住重点。而且 token 是要花钱的塞一堆废话纯属浪费。我的做法是按相关度排序取前 3 到 5 条每条前面标注来源让模型知道这是外部知识而非它自己的记忆。Prompt 模板大致长这样你是一个基于知识库回答问题的助手。请严格依据下面提供的资料作答 如果资料中没有相关信息直接说资料中未提及不要编造。 【资料 1】来源员工手册.pdf 差旅报销标准一线城市住宿每晚不超过 500 元…… 【资料 2】来源财务制度.md 报销需在出差结束后 15 个工作日内提交…… 用户问题出差去上海住宿能报多少这个模板里有两个关键约束严格依据资料和没有就说没有。前者防止模型自由发挥后者是抑制幻觉的关键。我实测下来加上资料中未提及这个兜底话术后编造答案的比例明显下降。3.2 引用溯源让答案可验证一个成熟的 RAG 系统答案里应该带上引用标记比如根据《员工手册》第 3 章……。这不仅是体验问题更是信任问题——用户能点开来源核对才敢把系统用在正经业务上。实现上就是在拼装上下文时给每条资料编号然后要求模型在答案里用[1][2]这样的标记引用。解析答案时把标记映射回原始来源即可。这一步不难但很多 Demo 都省了导致系统看起来能答实际没人敢用。3.3 多轮对话下的检索策略单轮问答好办多轮对话就麻烦了。用户第二句说那北京呢你拿那北京呢去检索啥也搜不到。这时候需要查询改写把当前问题和历史对话一起喂给模型让它改写出一个自包含的检索查询比如出差去北京的住宿报销标准是多少。async function rewriteQuery(history: Message[], current: string): Promisestring { if (history.length 0) return current; const prompt 根据以下对话历史把用户的最新问题改写成一个独立完整的检索查询 只输出改写后的查询不要解释。\n\n历史\n${history.map(m ${m.role}: ${m.content}).join(\n)}\n\n最新问题${current}; return await llm.complete(prompt); }这个改写步骤在多轮场景下几乎是必需的否则检索质量会断崖式下跌。代价是多一次模型调用延迟增加几百毫秒但换来的是命中率的大幅提升非常值。4. 实操中那些文档不会告诉你的坑4.1 切分粒度调优从能跑到好用的必经之路前面说了切分的重要性但具体切多大没有万能公式。我的做法是先跑一版基线再用真实问题测命中率然后针对性调整。具体操作准备 20 到 30 个真实用户问题人工标注每个问题的正确答案出自哪个文档的哪一段然后跑检索看 Top-5 里有没有命中。命中率低于 70% 就说明切分或检索有问题。调优时优先动这几个参数块大小、重叠比例、Top-K 数量。我遇到过一种情况块设成 1000 字时命中率只有 60%改成 400 字后跳到 85%——原因是原文里一段话讲了三个不同主题块太大导致检索时主题被平均掉了。这个教训是块大小要匹配你文档的主题密度主题密集的文档要切小主题松散的可以切大。4.2 元数据过滤被低估的检索利器纯向量检索有个盲区它只看语义相似不看该不该看。比如用户问2024 年的政策向量检索可能把 2022 年的相似条款也捞回来。这时候元数据过滤就派上用场了——在检索前先按年份、部门、文档类型过滤把范围缩小到该看的子集。实现上向量库基本都支持带过滤条件的检索。即使是内存方案也可以在算相似度前先筛一遍function filteredSearch(query: number[], chunks: Chunk[], vectors: number[][], filter: (c: Chunk) boolean, k 5) { const candidates chunks .map((c, i) ({ chunk: c, vector: vectors[i] })) .filter(({ chunk }) filter(chunk)); return candidates .map(({ chunk, vector }) ({ chunk, score: cosineSimilarity(query, vector) })) .sort((a, b) b.score - a.score) .slice(0, k); }元数据设计得好检索质量能上一个台阶。我在项目里给每个 Chunk 都打了source、heading、updatedAt、category四个字段过滤时灵活组合效果立竿见影。4.3 增量更新知识库不是一次性的知识库会变。文档会更新新文件会加进来。如果每次改动都全量重建向量数据量一大就受不了。正确做法是增量更新给每个 Chunk 一个稳定的 ID比如文件路径 块序号的哈希更新时只处理变化的文件删掉旧 Chunk、插入新 Chunk。这里有个容易忽略的点删除要彻底。我见过有人更新文档时只插入新块忘了删旧块结果同一个问题检索出新旧两个版本的答案模型直接精神分裂。所以更新逻辑必须是先删后插或者用文档 ID 做覆盖写。async function upsertDocument(docId: string, content: string) { const chunks splitDocument(content); const vectors await embedBatch(chunks.map(c c.text)); // 先删除该文档的所有旧块 await store.deleteByMetadata({ source: docId }); // 再插入新块 await store.insert(chunks.map((c, i) ({ ...c, vector: vectors[i] }))); }4.4 评估没有度量就没有优化最后说一个最容易被跳过、但最重要的环节评估。没有评估你根本不知道改动是变好了还是变坏了全靠感觉调参纯属瞎蒙。我的评估方案很朴素维护一个测试集每条包含问题 期望命中的文档片段每次改动后跑一遍看命中率和答案质量。命中率是客观指标答案质量可以人工打分或用一个强模型当裁判。这个测试集不用很大20 到 50 条就能反映趋势。关键是每次改动都跑形成闭环。热词里反复出现的RAG 瓶颈RAG 命中率本质上都是评估缺失导致的——你不知道瓶颈在哪自然无从优化。把评估做起来瓶颈会自己浮出水面。5. 从基础 RAG 到 Agentic RAG 的演进方向基础 RAG 跑通之后你会发现它有几个天然局限检索是一次性的不管问题多复杂都只查一轮检索策略是固定的不会根据问题类型调整。这就是Agentic RAG要解决的问题——让 Agent 自己决定要不要检索检索几次用什么策略检索。举个具体例子用户问对比一下 A 产品和 B 产品的退款政策。基础 RAG 会把这个当成一个查询去检索结果可能只捞到其中一个产品的政策。而 Agentic RAG 会先拆解成两个子查询分别检索再综合对比。这种检索规划能力是基础 RAG 给不了的。实现上Agentic RAG 通常把检索封装成一个工具Tool让 Agent 通过工具调用来触发检索并且可以多轮调用。这正好衔接了本系列前面讲的 Agent 工具调用机制。你可以先让 Agent 判断问题是否需要检索需要的话生成查询、调用检索工具、评估结果是否充分不充分就换个查询再检一次。const retrievalTool { name: search_knowledge, description: 在内部知识库中检索相关信息输入检索查询返回相关文档片段, parameters: { query: string }, execute: async ({ query }: { query: string }) { const hits await hybridRetrieve(query, 5); return hits.map(h 【${h.chunk.metadata.source}】${h.chunk.text}).join(\n\n); }, };把检索做成工具之后Agent 的自主性就上来了。它可以根据第一次检索的结果决定下一步而不是被动地接受一次检索的结果。这是从RAG 管道到RAG 智能体的关键转变也是当前这个方向最值得投入的地方。我个人在实际项目里的体会是先把基础 RAG 的每个环节做扎实再考虑 Agentic 化。基础没打好就上 Agentic只会把问题放大——检索本身就不准让 Agent 多检几轮也只是多捞几遍垃圾。顺序不能反。最后分享一个小技巧调试 RAG 时把每次检索的查询、召回的 Chunk、最终拼装的 Prompt 全部打日志。出问题时一眼就能看出是检索没召回、还是召回了但没用好。这个日志我在每个 RAG 项目里都会加省下的排查时间难以估量。