我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

AI Agent知识获取管道:RAG从原理到工程实践

AI Agent知识获取管道:RAG从原理到工程实践 1. 为什么知识获取管道是 AI Agent 的第一个真问题很多人搭 AI Agent 的时候第一反应是去折腾工具调用、多智能体编排、记忆系统这些听起来很酷的东西。我一开始也是这个路子结果搭出来的东西看着花哨实际用起来一塌糊涂——问它一个稍微具体点的问题它就开始一本正经地胡说八道。后来我才想明白Agent 的智能程度很大程度上不取决于它的推理框架有多复杂而取决于它拿到手的上下文质量有多高。你给它的知识是错的、过时的、不完整的再强的模型也只能在垃圾上做推理。这就是知识获取管道要解决的问题。所谓知识获取管道说白了就是一套让 Agent 能够从外部知识源里取到对的东西的机制。它要处理的事情包括知识从哪来、怎么切、怎么存、怎么找、找到之后怎么塞进模型的上下文窗口。这一整套流程里检索增强生成RAG是目前最主流、也最实用的技术路线。RAG 的核心思想其实很朴素——模型自己不知道的就去外部知识库里查查到了再让模型基于查到的内容来回答而不是让它凭记忆瞎编。我写这个系列的时候一直在想一个问题为什么市面上讲 RAG 的文章那么多但真正能让人从零搭出一个可用管道的内容却不多后来发现大部分内容要么停留在概念层面讲一堆向量检索语义相似度的名词就结束了要么直接甩一个 LangChain 的调用示例你跑通了 Demo但完全不知道中间发生了什么一旦效果不好就无从下手。所以我打算换个讲法从管道的每一段拆开来讲讲清楚每个环节在干什么、为什么这么设计、实际做的时候会踩什么坑。这篇文章适合两类人看。一类是刚开始接触 AI Agent 开发、想搞清楚 RAG 到底怎么回事的开发者另一类是用过 RAG 但效果不理想、想弄明白问题出在哪里的实践者。我会用 TypeScript 作为主要示例语言因为它在类型安全性和工程化方面对构建这类管道特别友好而且现在主流的 Agent 开发框架对 TypeScript 的支持已经相当成熟。不过语言只是载体核心思路换成 Python 或 Java 一样成立。2. 拆开知识获取管道从原始文档到可检索知识块2.1 管道的四个核心阶段一条完整的知识获取管道我习惯把它拆成四个阶段来看摄取、切分、索引、检索。这四个阶段环环相扣任何一个环节出问题最终效果都会打折扣。摄取阶段负责把各种格式的原始资料——PDF、Markdown、网页、数据库记录——读进来转成纯文本。这一步听起来简单实际上是最容易被低估的环节。PDF 里的表格、多栏排版、页眉页脚处理不好就会变成一堆乱码后面再怎么优化检索都是白搭。切分阶段是把长文本切成一个个知识块chunk。为什么要切因为模型的上下文窗口有限你不可能把整本书塞进去而且检索的粒度太粗找到的内容里大部分是无关信息反而会干扰模型判断。切分的核心矛盾在于切得太碎语义不完整切得太粗噪声太多。索引阶段是把切好的知识块转成向量存进向量数据库同时保留原文和元数据。检索阶段则是把用户的查询也转成向量去数据库里找最相似的若干个知识块再拼装成上下文喂给模型。2.2 为什么不能跳过切分直接全文检索有人可能会想现在模型上下文窗口都上百万 token 了我直接把整个知识库塞进去不就行了这个想法在理论上可行在实践中有三个致命问题。第一是成本。每次查询都把几十万 token 塞进上下文token 费用会高到离谱而且大部分内容跟当前问题毫无关系。第二是效果。模型在超长上下文里找关键信息的能力远没有宣传的那么可靠中间部分的信息经常被忽略这就是所谓的迷失在中间现象。第三是延迟。上下文越长推理时间越长用户体验直线下降。所以切分和检索这套机制本质上是在做一次预筛选——用相对廉价的向量计算把候选范围从几万个知识块缩小到几个再让昂贵的模型去处理这几个。这个思路跟数据库的索引是一个道理你不会为了查一条记录去全表扫描而是先走索引定位。2.3 知识块大小到底怎么定这是被问得最多的问题也是没有标准答案的问题。我的经验是知识块大小要根据内容的语义结构来定而不是拍脑袋定一个固定值。对于结构化的技术文档比如 API 文档、产品手册按段落或小节切分通常效果最好每个块大概 200 到 500 个 token。对于叙述性的内容比如教程、分析报告块可以大一些500 到 1000 个 token保证一个完整的论述不被切断。对于问答对、FAQ 这类内容一条就是一个块不用切。我一般会设置一个重叠区overlap让相邻的知识块共享一部分内容通常是块大小的 10% 到 20%。这么做是为了防止关键信息正好落在切分边界上被割裂。比如一句话被从中间切开前半句在块 A后半句在块 B检索时只命中一个块信息就不完整了。有了重叠区这种边界问题就能缓解。下面是一个基于 TypeScript 的切分函数示例按段落聚合控制块大小并保留重叠interface Chunk { id: string; content: string; metadata: Recordstring, unknown; } function splitIntoChunks( text: string, maxTokens: number 500, overlapTokens: number 80 ): Chunk[] { // 先按段落切保留语义边界 const paragraphs text.split(/\n\s*\n/).filter((p) p.trim().length 0); const chunks: Chunk[] []; let buffer: string[] []; let bufferTokens 0; const estimateTokens (s: string) Math.ceil(s.length / 1.5); for (const para of paragraphs) { const paraTokens estimateTokens(para); if (bufferTokens paraTokens maxTokens buffer.length 0) { const content buffer.join(\n\n); chunks.push({ id: chunk-${chunks.length}, content, metadata: { tokenCount: bufferTokens }, }); // 保留尾部内容作为重叠 const overlapText content.slice(-overlapTokens * 1.5); buffer [overlapText]; bufferTokens estimateTokens(overlapText); } buffer.push(para); bufferTokens paraTokens; } if (buffer.length 0) { chunks.push({ id: chunk-${chunks.length}, content: buffer.join(\n\n), metadata: { tokenCount: bufferTokens }, }); } return chunks; }这段代码里有个细节值得说token 估算我用的是字符数除以 1.5这是个粗略的近似。英文大致是 4 个字符一个 token中文大致是 1.5 到 2 个字符一个 token。实际生产环境里应该用真正的 tokenizer但做原型阶段这个估算够用了能省掉引入额外依赖的麻烦。3. 向量化与索引把文本变成可计算的东西3.1 嵌入模型选型的几个现实考量向量化的核心是嵌入模型embedding model它负责把一段文本映射成一个高维向量语义相近的文本在向量空间里距离也相近。选嵌入模型的时候我主要看四个维度。维度一语言支持。如果你的知识库以中文为主一定要选对中文支持好的模型。很多英文为主的模型在中文上的表现会明显下降尤其是处理专业术语和长句的时候。现在主流的几家模型提供商都有针对中文优化的版本选之前最好拿自己的实际数据测一下。维度二向量维度。维度越高表达能力越强但存储和计算成本也越高。常见的维度有 768、1024、1536、3072。我的经验是对于大多数企业知识库场景1024 到 1536 维已经足够没必要盲目追求最高维度。维度翻倍带来的效果提升往往很有限但存储成本是实打实翻倍的。维度三最大输入长度。嵌入模型对单次输入的 token 数有限制常见的是 512 或 8192。如果你的知识块超过这个限制会被截断导致信息丢失。所以切分的时候要确保块大小不超过嵌入模型的上限。维度四成本和延迟。如果知识库很大向量化是一次性的大批量操作成本要考虑进去。有些模型按 token 计费几百万 token 下来也是一笔钱。另外如果要做实时查询的向量化延迟也很关键。3.2 向量数据库不是越重越好一提到向量存储很多人第一反应就是上专业的向量数据库。但我的建议是先想清楚你的数据规模和查询模式再决定用什么。如果知识块数量在几万以内用 PostgreSQL 加一个向量扩展就完全够用运维成本低还能跟业务数据放在一起做联合查询。如果到了几十万上百万级别再考虑专门的向量数据库。如果只是做原型验证甚至可以直接在内存里算余弦相似度几千个块的情况下速度完全能接受。我见过不少项目知识库总共就几千条上来就部署一套分布式向量数据库结果运维复杂度陡增收益却微乎其微。技术选型要匹配实际需求不要为了用而用。下面是一个用内存做向量检索的简化实现适合原型阶段interface VectorRecord { id: string; vector: number[]; content: string; metadata: Recordstring, unknown; } function cosineSimilarity(a: number[], b: number[]): number { let dot 0; let normA 0; let 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 searchTopK( queryVector: number[], records: VectorRecord[], k: number 5 ): VectorRecord[] { return records .map((r) ({ record: r, score: cosineSimilarity(queryVector, r.vector) })) .sort((a, b) b.score - a.score) .slice(0, k) .map((item) item.record); }这段代码没有任何依赖直接就能跑。它的意义在于让你理解检索的本质就是算相似度然后排序专业数据库做的事情本质上也是这个只是在大规模数据上做了工程优化。3.3 元数据是检索质量的隐藏杠杆很多人在建索引的时候只存向量和原文把元数据当成可有可无的东西。这是个很大的浪费。元数据在检索阶段能发挥的作用远超想象。最基本的元数据包括来源文件、创建时间、文档类型、所属分类。有了这些你就能做过滤式检索——先按条件缩小范围再在范围内做向量相似度排序。比如用户问的是某个产品的最新政策你可以先过滤出该产品、且创建时间在最近半年内的知识块再算相似度。这样能大幅提升结果的相关性还能避免检索到已经废弃的旧内容。更进阶一点可以给知识块打上标签比如操作步骤注意事项常见问题检索时根据查询意图选择性地召回某类标签的内容。这个思路在构建问答型 Agent 的时候特别有用。4. 检索策略从能查到到查得准4.1 纯向量检索的盲区向量检索擅长语义匹配用户问怎么重置密码它能找到密码找回流程这样的内容即使字面不完全一样。但它也有明显的盲区。第一个盲区是精确匹配。用户问的是错误码E1042向量检索可能会返回一堆语义相近但错误码不同的内容因为向量模型对数字和代码这类信息的区分能力较弱。第二个盲区是关键词召回。有些查询的核心就是一个特定术语向量检索反而可能因为语义泛化而漏掉。所以实际生产环境里纯向量检索往往不够需要配合关键词检索做混合检索。4.2 混合检索的落地方式混合检索的思路是把向量检索和关键词检索的结果融合起来。关键词检索可以用成熟的全文搜索引擎也可以用简单的 BM25 算法实现。融合的方式有两种一种是加权求和给两种检索的分数各分配一个权重另一种是倒数排名融合RRF只看排名不看分数把两个结果列表的排名做加权合并。RRF 的好处是不需要归一化分数因为向量相似度和 BM25 分数的量纲完全不同直接加权很麻烦。RRF 只关心排第几实现简单且效果稳定。下面是一个 RRF 融合的实现interface SearchResult { id: string; content: string; score: number; } function reciprocalRankFusion( resultLists: SearchResult[][], k: number 60 ): SearchResult[] { const scoreMap new Mapstring, { result: SearchResult; rrfScore: number }(); for (const list of resultLists) { list.forEach((result, rank) { const existing scoreMap.get(result.id); const rrfContribution 1 / (k rank 1); if (existing) { existing.rrfScore rrfContribution; } else { scoreMap.set(result.id, { result, rrfScore: rrfContribution }); } }); } return Array.from(scoreMap.values()) .sort((a, b) b.rrfScore - a.rrfScore) .map((item) item.result); }这里的 k 参数是个平滑常数通常取 60。它的作用是降低高排名之间的差距让融合更平滑。这个值不用太纠结60 是经过大量实践验证的默认值。4.3 重排序检索管道的最后一道关卡混合检索召回的结果通常会有十几到几十条。这些结果里真正跟问题高度相关的可能只有几条。这时候就需要重排序rerank来精挑细选。重排序模型和嵌入模型不一样。嵌入模型是分别把查询和文档转成向量然后算距离速度快但精度有限。重排序模型是把查询和文档一起输入直接输出一个相关性分数精度高但速度慢。所以典型的做法是用嵌入检索快速召回一批候选再用重排序模型对这批候选精排取前几条最相关的。这个两阶段的设计思路跟搜索引擎的召回加排序架构是一脉相承的。召回阶段追求快和全排序阶段追求准。实际测试下来加了重排序之后最终喂给模型的知识块相关性会有明显提升Agent 回答的准确率也跟着上去。4.4 查询改写让检索更懂用户用户输入的查询往往很口语化、很模糊直接拿去检索效果不好。查询改写就是在检索之前先把用户的查询处理一下。最简单的改写是查询扩展把查询里的关键词做同义词替换或者补充相关的术语。比如用户问电脑开不了机可以扩展成电脑无法启动 开机故障 电源问题。更进阶的是查询分解把复杂的多跳问题拆成几个子查询分别检索再合并。比如我们公司去年的营收是多少跟前年比增长了多少可以拆成去年营收和前年营收两个查询。还有一种做法是用模型来改写查询让模型根据对话历史把指代消解掉生成一个自包含的检索查询。这在多轮对话场景里特别重要因为用户第二句可能只说那它呢直接检索完全没法用。5. 上下文组装把检索结果变成模型能用的输入5.1 上下文窗口的分配策略检索到相关的知识块之后怎么把它们组装成模型的输入这里面有不少讲究。模型的上下文窗口是有限的你需要在这几个部分之间分配系统提示词、对话历史、检索到的知识、用户当前问题。我的分配原则是系统提示词和用户问题优先级最高必须完整保留对话历史保留最近几轮更早的做摘要压缩剩下的空间全给检索知识。如果检索知识太多塞不下就按相关性排序从高到低填填满为止。这里有个容易忽略的点知识块之间要有明确的分隔和标注。不要让模型面对一大坨没有结构的文本而是明确告诉它以下是检索到的相关资料请基于这些资料回答。每个知识块最好带上来源标识方便模型引用也方便你排查问题。5.2 提示词里的防幻觉设计RAG 的一个核心目标是减少幻觉但如果提示词写得不好模型照样会编。我一般会在系统提示词里明确几条规则。第一条只基于提供的资料回答资料里没有的信息明确说根据现有资料无法回答不要自己发挥。第二条如果资料之间有冲突指出冲突并说明。第三条回答时标注信息来源方便用户核实。这些规则不是万能的模型有时候还是会不听话但能显著降低幻觉率。另外把检索到的资料放在用户问题之前还是之后效果也有差异。我的实测是放在问题之前效果更好因为模型读资料的时候已经带着我要回答什么的预期注意力更集中。5.3 一个完整的组装函数把前面的环节串起来一个完整的上下文组装函数大概长这样interface AssembledContext { systemPrompt: string; messages: Array{ role: string; content: string }; } function assembleContext( query: string, retrievedChunks: SearchResult[], history: Array{ role: string; content: string }, maxContextTokens: number 8000 ): AssembledContext { const systemPrompt 你是一个基于知识库回答问题的助手。 规则 1. 只基于下方提供的资料回答问题不要使用资料之外的知识。 2. 如果资料中没有相关信息明确告知用户根据现有资料无法回答。 3. 回答时请标注信息来源。 4. 如果资料之间存在矛盾请指出并说明。; const knowledgeSection retrievedChunks .map((c, i) 【资料${i 1}】\n${c.content}) .join(\n\n); const estimateTokens (s: string) Math.ceil(s.length / 1.5); let usedTokens estimateTokens(systemPrompt) estimateTokens(query); const selectedHistory: typeof history []; // 从最近的对话开始保留 for (let i history.length - 1; i 0; i--) { const t estimateTokens(history[i].content); if (usedTokens t maxContextTokens * 0.3) break; selectedHistory.unshift(history[i]); usedTokens t; } const messages [ ...selectedHistory, { role: user, content: 参考资料\n${knowledgeSection}\n\n问题${query}, }, ]; return { systemPrompt, messages }; }这个函数里对话历史我限制在总预算的 30% 以内剩下的留给知识和问题。这个比例可以根据实际场景调整如果对话历史很重要就多留点如果主要是单轮问答就少留点。6. 实测中那些文档不会告诉你的坑6.1 检索命中率上不去先别急着换模型很多人一发现检索效果不好第一反应是换个更强的嵌入模型。但我踩过的坑告诉我大部分时候问题不在模型而在数据和切分。我遇到过一个案例知识库是一堆产品文档检索效果一直很差。排查了半天最后发现是 PDF 解析的时候把表格搞乱了很多关键参数变成了没有上下文的孤立数字。这种问题换再好的模型也救不了得从数据清洗入手。所以排查检索问题的顺序应该是先看原始数据质量再看切分是否合理再看检索策略最后才考虑换模型。这个顺序能帮你省下大量试错成本。6.2 相似度分数高不代表内容相关向量相似度是个相对值不同模型、不同数据分布下同样的分数含义完全不同。我见过相似度 0.85 的结果完全不相关也见过 0.6 的结果非常精准。所以不要设一个固定的相似度阈值来过滤那样很容易误杀。更靠谱的做法是看排名取 top-k 而不是卡分数。如果一定要过滤就基于你自己的数据统计一个合理的阈值而且要定期重新评估因为数据在变。6.3 知识库更新是个持续工程知识库不是建一次就完事了。内容会过时文档会更新新的资料会加进来。如果没有一套更新机制知识库很快就会变成一堆过期信息的垃圾场。我的做法是给每个知识块记录创建时间和来源版本。更新的时候先按来源删除旧的知识块再插入新的。查询的时候可以给新内容更高的权重或者直接过滤掉超过一定时间的旧内容。这套机制不复杂但能保证知识库的时效性。6.4 别忽视检索日志的价值上线之后一定要把每次检索的查询、召回结果、最终用了哪些知识块、模型的回答都记录下来。这些日志是优化管道的金矿。通过分析日志你能发现哪些查询经常检索不到好结果哪些知识块从来没被命中过哪些回答用户反馈不好。基于这些真实数据去优化比拍脑袋调参有效得多。我一般会定期抽样看日志找出 bad case然后针对性地改进切分策略或补充知识。7. 从基础 RAG 到更聪明的知识获取基础 RAG 跑通之后你会发现它还是有不少局限。比如它只能做单轮检索遇到需要多步推理的问题就力不从心它把检索和生成当成两个独立步骤检索的时候不知道生成需要什么。现在有一些进阶方向值得探索。一个是迭代式检索让 Agent 先检索一轮根据结果判断信息够不够不够就换个查询再检索直到信息充分再生成。另一个是自适应检索让 Agent 自己判断这个问题需不需要检索简单问题直接答复杂问题才走检索流程这样能省下不少开销。还有一个方向是把知识组织成图结构也就是常说的 GraphRAG。它不只是把知识切成孤立的块而是建立块与块之间的关系检索的时候能沿着关系找到关联信息。这对于需要理解实体关系的场景特别有用比如某公司的子公司有哪些这类问题图结构能比纯向量检索答得更准。不过这些进阶方案都有各自的复杂度成本我的建议是先把基础 RAG 做扎实把数据质量、切分策略、检索评估这些基本功练好再去考虑进阶。基础不牢上再花哨的架构也是空中楼阁。我在实际项目里的体会是知识获取管道的优化是个持续迭代的过程没有一劳永逸的方案。每次遇到效果不好的 case都是一次理解数据和用户需求的机会。把每次 bad case 都当成改进的线索管道就会越用越顺。
返回列表