AI Agent 知识获取管道:RAG 检索增强生成实战与避坑指南
1. 为什么知识获取管道是 AI Agent 落地的第一道坎做 AI Agent 开发的人绕不开一个尴尬的现实模型本身很聪明但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那套数据长什么样。你问它一个关于内部系统的问题它要么一本正经地胡说八道要么直接告诉你“我无法访问该信息”。这不是模型不行而是它缺了一条把外部知识喂给它的管道。这条管道就是RAGRetrieval-Augmented Generation检索增强生成。名字听起来挺唬人拆开看其实很朴素检索 增强 生成。先从知识库里把相关内容捞出来再把捞出来的内容塞进提示词里最后让模型基于这些内容生成回答。就这么三步但它解决了大模型落地中最核心的一个矛盾——通用能力与私有知识之间的鸿沟。我见过太多团队在搭建 AI Agent 时一上来就急着调工具、接 API、写 Agent 循环结果跑起来发现 Agent 回答质量惨不忍睹。排查半天问题不在 Agent 逻辑而在知识获取这一层根本没搭好。Agent 拿到的上下文是错的、过时的、不完整的后面再怎么优化推理链路都是白搭。所以我把知识获取管道放在整个 AI Agent 系列的第四篇来讲不是因为它简单而是因为它太重要了重要到值得你花大力气把它做扎实。这篇文章面向的是已经了解 AI Agent 基本概念、准备动手搭建知识库的开发者。不管你用的是 TypeScript 还是 Java不管你选 LangChain 还是自己手写RAG 的核心原理和工程要点是相通的。我会从整体设计思路讲起然后拆解每个环节的实操细节最后把我踩过的坑和排查经验整理出来。目标是让你读完能直接动手搭一套可用的 RAG 管道而不是停留在“我知道 RAG 是什么”的层面。2. RAG 管道的整体设计与核心思路拆解2.1 一条完整的知识获取管道长什么样很多人对 RAG 的理解停留在“把文档切块、存进向量库、查询时做相似度匹配”这个层面。这个理解不算错但太粗糙了。一条真正能在生产环境跑起来的 RAG 管道至少包含以下几个环节文档加载从各种来源本地文件、数据库、API、网页把原始知识读进来文档解析把 PDF、Word、HTML 等格式转成纯文本保留结构信息文本分块把长文档切成适合检索和放入上下文的小块向量化用 Embedding 模型把文本块转成向量向量存储把向量和原文存进向量数据库查询处理对用户输入做改写、扩展、意图识别检索召回从向量库中找出最相关的文本块重排序对召回结果做精排提升相关性上下文组装把检索结果和用户问题拼成最终提示词生成回答调用大模型生成最终答案这十个环节每一个都有坑。有人觉得分块随便切切就行结果检索出来的内容支离破碎有人觉得向量化用默认模型就行结果中文语义匹配一塌糊涂有人觉得检索 Top-K 设大一点总没错结果上下文塞满了无关内容模型反而被干扰。2.2 为什么选择 RAG 而不是微调这是我在技术选型阶段被问得最多的问题。答案其实不复杂RAG 解决的是“知识”问题微调解决的是“能力”问题。如果你需要模型掌握新的知识事实用 RAG如果你需要模型学会新的输出格式、新的推理风格用微调。两者不是互斥的但在知识获取这个场景下RAG 有几个微调无法比拟的优势。第一知识更新成本极低。业务文档改了你只需要重新索引那部分文档不需要重新训练模型。微调一次动辄几小时到几天RAG 更新一次可能就几秒钟。第二可溯源。RAG 检索出来的内容是有出处的你可以告诉用户“这个答案来自某某文档第几页”。微调后的模型你根本不知道它为什么这么回答。第三成本可控。微调需要 GPU 资源RAG 主要消耗的是 Embedding 和向量存储的成本量级差很多。第四避免灾难性遗忘。微调容易让模型在学会新知识的同时忘掉旧能力RAG 不存在这个问题。当然 RAG 也有它的局限检索质量高度依赖分块策略和 Embedding 模型上下文窗口有限导致不能塞太多内容多跳推理能力弱于微调模型。但对于绝大多数企业知识库场景RAG 是性价比最高的选择。2.3 TypeScript 技术栈下的 RAG 选型考量既然热搜词里出现了 TypeScript我就专门聊聊 TS 生态下的 RAG 选型。很多做 AI Agent 的团队后端是 Node.js 或 Bun前端是 React整个技术栈都是 TypeScript这时候引入 Python 的 LangChain 就很别扭——跨语言调用、部署复杂度、团队技能栈不匹配都是问题。TypeScript 生态下目前比较成熟的 RAG 方案有几类方案类型代表工具适用场景注意事项全栈框架LangChain.js快速原型、功能全面抽象层较厚调试困难轻量库LlamaIndex.TS专注检索场景文档相对少向量库客户端Chroma、Qdrant、Pinecone 的 TS SDK已有自研管道需要自己组装流程自研直接调 Embedding API 向量库追求可控性工作量大但最灵活我的建议是原型阶段用 LangChain.js 快速验证生产阶段逐步替换成自研管道。LangChain 的抽象层在调试时非常痛苦你很难知道它内部到底做了什么。当你需要精细控制分块策略、检索参数、重排序逻辑时自研反而更省时间。另外提醒一句TypeScript 7.0 中baseUrl和moduleResolutionnode10这些选项已经弃用如果你在搭建项目时看到相关警告别忽略尽早迁移到bundler或node16解析模式否则后续升级会很痛苦。3. 核心环节的实操要点与避坑指南3.1 文档解析别小看这一步它决定了后续所有环节的上限文档解析是整条管道最容易被低估的环节。很多人觉得“不就是把 PDF 转成文本吗”结果转出来的文本全是乱码、表格错位、段落粘连后面分块和检索全崩。我处理过的文档类型里难度从低到高大致是纯文本 Markdown HTML Word PDF 扫描件。PDF 是重灾区尤其是那种多栏排版、带表格、带公式的学术论文或产品手册。实操建议纯文本和 Markdown直接读但要注意编码问题统一转成 UTF-8HTML用cheerio或jsdom提取正文去掉导航、广告、页脚Word用mammoth转 Markdown保留标题层级PDF优先用pdf-parse或pdfjs-dist如果排版复杂考虑用云服务做 OCR 和版面分析扫描件必须走 OCRTesseract 或者云服务都行但准确率要实测注意PDF 解析出来的文本经常带有页眉页脚、页码、水印这些噪声会严重干扰检索。我一般会在解析后加一步清洗用正则去掉重复出现的页眉页脚模式。还有一个细节保留文档的元数据。文件名、标题、章节、页码、更新时间这些信息在检索时可以作为过滤条件也能在生成回答时作为引用来源。很多人只存文本内容后面想加过滤功能时发现元数据全丢了只能重新索引。3.2 文本分块切得好不好直接决定检索准不准分块是 RAG 管道里最需要反复调优的环节。切太大检索出来的内容包含太多无关信息干扰模型切太小语义不完整检索出来的片段读不懂。常见的分块策略有几种固定长度分块按字符数或 token 数切简单粗暴。比如每 500 个 token 一块重叠 50 个 token。优点是实现简单缺点是经常在句子中间切断语义不完整。递归字符分块按分隔符优先级递归切分先按段落切段落太长再按句子切句子太长再按字符切。这是 LangChain 的默认策略效果比固定长度好很多。语义分块用 Embedding 计算相邻句子的相似度相似度低的地方作为切分点。效果最好但计算成本高。结构化分块按文档本身的结构切比如 Markdown 的标题层级、代码的函数定义。这是我最推荐的方式前提是你的文档有清晰的结构。我的实操经验是优先用结构化分块退而求其次用递归字符分块固定长度分块只在万不得已时用。分块大小没有万能值一般 300 到 800 token 之间比较合适具体要看你的文档类型和查询粒度。技术文档可以小一点叙述性内容可以大一点。重叠窗口也很关键。我一般设置 10% 到 20% 的重叠防止关键信息刚好被切在边界上。但重叠太多会导致检索结果重复浪费上下文窗口。3.3 向量化Embedding 模型选不对后面全白费Embedding 模型的选择直接决定了检索的语义匹配能力。选型时主要看几个维度语言支持中文场景必须选中文优化过的模型直接用 OpenAI 的 text-embedding-ada-002 处理中文效果会打折扣维度维度越高表达能力越强但存储和计算成本也越高。常见的有 768、1024、1536、3072 维最大输入长度决定了你的分块上限一般是 512 或 8192 token成本API 调用按 token 计费自部署则看 GPU 资源是否支持本地部署数据敏感场景必须本地部署中文场景下我实测过几个方案BGE 系列BAAI 出品在中文语义匹配上表现很好支持本地部署M3E 系列也不错如果预算充足可以用云服务的中文 Embedding API。注意Embedding 模型一旦选定索引和查询必须用同一个模型。中途换模型意味着所有向量都要重新计算这个成本很高。所以选型时要考虑长期可用性。还有一个容易忽略的点查询和文档要用相同的 Embedding 方式。有些模型对查询和文档有不同的前缀要求比如查询前加 query:文档前加 passage:。不按规范来检索效果会明显下降。3.4 向量存储选对数据库省一半运维精力向量数据库的选择要看你的数据规模、部署环境和预算。小规模场景几万条以内用内存向量库就够了比如hnswlib-node或者 Chroma 的本地模式。中等规模百万级可以考虑 Qdrant、Weaviate、Milvus。大规模千万级以上一般用云服务比如 Pinecone、Zilliz Cloud。TypeScript 生态下我比较推荐 Qdrant它的 TS SDK 写得很干净本地部署也简单Docker 一条命令就能跑起来。Chroma 也不错但它的 TS 客户端功能比 Python 版少一些。存储时除了向量本身还要存原文、元数据、以及一个唯一 ID。元数据的设计要考虑后续的过滤需求比如按文档类型、按时间范围、按权限过滤。3.5 检索策略Top-K 不是越大越好检索环节最常见的误区就是“Top-K 设大一点总能捞到相关内容”。实际上 Top-K 太大有两个问题一是上下文窗口被无关内容占满模型注意力被分散二是噪声增加模型可能被错误信息误导。我的经验是先用向量检索召回 20 到 50 条候选再用重排序模型精排到 3 到 5 条。这样既保证了召回率又保证了精度。重排序模型Reranker是提升检索质量的关键武器。它和 Embedding 模型的区别在于Embedding 是双塔结构查询和文档分别编码后算相似度速度快但精度有限Reranker 是交叉编码查询和文档一起输入模型打分速度慢但精度高。所以典型流程是 Embedding 粗排 Reranker 精排。常见的 Reranker 有 BGE-Reranker 系列、Cohere Rerank 等。中文场景下 BGE-Reranker 表现不错支持本地部署。另外混合检索也值得一试。纯向量检索对关键词匹配不敏感比如用户搜一个产品型号 XYZ-2000向量检索可能召回一堆语义相似但型号不对的内容。这时候结合 BM25 关键词检索用加权融合的方式合并结果效果会好很多。3.6 上下文组装怎么把检索结果喂给模型检索出来的内容不能直接一股脑塞给模型需要做组装。组装时要考虑几个问题顺序相关度高的放前面还是后面研究表明模型对上下文开头和结尾的内容注意力更强中间部分容易被忽略。所以把最相关的放开头次相关的放结尾。格式给每个片段加上来源标记比如[文档1]、[文档2]方便模型引用也方便你后续做溯源。长度控制总 token 数不能超过模型的上下文窗口还要给系统提示词和用户问题留空间。一般检索内容控制在上下文窗口的 50% 到 70%。去重如果多个片段内容高度重叠要去重避免浪费窗口。一个典型的组装模板长这样你是一个知识助手请基于以下参考资料回答用户问题。 如果参考资料中没有相关信息请明确告知用户你不知道。 参考资料 [文档1] {内容} [文档2] {内容} [文档3] {内容} 用户问题{question} 请给出准确、简洁的回答并标注引用的文档编号。4. 从零搭建一套可用的 RAG 管道4.1 环境准备与依赖安装假设你用 TypeScript 做开发Node.js 20 以上版本。先初始化项目mkdir rag-pipeline cd rag-pipeline npm init -y npm install typescript tsx types/node npx tsc --inittsconfig.json里注意把moduleResolution设成bundler或node16别用已经弃用的node10。target设成ES2022以上。然后安装核心依赖npm install qdrant/js-client-rest openai cheerio mammoth pdf-parse npm install -D types/pdf-parse这里用 OpenAI 的 Embedding API 做演示实际生产可以换成 BGE 的本地服务。Qdrant 用 Docker 起一个docker run -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant4.2 文档加载与解析实现先写一个通用的文档加载器根据文件扩展名选择解析方式import fs from fs/promises; import path from path; import mammoth from mammoth; import pdfParse from pdf-parse; import * as cheerio from cheerio; interface RawDocument { content: string; metadata: { source: string; title: string; type: string; updatedAt: string; }; } async function loadDocument(filePath: string): PromiseRawDocument { const ext path.extname(filePath).toLowerCase(); const fileName path.basename(filePath); let content ; if (ext .txt || ext .md) { content await fs.readFile(filePath, utf-8); } else if (ext .html || ext .htm) { const html await fs.readFile(filePath, utf-8); const $ cheerio.load(html); $(script, style, nav, footer, header).remove(); content $(body).text(); } else if (ext .docx) { const result await mammoth.extractRawText({ path: filePath }); content result.value; } else if (ext .pdf) { const buffer await fs.readFile(filePath); const result await pdfParse(buffer); content result.text; } else { throw new Error(Unsupported file type: ${ext}); } content cleanText(content); return { content, metadata: { source: filePath, title: fileName, type: ext.slice(1), updatedAt: new Date().toISOString(), }, }; } function cleanText(text: string): string { return text .replace(/\r\n/g, \n) .replace(/\n{3,}/g, \n\n) .replace(/[ \t]{2,}/g, ) .trim(); }这段代码的关键在于cleanText函数。PDF 解析出来的文本经常有多余空行和空格不清洗的话分块效果会很差。4.3 文本分块的具体实现我推荐用递归字符分块实现一个简化版interface Chunk { content: string; metadata: Recordstring, any; } function splitText( text: string, chunkSize: number 500, overlap: number 50 ): string[] { const separators [\n\n, \n, 。, , , ., !, ?, , ]; const chunks: string[] []; function split(text: string, separatorIndex: number): void { if (text.length chunkSize) { if (text.trim()) chunks.push(text.trim()); return; } if (separatorIndex separators.length) { for (let i 0; i text.length; i chunkSize - overlap) { chunks.push(text.slice(i, i chunkSize).trim()); } return; } const separator separators[separatorIndex]; const parts text.split(separator); let current ; for (const part of parts) { const candidate current ? current separator part : part; if (candidate.length chunkSize) { current candidate; } else { if (current) chunks.push(current.trim()); if (part.length chunkSize) { split(part, separatorIndex 1); current ; } else { current part; } } } if (current.trim()) chunks.push(current.trim()); } split(text, 0); return chunks; }这个实现优先按段落切段落太长按句子切句子太长按字符切。中文场景下我把中文标点也加进了分隔符列表效果比只用换行符好很多。分块大小我一般设 500 字符重叠 50 字符。如果你的文档是技术手册可以设小一点到 300如果是叙述性内容可以设大到 800。4.4 向量化与入库接下来把分块后的内容向量化并存入 Qdrantimport OpenAI from openai; import { QdrantClient } from qdrant/js-client-rest; const openai new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const qdrant new QdrantClient({ url: http://localhost:6333 }); const COLLECTION_NAME knowledge_base; const VECTOR_SIZE 1536; async function ensureCollection(): Promisevoid { const collections await qdrant.getCollections(); const exists collections.collections.some(c c.name COLLECTION_NAME); if (!exists) { await qdrant.createCollection(COLLECTION_NAME, { vectors: { size: VECTOR_SIZE, distance: Cosine }, }); } } async function embedTexts(texts: string[]): Promisenumber[][] { const response await openai.embeddings.create({ model: text-embedding-3-small, input: texts, }); return response.data.map(d d.embedding); } async function indexChunks(chunks: Chunk[]): Promisevoid { const batchSize 100; for (let i 0; i chunks.length; i batchSize) { const batch chunks.slice(i, i batchSize); const vectors await embedTexts(batch.map(c c.content)); await qdrant.upsert(COLLECTION_NAME, { points: batch.map((chunk, idx) ({ id: i idx, vector: vectors[idx], payload: { content: chunk.content, ...chunk.metadata, }, })), }); } }批量处理很重要一条一条调 Embedding API 会慢到让你怀疑人生。100 条一批是比较稳妥的选择太大可能触发 API 限制。4.5 检索与重排序检索部分先做向量召回再用 Reranker 精排interface SearchResult { content: string; score: number; metadata: Recordstring, any; } async function search( query: string, topK: number 5, recallK: number 30 ): PromiseSearchResult[] { const [queryVector] await embedTexts([query]); const results await qdrant.search(COLLECTION_NAME, { vector: queryVector, limit: recallK, with_payload: true, }); const candidates results.map(r ({ content: r.payload?.content as string, score: r.score, metadata: r.payload as Recordstring, any, })); return rerank(query, candidates).slice(0, topK); } async function rerank( query: string, candidates: SearchResult[] ): PromiseSearchResult[] { // 这里用简单的关键词重叠度做演示 // 生产环境应替换为真正的 Reranker 模型 const queryTerms new Set(query.toLowerCase().split(/\s/)); return candidates .map(c { const contentTerms c.content.toLowerCase().split(/\s/); const overlap contentTerms.filter(t queryTerms.has(t)).length; const keywordScore overlap / Math.max(queryTerms.size, 1); return { ...c, score: c.score * 0.7 keywordScore * 0.3, }; }) .sort((a, b) b.score - a.score); }生产环境里Reranker 应该用专门的模型比如 BGE-Reranker。可以部署一个本地服务通过 HTTP 调用。这里用关键词重叠度做演示是为了让你能直接跑起来看效果。4.6 上下文组装与生成最后把检索结果组装成提示词调用大模型生成回答async function generateAnswer( query: string, contexts: SearchResult[] ): Promisestring { const contextText contexts .map((c, i) [文档${i 1}] ${c.content}) .join(\n\n); const prompt 你是一个知识助手请基于以下参考资料回答用户问题。 如果参考资料中没有相关信息请明确告知用户你不知道不要编造答案。 参考资料 ${contextText} 用户问题${query} 请给出准确、简洁的回答并标注引用的文档编号。; const response await openai.chat.completions.create({ model: gpt-4o-mini, messages: [{ role: user, content: prompt }], temperature: 0.1, }); return response.choices[0].message.content || ; }temperature设低一点0.1 到 0.3 之间保证回答稳定。系统提示词里明确要求“不知道就说不知道”能有效减少幻觉。把整个流程串起来async function main() { await ensureCollection(); const doc await loadDocument(./docs/product-manual.pdf); const textChunks splitText(doc.content, 500, 50); const chunks: Chunk[] textChunks.map(content ({ content, metadata: doc.metadata, })); await indexChunks(chunks); console.log(Indexed ${chunks.length} chunks); const query 产品的保修期是多久; const results await search(query, 5, 30); const answer await generateAnswer(query, results); console.log(Answer:, answer); } main().catch(console.error);这套代码跑通之后你就有了一个最基础的 RAG 管道。接下来就是根据实际效果不断调优。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么办这是最高频的问题。排查思路按优先级来第一步检查分块质量。把检索出来的片段打印出来看看是不是被切得支离破碎。如果是调整分块大小和重叠窗口。中文内容建议用中文标点做分隔符。第二步检查 Embedding 模型。中文场景用英文优化的模型效果肯定差。换 BGE 或 M3E 试试。另外确认查询和文档用的是同一个模型前缀规范有没有遵守。第三步检查查询本身。用户的问题可能太短、太模糊或者包含大量口语化表达。可以加一步查询改写用大模型把用户问题改写成更适合检索的形式。第四步加 Reranker。如果向量召回的结果里有相关内容但排名靠后Reranker 能把它提到前面。第五步考虑混合检索。纯向量检索对精确匹配不敏感加上 BM25 关键词检索做融合。5.2 模型回答出现幻觉怎么处理幻觉的根源通常是检索内容里没有答案但模型还是硬编了一个。解决办法系统提示词里明确要求“仅基于参考资料回答不知道就说不知道”降低 temperature在检索结果里加一个相关性阈值低于阈值的结果不传给模型要求模型在回答中标注引用来源没有来源的内容不允许输出我实测下来明确要求模型标注引用这一招最有效。模型一旦被要求给出出处它就不太敢乱编了。5.3 索引速度太慢怎么优化索引慢通常卡在 Embedding API 调用上。优化方向批量调用一次传多条文本并发调用但注意 API 的速率限制用本地 Embedding 模型虽然单次可能慢但没有网络延迟和速率限制增量索引只处理新增或修改的文档不要每次全量重建5.4 上下文窗口不够用怎么办检索内容太多导致超出上下文窗口几个应对策略减少 Top-K从 5 降到 3压缩检索内容用大模型对检索结果做摘要用支持更长上下文的模型分多次检索先检索再基于中间结果做二次检索5.5 常见问题速查表问题现象可能原因排查方向解决方案检索结果完全不相关Embedding 模型不匹配检查模型语言支持换中文优化模型检索结果相关但排名靠后缺少精排检查是否有 Reranker加 Reranker 模型回答内容不完整分块太小检查分块大小增大分块或增加重叠回答包含无关信息Top-K 太大检查召回数量减小 Top-K 或加阈值过滤模型编造答案检索无结果但未告知检查提示词明确要求不知道就说不知道索引速度慢API 调用未批量检查调用方式批量并发调用中文检索效果差模型英文优化检查模型换 BGE 或 M3E重复内容多重叠窗口太大检查重叠比例降低重叠到 10%5.6 几个我踩过的坑坑一PDF 解析出来的文本顺序错乱。多栏排版的 PDF解析出来文字顺序是乱的读起来前言不搭后语。解决办法是用支持版面分析的解析工具或者干脆把 PDF 转成图片走 OCR。坑二Embedding 模型换了但索引没重建。查询用新模型索引是旧模型生成的检索结果完全不可用。换模型一定要全量重建索引。坑三元数据没存全。后面想加按时间过滤的功能发现索引时没存时间字段只能重新索引。索引时多存点元数据没坏处。坑四Top-K 设太大导致模型被干扰。检索出来 20 条其中 15 条是无关的模型被这些噪声带偏了。后来改成召回 30 条精排到 3 条效果立竿见影。坑五忽略查询改写。用户问“这个东西怎么用”检索系统根本不知道“这个东西”指什么。加一步查询改写把指代词还原成具体名词检索准确率提升明显。6. 进阶方向与后续扩展思路基础管道跑通之后有几个方向可以继续深挖。Agentic RAG让 Agent 自己决定什么时候检索、检索什么、检索几次。传统 RAG 是一次检索然后生成Agentic RAG 可以多轮检索、自我反思、动态调整查询。这需要把 RAG 和 Agent 循环结合起来复杂度高但效果上限也高。GraphRAG把知识图谱和 RAG 结合解决多跳推理问题。传统 RAG 只能检索到和查询直接相关的片段GraphRAG 可以通过实体关系链找到间接相关的信息。适合知识关联性强的场景比如医疗、法律、科研。多模态 RAG不只检索文本还能检索图片、表格、视频。这需要多模态 Embedding 模型和对应的向量库支持。RAG as a Service把 RAG 管道封装成服务提供 API 给多个 Agent 调用。AgentScope 2.0 提到的 RAG as Service 就是这个思路。好处是知识库统一管理多个 Agent 共享更新一次全部生效。评估体系RAG 效果好不好不能靠感觉要有量化指标。常用的有检索命中率Hit Rate、MRRMean Reciprocal Rank、答案忠实度Faithfulness、答案相关性Answer Relevancy。可以搭一套自动化评估流程每次调参后跑一遍看指标变化。我个人在实际操作中的体会是RAG 管道没有一步到位的完美方案都是根据实际数据不断调出来的。同样的参数换一个知识库可能效果就完全不同。所以别迷信别人的最佳实践自己动手测、动手调才是正道。先跑通最小可用版本然后针对具体问题逐个优化比一上来就追求完美架构要务实得多。