ARTICLE DETAIL

资讯详情

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

AI Agent知识获取管道:RAG检索增强生成全流程与TypeScript实战

AI Agent知识获取管道:RAG检索增强生成全流程与TypeScript实战 1. 为什么知识获取管道是 AI Agent 的分水岭做 AI Agent 的人迟早会撞上一堵墙模型本身很聪明但你问它公司内部的报销流程、上周刚更新的接口文档、某个客户的特殊约定它要么一本正经地胡说要么干脆承认不知道。这不是模型不行而是它的知识被冻结在训练截止那一刻而你的业务知识每天都在变。知识获取管道就是解决这个问题的核心组件。它的职责很明确在 Agent 需要知识的时候把正确、最新、相关的信息从外部知识源里捞出来塞进模型的上下文窗口让模型基于真实材料回答。这套机制最主流的实现就是RAG检索增强生成Retrieval-Augmented Generation。我见过太多团队在 Agent 项目上翻车不是因为模型选错了而是因为知识管道做得稀烂。检索回来的东西驴唇不对马嘴模型再强也只能基于垃圾输入产出垃圾输出。所以我把 RAG 放在这个系列第四篇来讲因为它是从玩具 Demo跨到能上生产的关键一步。这篇文章面向的是已经动手搭过 Agent、想认真把知识管道做扎实的开发者。我会用TypeScript作为主要实现语言因为它在 Agent 工程化里越来越主流类型系统能帮你在管道这种多环节串联的场景里少踩很多坑。全文围绕一个核心问题展开一条能用的 RAG 管道到底由哪些环节组成每个环节的坑在哪里怎么用代码落地。先给一个整体认知RAG 不是向量数据库 大模型这么简单。它是一条流水线从文档进来到答案出去中间至少经过切分、向量化、存储、检索、重排、拼装、生成七个环节。任何一个环节偷懒整条管道的效果都会塌方。下面我按数据流动的顺序把每个环节拆开讲。2. 文档切分决定检索质量的第一道关卡2.1 切分粒度为什么比你想的更重要很多人做 RAG 的第一步是找个库把 PDF 读成文本然后按固定字数一切扔进向量库就完事了。这是最典型的翻车起点。切分粒度直接决定了检索的最小语义单元切得太碎一个完整的意思被拆散检索出来的是半句话切得太粗一个块里混了好几个主题向量被平均化检索精度暴跌。我一般把切分策略分成三个层次来考虑。固定长度切分最简单按 token 数或字符数硬切适合结构松散的纯文本但几乎必然切断语义。递归字符切分是折中方案按段落、句子、标点的优先级递归尝试尽量在自然边界断开这是大多数场景的默认选择。语义切分最贵也最准用嵌入模型判断相邻句子的语义相似度在语义转折处断开适合对精度要求极高的场景。实际项目里我的经验是先看文档结构再决定切分策略。如果原始文档本身有清晰的标题层级Markdown、HTML、带书签的 PDF那就顺着结构切一个二级标题下的内容作为一个块这比任何算法都靠谱。结构信息是免费的语义信号不用白不用。2.2 用 TypeScript 实现一个带重叠的递归切分器切分时有个关键参数叫重叠overlap就是相邻两个块之间保留一部分重复内容。为什么需要它因为无论怎么切边界处的语义都可能被割裂重叠能让被切断的信息至少在一个块里是完整的。经验值一般是块大小的 10% 到 20%。下面是一个我常用的递归切分实现核心思路是按分隔符优先级逐层尝试切出来的块如果还太大就继续往下切interface ChunkOptions { chunkSize: number; // 目标块大小字符数 chunkOverlap: number; // 重叠大小 separators: string[]; // 分隔符优先级从粗到细 } const DEFAULT_SEPARATORS [\n\n, \n, 。, , , ., , ]; function recursiveSplit(text: string, options: ChunkOptions): string[] { const { chunkSize, chunkOverlap, separators } options; // 找到当前层级能用的分隔符 const separator separators.find((s) text.includes(s)) ?? ; const parts separator ? text.split(separator) : [text]; const chunks: string[] []; 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); // 单个 part 就超长需要递归用更细的分隔符切 if (part.length chunkSize) { const nextSeparators separators.slice(separators.indexOf(separator) 1); chunks.push(...recursiveSplit(part, { ...options, separators: nextSeparators })); current ; } else { current part; } } } if (current) chunks.push(current); // 加上重叠把上一个块的尾部拼到当前块前面 return applyOverlap(chunks, chunkOverlap); } function applyOverlap(chunks: string[], overlap: number): string[] { if (overlap 0) return chunks; return chunks.map((chunk, i) { if (i 0) return chunk; const prev chunks[i - 1]; const tail prev.slice(Math.max(0, prev.length - overlap)); return tail chunk; }); }这段代码有几个细节值得说。分隔符数组的顺序就是优先级从段落分隔符\n\n一路降到空字符串空字符串意味着最后兜底按字符硬切。applyOverlap是在切分完成后统一加重叠比在切分过程中处理要清晰得多。注意重叠不是越大越好。重叠太大会导致向量库里充满重复内容检索时返回一堆高度相似的块浪费上下文窗口。我实测下来 10% 到 15% 是个甜点区超过 30% 基本是负收益。2.3 元数据被 90% 的人忽略的检索利器切分的时候千万别只存文本内容一定要把元数据一起存下来。元数据包括来源文件名、章节标题、页码、更新时间、文档类型等等。为什么重要因为检索阶段你可以用元数据做过滤比如只在最近三个月更新的文档里搜、只搜产品手册不搜会议纪要。纯向量检索做不到这种精确控制元数据过滤能把召回范围先缩小一圈精度立刻上一个台阶。我的做法是给每个块生成一个结构化的对象而不是裸字符串interface DocumentChunk { id: string; content: string; metadata: { source: string; // 来源文件 section?: string; // 所属章节 page?: number; // 页码 updatedAt: string; // 更新时间 docType: string; // 文档类型 }; }这个结构在后续的检索、重排、引用溯源环节都会反复用到。尤其是引用溯源当 Agent 给出答案时你能告诉用户这个结论来自《XX手册》第 3 章可信度完全不一样。3. 向量化与存储把语义变成可计算的距离3.1 嵌入模型选型不是越贵越好向量化的本质是把文本映射到一个高维空间语义相近的文本在空间里距离也近。这个映射由**嵌入模型Embedding Model**完成。选型时我关注三个维度维度、语言支持、成本。维度决定了向量库的存储和检索开销。常见的有 768 维、1024 维、1536 维。维度越高表达能力越强但检索越慢、存储越贵。对于中文为主的场景我一般优先选对中文优化过的模型因为很多英文模型在中文语义上的表现会明显打折。这里有个很多人踩的坑嵌入模型和生成模型是两回事。你可以用 A 模型做嵌入用 B 模型做生成它们互不干扰。嵌入模型只负责把文本变成向量生成模型负责根据检索结果写答案。选嵌入模型时看的是检索准确率选生成模型时看的是语言组织和推理能力别混为一谈。还有一个隐蔽的坑换了嵌入模型整个向量库必须重建。因为不同模型生成的向量空间不兼容旧向量和新向量放在一起检索结果完全是乱的。所以嵌入模型一旦定了尽量别中途换如果非要换做好全量重嵌入的准备。3.2 向量库的选型逻辑向量库这块选择很多我不列一堆产品名而是给你一套选型逻辑。核心问自己三个问题数据量多大几万条以内用内存向量库甚至本地文件就够了别上重型方案。百万级以上才需要考虑分布式向量库。要不要持久化生产环境必须持久化而且要支持增量更新不能每次全量重建。要不要混合检索如果需要关键词和向量的混合检索得选支持这种能力的库。对于大多数中小型 Agent 项目我的建议是先用最简单的方案跑通别一上来就上分布式。我见过太多项目在选型上纠结两周结果数据量才几千条用什么都绰绰有余。工程上有个原则叫 YAGNIYou Arent Gonna Need It在向量库选型上尤其适用。存储的时候向量和元数据要一起存。检索时先按向量相似度召回候选再用元数据过滤最后返回带元数据的完整块。下面是一个存储层的抽象接口方便你替换底层实现interface VectorStore { upsert(chunks: DocumentChunk[], vectors: number[][]): Promisevoid; search( queryVector: number[], topK: number, filter?: Recordstring, unknown ): PromiseArray{ chunk: DocumentChunk; score: number }; }这个接口把存和查两件事抽象出来底层换成任何向量库都不影响上层逻辑。这是我在多个项目里验证过的做法前期多花十分钟设计接口后期换库能省好几天。3.3 批量嵌入与限流处理调用嵌入接口时别一条一条调要批量。大多数嵌入服务都支持一次传多条文本批量调用能把网络往返开销摊薄速度提升非常明显。但批量也有上限一般单次几十到上百条超了会被拒绝。批量调用必须配限流和重试。嵌入接口通常有速率限制你一股脑发几千条请求会被限流甚至封禁。我的做法是用一个带并发控制的队列比如限制同时最多 5 个请求在飞失败的请求指数退避重试async function embedWithRetry( texts: string[], embedFn: (batch: string[]) Promisenumber[][], batchSize 64, maxRetries 3 ): Promisenumber[][] { const results: number[][] []; for (let i 0; i texts.length; i batchSize) { const batch texts.slice(i, i batchSize); let attempt 0; while (true) { try { results.push(...(await embedFn(batch))); break; } catch (err) { attempt; if (attempt maxRetries) throw err; // 指数退避1s, 2s, 4s... await new Promise((r) setTimeout(r, 1000 * 2 ** (attempt - 1))); } } } return results; }这段代码看起来简单但它是生产环境能不能稳定跑完的关键。我踩过的坑是本地测试几十条数据一切正常上线跑几万条文档时因为没做限流被服务端限流后大量请求失败整个索引任务卡死。加上重试和退避之后同样的任务稳稳跑完。4. 检索策略从能搜到到搜得准4.1 纯向量检索的天花板在哪里向量检索擅长语义匹配你问怎么报销差旅费它能召回出差费用申请流程这种字面不同但意思相近的内容。这是它的强项。但它也有明显短板对精确关键词不敏感。举个例子你搜一个产品型号XR-2000向量检索可能给你返回一堆XX-2000、XR-3000的相似内容因为它关注的是整体语义而不是精确的字符匹配。再比如搜一个专有名词、错误码、人名向量检索经常抓瞎。这就是为什么**混合检索Hybrid Search**成了标配。混合检索同时跑两路一路向量检索负责语义召回一路关键词检索比如 BM25负责精确匹配然后把两路结果融合。融合算法常用 RRFReciprocal Rank Fusion倒数排名融合它不需要两路分数可比只看排名简单又稳。4.2 用 RRF 融合向量与关键词结果RRF 的核心思想很朴素一个文档如果在多路检索里都排得靠前那它大概率真的相关。公式是每个文档的得分等于它在各路检索中排名的倒数之和function reciprocalRankFusion( resultLists: ArrayArray{ id: string; score: number }, k 60 ): Array{ id: string; score: number } { const scoreMap new Mapstring, number(); for (const list of resultLists) { list.forEach((item, rank) { const rrfScore 1 / (k rank 1); scoreMap.set(item.id, (scoreMap.get(item.id) ?? 0) rrfScore); }); } return [...scoreMap.entries()] .map(([id, score]) ({ id, score })) .sort((a, b) b.score - a.score); }k这个常数一般取 60作用是平滑排名靠前文档的权重差距避免第一名一家独大。这个算法我在多个项目里用过比手动调两路分数的权重靠谱得多因为向量相似度和 BM25 分数根本不在一个量纲上硬加权很容易调崩。4.3 重排把最相关的顶到最前面检索召回之后还有一步能显著提升精度重排Rerank。检索阶段为了快用的是向量点积或近似最近邻精度有限。重排阶段用一个更精细的模型通常是交叉编码器对召回的候选逐一打分把真正相关的排到最前面。为什么重排有效因为检索阶段是粗筛它把查询和文档分别编码成向量再算距离两者没有交互。而重排模型把查询和文档拼在一起送进模型能捕捉到细粒度的交互信息精度高得多。代价是慢所以只对召回的 Top N比如 20 条做重排再取 Top K比如 5 条给生成模型。我的标准流程是混合检索召回 20 到 50 条重排后取 3 到 5 条。这个比例是实测出来的召回太少会漏召回太多重排又慢。重排这一步是性价比最高的精度提升手段如果你的 RAG 效果不理想先别急着换模型加个重排试试。4.4 查询改写让用户的烂问题也能搜到好东西用户的问题往往很烂。可能是一句话里混了好几个意图可能是口语化表达可能省略了关键信息。直接拿这种问题去检索效果自然差。**查询改写Query Rewriting**就是在检索前用模型把用户问题改写成更适合检索的形式。常见的改写策略有几种。多查询生成把一个问题改写成几个不同角度的查询分别检索再合并结果覆盖更全面。假设文档生成HyDE让模型先编一个理想答案再用这个答案去检索因为答案和文档的语义更接近。指代消解把它、这个这类代词替换成具体指代的对象这在多轮对话里特别重要。我一般会在 Agent 里加一个轻量的查询改写步骤成本很低但收益明显。尤其是多轮对话场景用户第二句经常是那它的价格呢不消解指代根本没法检索。5. 上下文拼装与生成最后一步决定用户体验5.1 上下文窗口是稀缺资源要精打细算检索回来的块怎么塞进提示词这里面有讲究。上下文窗口是有限的塞太多无关内容不仅浪费还会稀释真正相关的信息让模型抓不住重点。这就是所谓的迷失在中间现象模型对上下文开头和结尾的信息更敏感中间的内容容易被忽略。我的拼装策略是按相关性排序最相关的放最前面和最后面。具体做法是把重排后的块按分数排好然后交错放置把最高分的放开头次高分的放结尾中间的按顺序填。这样能最大化利用模型对首尾位置的注意力偏好。每个块前面要加来源标注比如[来源产品手册 第3章]。这有两个好处一是模型能引用来源二是当多个块内容冲突时模型能根据来源判断可信度。拼装的模板大概长这样function buildContext(chunks: Array{ content: string; metadata: any }): string { return chunks .map((c, i) [片段${i 1} | 来源${c.metadata.source}]\n${c.content}) .join(\n\n---\n\n); } function buildPrompt(question: string, context: string): string { return 你是一个严谨的助手。请仅根据下面提供的资料回答问题。 如果资料中没有相关信息请明确说明资料中未提及不要编造。 资料 ${context} 问题${question} 回答; }注意提示词里那句仅根据资料回答和没有就说没有这是抑制幻觉的关键。不加这句模型很容易把检索到的片段和自己的先验知识混在一起编出看似合理实则错误的内容。5.2 引用溯源让答案可验证生产环境的 RAG 必须支持引用溯源。用户看到答案后能点开看到这个结论来自哪个文档的哪一段。这不仅是信任问题也是排错手段当答案错了你能快速定位是检索错了还是生成错了。实现上就是在拼装上下文时给每个块编号然后要求模型在回答时标注引用了哪个片段。解析模型输出时把编号映射回原始文档的元数据。这个功能做起来不难但对产品可信度的提升是巨大的。我在项目里加了这个功能后用户对 Agent 的信任度明显上升因为他们能自己验证。5.3 流式输出与首字延迟生成阶段还有个体验问题首字延迟。如果等模型把整个答案生成完再返回用户要盯着空白屏幕好几秒。解决办法是流式输出模型生成一个 token 就推一个 token 给前端用户能立刻看到内容在往外冒感知延迟大大降低。流式输出在 TypeScript 里通常用异步迭代器实现逐块读取响应流并推送给前端。这块要注意的是流式输出和引用溯源要配合好引用信息一般在流结束后统一返回或者用特殊标记在流中插入。6. 评估与调优没有度量就没有优化6.1 建立一套能跑的评估集RAG 最怕的是感觉还行。你觉得效果不错但到底哪里好哪里差说不清楚。所以必须建立评估集一批有标准答案的问题每次改动管道后跑一遍看指标变化。评估集不用很大几十到上百条就够但要有代表性覆盖各种问题类型事实查询、多跳推理、否定问题、无答案问题。无答案问题特别重要用来测试模型会不会在资料里没有答案时老实说不知道而不是硬编。评估指标我关注两个层面。检索层面看召回率相关文档有没有被召回和精确率召回的有多少是相关的。生成层面看答案正确性和忠实度答案是否忠于检索到的资料有没有编造。检索和生成要分开评估否则出了问题你不知道是哪一环的锅。6.2 定位瓶颈是检索的锅还是生成的锅调优的第一步是定位瓶颈。方法很简单拿评估集里答错的案例人工看检索回来的内容对不对。如果检索回来的内容里根本没有正确答案那是检索的问题如果检索回来了但模型没用上或者用错了那是生成的问题。检索问题通常出在切分和检索策略上块切得不好、嵌入模型不合适、没做混合检索、没做重排。生成问题通常出在提示词和上下文拼装上提示词没约束好、上下文塞太多、块顺序不对。我踩过的一个典型坑有段时间答案质量突然下降排查半天发现是新导入的一批文档切分粒度不对块特别大导致检索时一个块里混了好几个主题向量被平均化召回精度暴跌。重新切分后立刻恢复。这个案例说明数据质量比模型调参重要得多别一上来就想着换模型。6.3 增量更新与知识保鲜知识是活的文档每天都在变。RAG 管道必须支持增量更新新文档进来能加进去旧文档改了能更新删了的能移除。全量重建索引在小数据量下还行数据一多就扛不住。增量更新的关键是给每个块一个稳定的 ID通常用文档路径 块序号或者内容哈希。文档更新时先删掉这个文档的所有旧块再插入新块。内容哈希的好处是内容没变的块可以跳过重新嵌入省成本。提示增量更新一定要处理删除场景。很多团队只做了新增和更新忘了删除结果旧版本的文档一直留在库里检索时新旧内容混在一起模型无所适从。删除逻辑必须在设计阶段就考虑进去。7. 我在实际项目里踩过的几个真坑第一个坑是过度依赖向量检索。早期我做的管道只有向量检索遇到精确匹配的场景就翻车。后来加了关键词检索和 RRF 融合效果立竿见影。教训是向量检索不是银弹混合检索才是生产标配。第二个坑是忽略元数据过滤。有次用户反馈搜出来的内容全是过期的旧文档排查发现是没做时间过滤旧文档和新文档一起参与检索旧文档因为内容更详细反而排名更高。加上时间过滤和版本标记后解决。元数据不是可选项是必需品。第三个坑是提示词没约束好导致幻觉。模型很聪明当检索内容不完整时它会自动用自己的知识补全补出来的东西看着合理但是错的。后来在提示词里加了严格的约束并且要求标注引用幻觉率明显下降。记住模型不会主动告诉你它不知道你得逼它承认。第四个坑是评估缺失导致优化靠猜。没有评估集的时候每次调参都是凭感觉改完不知道是变好了还是变坏了。建立评估集之后所有优化都有了依据效率提升不止一倍。这是我最想强调的一点先建评估再谈优化。8. 从基础 RAG 到 Agentic RAG 的演进方向基础 RAG 是一条固定流水线检索一次生成一次。但真实场景往往更复杂一次检索不够需要多轮检索、多步推理。这就是Agentic RAG的方向把检索能力交给 Agent 自主调度Agent 根据问题决定检索什么、检索几次、要不要换个角度再检索。比如一个复杂问题对比 A 产品和 B 产品的差异基础 RAG 可能一次检索就返回一堆混杂内容。而 Agentic RAG 会先检索 A 的信息再检索 B 的信息然后对比。这种自主调度能力是基础 RAG 不具备的。从工程角度看Agentic RAG 对知识管道的要求更高检索接口要能被 Agent 灵活调用返回结果要结构化要支持多轮检索的状态管理。但底层的东西没变还是切分、嵌入、检索、重排这一套。所以把基础 RAG 做扎实是通往 Agentic RAG 的必经之路。我个人在实际操作中的体会是别一上来就追求 Agentic RAG 的花哨先把基础管道的每个环节做对。我见过太多项目在基础检索都没做好的情况下急着上多轮检索和自主调度结果就是花架子效果还不如老老实实的单轮 RAG。基础打牢了往上叠能力是水到渠成的事。最后分享一个我常用的调试技巧把每次检索的中间结果都打日志——召回了哪些块、重排后排序如何、最终塞进上下文的是哪几条。出问题时翻日志一眼就能看出是哪一环掉了链子。这个习惯帮我省了无数排查时间比任何调试工具都管用。
返回列表