ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI Agent知识获取管道:RAG检索增强生成从原理到TypeScript实战

AI Agent知识获取管道:RAG检索增强生成从原理到TypeScript实战 1. 为什么知识获取管道是 AI Agent 落地的第一道坎做 AI Agent 开发的人绕不开一个现实问题模型本身很聪明但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那套数据表结构。你问它一个关于内部流程的问题它要么一本正经地胡说八道要么礼貌地告诉你我无法访问这些信息。这就是知识获取管道要解决的核心矛盾——让 Agent 在推理时能够拿到外部知识并且拿得准、拿得快、拿得对。RAGRetrieval-Augmented Generation检索增强生成就是目前工程上最成熟、性价比最高的解法。它的基本思路不复杂用户提问时先从知识库里检索出相关内容把这些内容作为上下文塞进提示词再让大模型基于这些上下文生成回答。听起来简单但真正落地时会发现从文档解析、切块策略、向量化、检索排序到最终拼装提示词每一步都有大量细节决定成败。这一篇是走进 AI Agent系列的第四篇前面几篇聊了 Agent 的基本架构、工具调用和规划能力这一篇专门把知识获取管道拆开讲透。我会用 TypeScript 作为主要示例语言因为它在 Agent 开发中的生态越来越成熟类型系统对复杂数据流的约束也让人安心。无论你是刚接触 RAG 的新手还是已经搭过一版但效果不理想的开发者这篇内容都能帮你把基础打扎实。提示RAG 不是银弹。它解决的是知识时效性和私有知识注入问题不解决推理能力问题。如果你的 Agent 逻辑本身有问题加再多知识库也救不回来。2. RAG 管道的四个核心环节与数据流转2.1 从原始文档到可检索片段的完整链路一条完整的 RAG 管道数据要经过四个阶段加载Load→ 切块Chunk→ 向量化Embed→ 存储与检索Store Retrieve。每个阶段都有它的脾气。加载阶段要面对的是格式地狱。PDF、Word、Markdown、HTML、Excel、数据库导出、API 返回的 JSON每种格式的解析方式都不一样。PDF 尤其麻烦扫描件需要 OCR多栏排版容易串行表格结构经常丢失。我见过太多项目在 PDF 解析上翻车最后不得不人工整理成 Markdown 再入库。切块阶段决定了检索的粒度。切得太粗一个片段里混了好几个主题检索出来噪声大切得太细上下文断裂模型拿到半句话也理解不了。常见的策略是按固定 token 数切分配合重叠窗口overlap来保持语义连续性。但更好的做法是按语义边界切——按段落、按标题层级、按代码函数块。向量化阶段把文本变成高维向量。这里的关键选择是嵌入模型Embedding Model。不同模型对中文、英文、代码的语义捕捉能力差异很大而且向量维度、推理成本、是否支持本地部署都是考量因素。选错模型后面检索再优化也是事倍功半。存储与检索阶段涉及向量数据库的选型。小规模场景用内存索引就够大规模场景需要专门的向量库还要考虑是否支持混合检索向量关键词、是否支持元数据过滤、是否支持增量更新。2.2 用 TypeScript 定义管道的数据契约在动手写代码之前先把数据契约定清楚。TypeScript 的类型系统在这里能帮大忙它强迫你思考每个环节的输入输出到底是什么。// 原始文档 interface RawDocument { id: string; source: string; // 来源标识如文件路径或 URL content: string; // 原始文本内容 metadata: Recordstring, unknown; // 标题、作者、更新时间等 } // 切块后的片段 interface Chunk { id: string; documentId: string; content: string; index: number; // 在文档中的顺序 tokenCount: number; metadata: Recordstring, unknown; } // 向量化后的记录 interface EmbeddedChunk extends Chunk { embedding: number[]; // 向量表示 embeddingModel: string; } // 检索结果 interface RetrievalResult { chunk: EmbeddedChunk; score: number; // 相似度分数 rank: number; }这套类型定义看起来简单但它把整条管道的数据流转固定下来了。后面无论你换什么嵌入模型、换什么向量库只要遵守这个契约上层逻辑就不用大改。这是我在多个项目里验证过的做法——先定契约再选实现。2.3 一个最小可运行的 RAG 管道骨架下面是一个不依赖任何重型框架的最小实现用 TypeScript 写方便你理解每一步在干什么。实际项目中你可以用 LangChain、LlamaIndex 这类框架但先手写一遍能让你在出问题时知道该往哪里查。class SimpleRAGPipeline { private chunks: EmbeddedChunk[] []; // 第一步加载文档 async loadDocuments(docs: RawDocument[]): Promisevoid { for (const doc of docs) { const chunks this.chunkDocument(doc); const embedded await this.embedChunks(chunks); this.chunks.push(...embedded); } } // 第二步切块 private chunkDocument(doc: RawDocument, maxTokens 500, overlap 50): Chunk[] { const paragraphs doc.content.split(/\n\n/); const chunks: Chunk[] []; let buffer ; let index 0; for (const para of paragraphs) { if (this.estimateTokens(buffer para) maxTokens buffer) { chunks.push({ id: ${doc.id}-${index}, documentId: doc.id, content: buffer.trim(), index, tokenCount: this.estimateTokens(buffer), metadata: doc.metadata, }); // 保留重叠部分维持语义连续 buffer buffer.slice(-overlap * 4) para; index; } else { buffer \n\n para; } } if (buffer.trim()) { chunks.push({ id: ${doc.id}-${index}, documentId: doc.id, content: buffer.trim(), index, tokenCount: this.estimateTokens(buffer), metadata: doc.metadata, }); } return chunks; } // 粗略估算 token 数中文按 1.5 字符/token英文按 4 字符/token private estimateTokens(text: string): number { const chineseChars (text.match(/[\u4e00-\u9fa5]/g) || []).length; const otherChars text.length - chineseChars; return Math.ceil(chineseChars / 1.5 otherChars / 4); } // 第三步向量化这里用伪代码表示调用嵌入模型 private async embedChunks(chunks: Chunk[]): PromiseEmbeddedChunk[] { const texts chunks.map(c c.content); const embeddings await this.callEmbeddingAPI(texts); return chunks.map((chunk, i) ({ ...chunk, embedding: embeddings[i], embeddingModel: your-embedding-model, })); } // 第四步检索 async retrieve(query: string, topK 5): PromiseRetrievalResult[] { const queryEmbedding (await this.callEmbeddingAPI([query]))[0]; const scored this.chunks.map(chunk ({ chunk, score: this.cosineSimilarity(queryEmbedding, chunk.embedding), rank: 0, })); scored.sort((a, b) b.score - a.score); return scored.slice(0, topK).map((r, i) ({ ...r, rank: i 1 })); } private 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)); } private async callEmbeddingAPI(texts: string[]): Promisenumber[][] { // 实际调用嵌入模型 API 或本地模型 throw new Error(需要接入具体的嵌入模型实现); } }这段代码不到一百行但把 RAG 的核心逻辑都串起来了。你可以先跑通这个骨架再逐步替换每个环节的实现。先跑通再优化这是我做 RAG 项目一贯的顺序。3. 切块策略决定检索质量的关键变量3.1 固定长度切块的陷阱与适用边界固定长度切块是最简单的策略按 token 数或字符数切每块之间留一点重叠。它的优点是实现简单、块大小均匀、便于批量处理。但它的缺点也很明显——它完全无视文本的语义结构。想象一下一份产品需求文档里有一段讲用户登录流程下一段讲支付流程。固定长度切块很可能把两个主题各切一半拼成一个既不像登录也不像支付的片段。检索时用户问登录相关的问题这个片段因为包含登录关键词被召回但里面一半内容是支付模型读了反而困惑。固定长度切块适合什么场景适合那些结构松散、主题不明确的文本比如聊天记录、会议纪要、用户反馈。这些文本本身就没有清晰的段落边界强行按语义切反而切不好。另外当你的文档量极大、需要快速建立基线时固定长度切块也是一个合理的起点。注意重叠窗口不是越大越好。重叠太大存储和检索成本上升而且容易召回重复内容。一般建议重叠占块大小的 10% 到 20%。3.2 按语义结构切块的实操方法更好的做法是利用文档自身的结构信息。Markdown 有标题层级HTML 有标签代码有函数和类法律文书有条款编号。这些结构本身就是天然的切块边界。以 Markdown 为例可以按标题层级递归切分先按一级标题切如果某个一级标题下的内容还是太长再按二级标题切以此类推。这样切出来的每个块都有明确的主题归属检索时命中率会高很多。interface MarkdownSection { heading: string; level: number; content: string; children: MarkdownSection[]; } function parseMarkdownSections(md: string): MarkdownSection[] { const lines md.split(\n); const root: MarkdownSection[] []; const stack: MarkdownSection[] []; for (const line of lines) { const match line.match(/^(#{1,6})\s(.)$/); if (match) { const level match[1].length; const section: MarkdownSection { heading: match[2], level, content: , children: [], }; while (stack.length stack[stack.length - 1].level level) { stack.pop(); } if (stack.length) { stack[stack.length - 1].children.push(section); } else { root.push(section); } stack.push(section); } else if (stack.length) { stack[stack.length - 1].content line \n; } } return root; } function flattenSections( sections: MarkdownSection[], parentPath ): Array{ path: string; content: string } { const result: Array{ path: string; content: string } []; for (const section of sections) { const path parentPath ? ${parentPath} ${section.heading} : section.heading; if (section.content.trim()) { result.push({ path, content: section.content.trim() }); } result.push(...flattenSections(section.children, path)); } return result; }这样切出来的每个块都带着它的路径信息比如产品文档 用户模块 登录流程。这个路径在检索时可以作为元数据过滤条件也可以直接拼进块内容里让模型知道这段文字在文档中的位置。3.3 块大小与重叠量的参数调优经验块大小设多少合适这个问题没有标准答案但有一些经验值可以参考。对于通用问答场景300 到 800 token 是比较舒服的区间。太小了上下文不足太大了噪声多。对于代码文档块可以小一些因为代码的语义密度高。对于法律、医疗这类需要精确引用的领域块可以大一些保证引用的完整性。重叠量一般设在块大小的 10% 到 20%。但如果你是按语义结构切的重叠可以设得很小甚至为零因为语义边界本身就是连续的。我自己的调优方法是先准备一组测试问题每个问题都有标准答案所在的文档位置。然后调整块大小和重叠量看检索命中率的变化。这个过程不需要很精确跑几十个问题就能看出趋势。别凭感觉调参用数据说话。场景类型建议块大小建议重叠切块策略通用问答300-500 token50-80 token语义段落技术文档400-600 token60-100 token标题层级代码库200-400 token20-40 token函数/类法律合同600-1000 token100-150 token条款编号聊天记录200-300 token30-50 token固定长度4. 向量化与检索从语义匹配到混合排序4.1 嵌入模型选型的几个硬指标选嵌入模型不能只看排行榜。排行榜上的分数是在通用基准上测的你的业务数据可能完全不同。我一般看这几个指标语言支持。如果你的知识库以中文为主必须选中文语义捕捉能力强的模型。有些模型英文很强中文一塌糊涂向量空间里中文词的分布是乱的。维度与成本。向量维度越高表达能力越强但存储和计算成本也越高。768 维和 1536 维在实际效果上可能差不了多少但存储成本差一倍。如果知识库规模大这个差距很可观。是否支持本地部署。有些场景数据不能出内网必须用本地模型。本地模型的推理速度取决于你的硬件GPU 和 CPU 的差距可能是几十倍。最大输入长度。嵌入模型对输入长度有限制一般是 512 或 8192 token。如果你的块超过这个长度会被截断后面的内容就丢了。4.2 余弦相似度之外的检索增强手段纯向量检索有一个先天缺陷它对关键词不敏感。用户搜TypeScript 7.0 弃用 baseUrl向量检索可能召回一堆讲 TypeScript 配置的文章但就是找不到精确提到这个弃用警告的那一篇。因为向量检索捕捉的是语义相似不是字面匹配。解决办法是混合检索向量检索和关键词检索如 BM25各跑一遍然后把结果融合。融合算法常用 RRFReciprocal Rank Fusion它不依赖分数绝对值只看排名对不同检索器的分数尺度不敏感。function reciprocalRankFusion( resultLists: RetrievalResult[][], k 60 ): RetrievalResult[] { const scoreMap new Mapstring, { result: RetrievalResult; score: number }(); for (const results of resultLists) { results.forEach((result, index) { const id result.chunk.id; const rrfScore 1 / (k index 1); if (scoreMap.has(id)) { scoreMap.get(id)!.score rrfScore; } else { scoreMap.set(id, { result, score: rrfScore }); } }); } return Array.from(scoreMap.values()) .sort((a, b) b.score - a.score) .map((item, index) ({ ...item.result, rank: index 1 })); }RRF 的 k 值一般取 60这是原论文里的经验值。实际用下来混合检索比纯向量检索的命中率能提升 10% 到 20%尤其是在专有名词、代码标识符、产品型号这类查询上。4.3 重排序模型在管道中的位置检索出 topK 个结果后还有一个可选环节重排序Rerank。向量检索是双塔结构查询和文档分别编码速度快但精度有限。重排序模型是交叉编码器把查询和文档拼在一起过模型精度高但速度慢。所以典型做法是向量检索召回 top 50重排序精选 top 5。重排序模型对中文场景的提升尤其明显。我实测过一个中文技术文档问答场景不加重排序时 top 3 命中率大概 65%加了重排序后提升到 85% 左右。这个提升幅度值得多花的那点推理时间。提示重排序模型通常比嵌入模型大得多推理成本也高。如果 QPS 要求高可以考虑用较小的重排序模型或者只对 top 20 做重排序。5. 提示词拼装把检索结果变成模型能用的上下文5.1 上下文窗口的分配策略检索回来的片段不能一股脑全塞给模型。模型的上下文窗口有限而且塞得越多模型越容易迷失在中间。有研究表明模型对上下文开头和结尾的信息更敏感中间部分容易被忽略。我的做法是按相关性排序最相关的放最前面次相关的放最后面中间放补充信息。如果片段之间有明显的逻辑顺序比如步骤一、步骤二就按逻辑顺序排不要打乱。另外每个片段都要标注来源。这样模型在回答时可以引用来源用户也能验证信息的可靠性。来源标注格式可以是[来源文档名 章节]简洁明了。5.2 提示词模板的设计与防幻觉约束提示词模板的核心任务是告诉模型只根据提供的上下文回答上下文里没有的信息就说不知道。这句话听起来简单但模型经常不听话。你需要用更强的约束。function buildRAGPrompt(query: string, contexts: RetrievalResult[]): string { const contextText contexts .map((r, i) [片段 ${i 1}] 来源${r.chunk.metadata.source || 未知}\n${r.chunk.content}) .join(\n\n---\n\n); return 你是一个严谨的知识助手。请严格根据下面提供的参考片段回答用户问题。 规则 1. 只使用参考片段中的信息不要使用你自己的知识。 2. 如果参考片段中没有足够的信息回答问题直接说根据现有资料无法回答不要编造。 3. 回答时标注信息来源格式为 [片段 N]。 4. 如果多个片段信息冲突指出冲突并说明。 参考片段 ${contextText} 用户问题${query} 请回答; }这个模板的关键在于规则要具体、可执行。不要编造太模糊如果片段中没有就说无法回答才是可操作的指令。另外要求标注来源能进一步约束模型因为编造的内容很难对应到具体片段。5.3 多轮对话中的检索时机判断在 Agent 场景里用户不会只问一个问题。多轮对话中什么时候需要重新检索如果用户追问那第二点呢可能不需要重新检索直接用上一轮的上下文就行。如果用户问那另一个产品呢就需要重新检索。一个简单的判断规则是如果当前问题和上一轮问题的语义相似度高且没有引入新的实体就复用上下文否则重新检索。更复杂的做法是让模型自己判断但这会增加一次模型调用。我一般用规则判断就够了因为多轮对话中真正需要重新检索的比例并不高。把每次对话都重新检索一遍既浪费资源又可能引入不相关的噪声。6. 实测中的坑与效果评估方法6.1 检索命中率上不去的排查链路RAG 效果不好排查要按管道顺序来不要跳步。我的排查链路是这样的第一步看切块。把检索出来的片段打印出来人工读一遍。如果片段本身就不完整、语义断裂那问题在切块环节。调整块大小和切块策略重新入库。第二步看向量化。拿一个已知答案的问题把它的向量和标准答案片段的向量算相似度。如果相似度很低说明嵌入模型不适合你的数据。换模型或者对文本做预处理比如去掉无关的页眉页脚。第三步看检索。如果切块和向量化都没问题但检索结果里没有正确答案那可能是 topK 太小或者需要混合检索。先加大 topK 看看正确答案在不在里面如果在说明排序有问题加重排序如果不在说明召回有问题加关键词检索。第四步看提示词。如果检索结果里有正确答案但模型没用好那是提示词的问题。检查上下文是否太长、来源标注是否清晰、约束是否足够强。这个链路我走过很多遍大部分问题都出在切块和向量化环节而不是模型本身。6.2 构建小型评估集的最低成本方案没有评估集调优就是盲人摸象。但构建评估集不需要很复杂。最低成本的方案是从真实用户问题里挑 50 个人工标注每个问题的标准答案所在文档位置。然后写个脚本跑一遍检索看标准答案有没有被召回排在第几位。评估指标用Hit RateK和MRRMean Reciprocal Rank就够了。Hit Rate5 表示前 5 个结果里包含正确答案的比例MRR 反映正确答案的平均排名。这两个指标能覆盖大部分调优需求。interface EvalCase { query: string; expectedChunkIds: string[]; } function evaluateRetrieval( pipeline: SimpleRAGPipeline, cases: EvalCase[], k 5 ): { hitRate: number; mrr: number } { let hits 0; let reciprocalRankSum 0; for (const evalCase of cases) { const results pipeline.retrieve(evalCase.query, k); const rank results.findIndex(r evalCase.expectedChunkIds.includes(r.chunk.id) ); if (rank 0) { hits; reciprocalRankSum 1 / (rank 1); } } return { hitRate: hits / cases.length, mrr: reciprocalRankSum / cases.length, }; }50 个评估用例人工标注大概花半天时间。但这半天能让你后面所有的调优都有据可依非常值得。6.3 增量更新与知识时效性维护知识库不是建一次就完事了。文档会更新新文档会加入旧文档会过期。增量更新要解决两个问题怎么知道哪些文档变了以及怎么在不重建全量索引的情况下更新。第一个问题可以用文档的哈希值或修改时间来判断。每次同步时对比当前文档的哈希和上次入库时的哈希只处理变化的文档。第二个问题取决于向量库的能力有些向量库支持按 ID 删除和插入有些只支持全量重建。选型时要考虑这一点。注意删除旧文档的向量时要确保所有相关的块都被删掉。如果文档切成了 20 个块只删了 19 个剩下的那个会变成幽灵知识检索时冒出来但内容已经过期。7. 从基础 RAG 到 Agentic RAG 的演进方向基础 RAG 的管道是线性的检索一次生成一次。但真实场景往往需要多步检索。比如用户问对比 A 产品和 B 产品的定价策略Agent 可能需要先检索 A 产品的定价再检索 B 产品的定价然后对比。这就是 Agentic RAG 的思路——把检索作为 Agent 的一个工具让 Agent 自己决定什么时候检索、检索什么、检索几次。实现 Agentic RAG 的关键是把检索封装成一个工具函数然后让 Agent 的规划模块来决定调用时机。这比固定管道灵活得多但也更难控制。我的建议是先把基础 RAG 跑稳再考虑往 Agentic 方向演进。基础不牢Agentic 只会让问题更复杂。另一个方向是GraphRAG把知识图谱和向量检索结合起来。对于实体关系复杂的领域比如医疗、法律、金融GraphRAG 能捕捉到纯向量检索捕捉不到的关系信息。但它的构建成本高得多需要先抽实体、建关系、维护图谱。除非你的场景确实需要否则基础 RAG 加混合检索已经能覆盖大部分需求。TypeScript 生态在 RAG 方面的工具链还在快速完善中。目前我常用的组合是用 LangChain.js 做管道编排用本地的向量库做存储嵌入模型根据数据语言和部署要求选。这套组合不算最先进但足够稳定出了问题也容易排查。最后分享一个我在多个项目里验证过的经验RAG 的效果上限取决于知识库的质量而不是管道的复杂度。我见过太多团队在检索算法上反复折腾却忽略了知识库本身充斥着过时文档、重复内容和格式混乱的问题。先把知识库整理干净比换任何模型都管用。
返回列表