ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI Agent 知识获取管道实战:TypeScript 从零搭建 RAG 检索增强生成系统

2026/9/30 13:53:25 拓冰建站 浏览量
AI Agent 知识获取管道实战:TypeScript 从零搭建 RAG 检索增强生成系统 1. 为什么知识获取管道是 AI Agent 落地的第一道坎做 AI Agent 的人迟早会撞上一堵墙模型本身很聪明但你问它公司内部的报销标准、上周刚更新的产品参数、某个客户的特殊约定它要么一本正经地胡说要么直接告诉你我不知道。这不是模型不行而是它的知识被冻结在训练截止那一刻而真实业务里的知识每天都在变。知识获取管道要解决的就是这个问题。它的核心任务只有一句话在 Agent 需要回答问题的那个瞬间把跟这个问题最相关的、最新的、来自你自己数据源的那几段内容塞进模型的上下文里。这套机制业界叫RAGRetrieval-Augmented Generation检索增强生成中文常译作检索增强生成。我见过太多团队一上来就冲着Agent 自主规划多智能体协作去结果 Demo 很炫一上生产就崩因为底层知识根本喂不进去。RAG 是 Agent 的地基不是可选项。一个没有可靠知识获取管道的 Agent本质上只是一个会聊天的玩具。这篇是走进 AI Agent系列的第四篇专门啃 RAG 基础。我会用TypeScript作为实现语言因为现在大量 Agent 框架LangChain.js、Vercel AI SDK、Mastra 等都是 TS 生态前端同学上手成本最低。读完你应该能自己从零搭出一条能跑通、能调试、能上量的知识获取管道而不是只会调别人的 API。适合谁看写过一点 TypeScript、想搞明白 RAG 到底怎么回事的开发者已经用过现成 RAG 服务但不知道里面发生了什么的人以及被检索命中率上不去折磨过的同学。先说一个反直觉的结论RAG 里最难的不是向量数据库也不是大模型而是切块和检索策略这两件看起来最土的事。后面我会用大量篇幅讲这两块因为它们决定了你整条管道的上限。2. RAG 到底在做什么把开卷考试讲透2.1 一个生活化的类比想象你参加一场考试。闭卷考试时你只能靠脑子里记的东西答题记错了就答错——这就是纯大模型。开卷考试时你可以翻书找到相关章节照着答——这就是RAG。但开卷考试有个前提你得翻对书、翻对页。如果书有十万页你只有三秒钟找答案那你必须有一套高效的索引和检索方法。RAG 的全部工程难点几乎都浓缩在怎么在三秒内翻到对的那一页上。所以 RAG 的完整链路是这样的离线阶段把你的原始资料PDF、网页、数据库记录、Markdown 文档切成小块转成向量存进向量数据库同时保留原文。在线阶段用户提问 → 把问题也转成向量 → 在向量库里找最相似的若干块 → 把这些块拼进 Prompt → 交给大模型生成答案。听起来简单但每一步都有坑。下面逐个拆。2.2 为什么是向量而不是关键词传统搜索用关键词匹配你搜报销标准它找包含报销标准这四个字的文档。问题是用户可能问出差吃饭能报多少字面上跟报销标准一个字都不重合关键词搜索直接歇菜。向量检索把文本映射到一个高维空间里的点语义相近的文本点与点的距离就近。出差吃饭能报多少和差旅费报销标准在向量空间里会靠得很近哪怕它们没有一个共同的字。这就是语义检索的威力。代价是向量检索对精确匹配反而不敏感。用户问工号 A12345 的权限向量检索可能给你返回一堆讲权限的文档但就是漏掉那个精确的工号记录。所以成熟的 RAG 系统往往是向量检索 关键词检索的混合这个后面细讲。2.3 一条最小可跑的管道长什么样在动手写代码前先把数据流画清楚用文字不用图原始文档 → 加载(Load) → 切块(Chunk) → 向量化(Embed) → 存储(Store) ↓ 用户问题 → 向量化(Embed) → 检索(Retrieve) → 重排(Rerank) → 拼Prompt → LLM生成每一环我都会在后面的章节展开。这里先记住一个原则离线阶段做得越扎实在线阶段就越省心。很多人把精力全花在在线检索调参上却忽略了离线切块的质量这是本末倒置。3. 文档加载与切块决定 RAG 上限的隐形战场3.1 加载阶段最容易被忽略的脏数据加载看起来最简单——把文件读进来而已。但真实场景里你的数据源五花八门PDF 有扫描件、有双栏排版、有页眉页脚网页有导航栏和广告Word 有批注和修订痕迹。如果加载阶段不清理这些噪声会一路污染到最终的检索结果。我在一个项目里踩过最典型的坑一批 PDF 每页底部都有第 X 页 共 Y 页和公司水印。切块之后几乎每个块里都带着这串字符。结果用户问任何问题检索出来的块都因为共享这串噪声而看起来相似把真正相关的内容挤了下去。清洗掉页眉页脚后检索命中率直接涨了一截。用 TypeScript 做加载常见的选择是pdf-parse或pdfjs-dist解析 PDF 文本cheerio解析 HTML去掉 script/style/导航mammoth解析 docx数据库直接走 SQL 查询加载完统一转成一个标准结构我习惯用这个接口interface RawDocument { id: string; content: string; metadata: { source: string; // 文件路径或 URL title?: string; updatedAt?: string; [key: string]: unknown; }; }metadata千万别省。后面做过滤、做引用溯源、做增量更新全靠它。3.2 切块为什么按字数切是个陷阱切块Chunking是 RAG 里最被低估的环节。新手最常见的做法是每 500 字切一刀简单粗暴但问题很大。问题一切断语义。一个完整的操作步骤被从中间切开前半段说第一步打开设置后半段说第三步保存中间那步跑到别的块里去了。检索时你只拿到半截模型自然答不全。问题二块太大或太小。块太大一个块里混了好几个主题向量被平均了检索时哪个主题都不够突出块太小一句话一个块缺乏上下文模型拿到也不知道在说什么。我的经验值是这样的基于常见实践具体要按你的文档类型调文档类型推荐块大小重叠切分依据技术文档/API300-500 token50-80 token按标题层级法律/合同500-800 token100 token按条款编号对话记录200-400 token30 token按轮次通用知识库400-600 token60 token按段落标题注意单位是token 不是字符。中文里 1 个 token 大约对应 1.5-2 个汉字英文里 1 个 token 约 4 个字符。用字符数切块中英文混排时会切得乱七八糟。3.3 递归切分目前最稳的通用策略我实测下来最稳的通用策略是递归字符切分先按最粗的边界段落切如果某段还是太大再按句子切还大就按字符切。这样能最大程度保住语义边界。用 TypeScript 手写一个简化版function recursiveSplit( text: string, maxSize: number, separators: string[] [\n\n, \n, 。, , , ., , ] ): string[] { const [sep, ...rest] separators; if (text.length maxSize || sep undefined) { return [text]; } const parts sep ? text.split() : text.split(sep); const chunks: string[] []; let buffer ; for (const part of parts) { const candidate buffer ? buffer sep part : part; if (candidate.length maxSize) { buffer candidate; } else { if (buffer) chunks.push(buffer); // 单个 part 就超长递归用更细的分隔符 if (part.length maxSize) { chunks.push(...recursiveSplit(part, maxSize, rest)); buffer ; } else { buffer part; } } } if (buffer) chunks.push(buffer); return chunks; }这段代码的核心思想是优先在语义边界处切实在不行才硬切。分隔符数组的顺序就是优先级从段落一路降到单字符。3.4 重叠给检索留一点容错空间切块时一定要留重叠overlap。原因很简单如果一句话正好被切在边界上前半句在块 A后半句在块 B那无论检索到哪个块信息都是残缺的。加上重叠后这句话会完整地出现在其中一个块里。重叠比例我一般取块大小的10%-15%。太小起不到作用太大则存储和检索成本飙升还会导致大量重复内容被检索出来。注意重叠不是万能的。如果文档本身结构清晰比如有明确的标题层级优先按结构切重叠可以少放甚至不放。结构化的切分永远优于机械的字符切分。3.5 一个我踩过的真实坑表格和代码块有一次处理一批技术文档里面大量 API 参数表格。按段落切分后表格被切成了好几块表头在块 A参数行在块 B。用户问某个参数是什么意思检索到块 B但块 B 里没有表头模型根本不知道那列是什么。解决办法是把表格和代码块当作原子单元不切分。在切块前先识别出表格和代码块给它们打标记切分时整块保留。如果表格实在太大就按行切但每一块都要重复带上表头。这个细节看起来小但对技术类知识库的检索质量影响巨大。4. 向量化与存储选型背后的取舍逻辑4.1 Embedding 模型怎么选向量化的本质是把文本变成一串浮点数。选 Embedding 模型时我关注四个维度语言支持中文场景必须选中文能力强的很多英文模型在中文上表现断崖式下跌。维度常见 768、1024、1536 维。维度越高表达力越强但存储和计算成本也越高。最大输入长度决定了你的块能切多大。如果模型只支持 512 token你切 800 token 的块就会被截断。成本与延迟本地模型免费但吃资源API 模型省事但按量计费。我的建议是先用一个成熟的中文 Embedding 模型跑通全流程别一上来就纠结哪个模型好零点几个百分点。检索质量的大头在切块和检索策略模型差异是次要的。4.2 向量数据库别被选型焦虑绑架市面上的向量库多到让人眼花有专门的如各类向量数据库有传统数据库加向量插件的还有内存版的。选型时按这个顺序问自己数据量多大十万条以内内存版或单机版完全够用别上分布式。要不要和现有数据库共存如果业务数据已经在某个关系库里优先用它的向量扩展省得维护两套系统。要不要元数据过滤几乎一定要。比如只在这个部门的文档里检索这要求向量库支持带过滤条件的检索。要不要持久化生产环境必须持久化别用纯内存。对于学习和中小项目我强烈建议先用内存向量库把逻辑跑通接口抽象好将来换库只改一个适配层。下面是一个统一的接口设计interface VectorStore { add(items: { id: string; vector: number[]; content: string; metadata: Recordstring, unknown }[]): Promisevoid; search( queryVector: number[], topK: number, filter?: Recordstring, unknown ): Promise{ id: string; content: string; score: number; metadata: Recordstring, unknown }[]; }有了这层抽象你从内存库换到任何生产级向量库业务代码一行不用动。4.3 相似度度量余弦还是点积向量检索要算两个向量有多像。常见的有余弦相似度和点积。余弦只看方向不看长度点积两者都看。关键点如果你的 Embedding 模型已经做了归一化向量长度为 1那余弦相似度和点积是等价的。很多模型默认归一化这时候用哪个都行。但如果没归一化用余弦更稳妥因为它对向量长度不敏感。我踩过的坑换了个 Embedding 模型忘了它没归一化检索结果全乱套。排查了半天才发现是度量方式的问题。换模型时一定要确认它的归一化情况和推荐的度量方式。4.4 存储时别忘了存原文一个常见错误向量库里只存向量和 ID原文存在别的地方。检索时先拿 ID 再回查原文多一次 IO还容易出不一致。我的做法是向量和原文存在一起。向量库通常支持存 metadata把原文塞进 metadata 或专门的字段。这样检索出来直接就能用链路短、出错少。代价是存储空间大一点但这点成本换来的是稳定性和开发效率非常值。5. 检索策略从能搜到到搜得准5.1 Top-K 不是越大越好检索时你会取最相似的 K 个块。新手容易觉得多取点总没错于是 K 设成 20、30。结果呢噪声块把真正相关的块淹没了模型被无关信息干扰答案质量反而下降。K 的取值要结合两个因素块的大小和模型上下文窗口。块小就多取几个块大就少取几个。我的经验起点是K4 到 6然后根据实际效果调。如果发现答案经常缺信息再往上加如果发现答案跑题就往下减。5.2 混合检索向量 关键词的双保险前面说过纯向量检索对精确匹配不敏感。解决办法是混合检索同时跑一路向量检索和一路关键词检索如 BM25然后把两路结果融合。融合最常用的算法是RRFReciprocal Rank Fusion倒数排名融合。它的逻辑很朴素一个文档在两路检索里排名都靠前那它就应该排最前。公式是score(d) Σ 1 / (k rank_i(d))其中rank_i(d)是文档 d 在第 i 路检索里的排名k 是平滑常数通常取 60。这个算法不需要两路分数可比只看排名非常鲁棒。function rrfFusion( resultLists: { id: string; content: string }[][], k 60 ): { id: string; content: string; score: number }[] { const scores new Mapstring, { content: string; score: number }(); for (const list of resultLists) { list.forEach((item, rank) { const prev scores.get(item.id) ?? { content: item.content, score: 0 }; prev.score 1 / (k rank 1); scores.set(item.id, prev); }); } return [...scores.entries()] .map(([id, v]) ({ id, content: v.content, score: v.score })) .sort((a, b) b.score - a.score); }我实测下来混合检索在包含专有名词、编号、代码标识符的场景里比纯向量检索强一大截。因为这些内容恰恰是向量检索的盲区。5.3 重排把最相关的顶到最前面检索出来的 Top-K 是按粗相似度排的不够精细。重排Rerank用一个更精细的模型对这 K 个候选重新打分排序。重排模型和 Embedding 模型不一样Embedding 是问题和文档分别编码再算距离快但粗重排是问题文档一起送进模型慢但准。所以典型做法是用 Embedding 快速召回几十个候选再用重排精选出最相关的几个。代价是延迟增加。如果对响应速度要求极高可以跳过重排或者只对 Top-10 做重排。我的建议是只要延迟允许重排几乎总是值得的它带来的质量提升通常比换 Embedding 模型更明显。5.4 查询改写用户问得烂你得帮他问好用户的问题往往很口语、很模糊。比如那个东西怎么弄你根本不知道那个东西是什么。查询改写Query Rewriting就是用 LLM 把用户问题改写成更适合检索的形式。常见手法有三种扩展把报销扩展成报销 差旅费 费用标准 审批流程。分解把对比 A 和 B 的优缺点拆成A 的优点A 的缺点B 的优点B 的缺点分别检索。消解指代结合对话历史把它替换成具体名词。在 Agent 场景里查询改写尤其重要因为 Agent 经常要基于多轮对话来检索。一个不带历史上下文的检索在多轮对话里几乎必然失败。6. 把管道接进 Agent从检索到生成6.1 Prompt 怎么拼检索到内容后要拼进 Prompt 交给模型。这里有个关键原则明确告诉模型只根据给定资料回答资料里没有就说不知道。否则模型会用自己的训练知识脑补把检索到的资料和记忆混在一起产生难以察觉的错误。一个我常用的模板结构你是一个基于资料回答问题的助手。 【资料】 {检索到的内容每段带来源标记} 【问题】 {用户问题} 【要求】 1. 只根据上面的资料回答不要使用资料之外的知识。 2. 如果资料中没有相关信息直接回答资料中未提及。 3. 回答时标注引用的资料来源编号。第 3 条要求引用来源不仅方便用户核实还能倒逼模型真的去看资料而不是瞎编。6.2 上下文窗口的预算管理模型的上下文窗口是有限的。检索内容 对话历史 系统提示 用户问题加起来不能超。很多人只算检索内容忘了对话历史也在吃窗口。我的做法是给各部分分配预算系统提示固定检索内容占大头比如 60%对话历史按需截断保留最近几轮用户问题留足。一旦超预算优先砍对话历史其次砍检索内容里排名靠后的块。6.3 引用溯源让答案可验证生产级 RAG 必须支持引用溯源答案里的每句话能对应回原始文档的哪一段。实现方式是在拼 Prompt 时给每个块编号要求模型在答案里标注编号前端再把编号渲染成可点击的链接。这件事的价值不只是好看。当用户能点开来源核对时他们对系统的信任度会大幅提升当答案出错时你也能快速定位是检索错了还是生成错了。没有溯源的 RAG出了问题你连从哪查起都不知道。6.4 一个完整的检索函数把前面的东西串起来一个完整的检索流程大概长这样async function retrieve( query: string, store: VectorStore, embed: (text: string) Promisenumber[], options: { topK: number; useHybrid: boolean; useRerank: boolean } ) { // 1. 查询改写可选 const rewritten await rewriteQuery(query); // 2. 向量检索 const queryVec await embed(rewritten); const vectorResults await store.search(queryVec, options.topK * 3); // 3. 关键词检索可选 let fused vectorResults; if (options.useHybrid) { const keywordResults await keywordSearch(rewritten, options.topK * 3); fused rrfFusion([vectorResults, keywordResults]); } // 4. 重排可选 let final fused.slice(0, options.topK); if (options.useRerank) { final await rerank(rewritten, fused.slice(0, options.topK * 2), options.topK); } return final; }注意每一步都留了开关。不要一上来就把所有高级特性全开先用最简版本跑通再逐个加上去每加一个测一次效果。这样你才知道每个特性到底有没有用。7. 效果评估与调优别靠感觉靠数据7.1 建立一个小而准的评测集RAG 调优最大的敌人是感觉好像变好了。你必须有一批带标准答案的测试问题每次改动后跑一遍看指标变化。评测集不用大50-100 条就够起步。关键是覆盖你的真实场景简单事实查询、多跳推理、精确匹配、模糊口语化提问各来一些。每条包含问题、期望检索到的文档 ID、期望答案要点。7.2 两个核心指标检索命中率Hit Rate期望文档有没有出现在检索结果里。这是检索环节的指标。答案正确率最终答案对不对。这是端到端指标。这两个要分开看。如果命中率高但答案错问题在生成环节Prompt 或模型如果命中率低问题在检索环节切块、Embedding、检索策略。分开定位才能对症下药。7.3 常见问题与对应调法现象可能原因调法检索不到相关内容块太大/太小、Embedding 不匹配调块大小、换 Embedding检索到但答案跑题Prompt 约束不够强化只用资料约束精确查询失败纯向量检索盲区加关键词混合检索答案缺细节Top-K 太小增大 K 或加重排答案混入无关信息Top-K 太大、噪声多减小 K、加重排多轮对话检索失败缺上下文加查询改写这张表是我踩坑踩出来的基本覆盖了 80% 的常见问题。遇到问题先对号入座能省大量瞎调的时间。7.4 增量更新知识是会变的真实业务里文档每天都在变。你不能每次都全量重建索引。增量更新要解决的是新增文档怎么加、修改文档怎么更新、删除文档怎么清理。我的做法是给每个块一个稳定的 ID比如文档ID 块序号更新时先按文档 ID 删掉旧块再插入新块。删除同理。这样能保证索引和源数据一致。千万别用随机 ID否则你根本没法定位和清理旧数据。8. 我在实际项目里攒下的几条经验第一条先把最简单的版本跑通再谈优化。我见过太多人一上来就上混合检索、重排、查询改写结果基础切块都没做好调了半天不知道问题出在哪。正确的顺序是朴素向量检索 → 加混合 → 加重排 → 加查询改写每步都测。第二条切块质量决定天花板。如果切块把语义切碎了后面再花哨的检索策略也救不回来。花在切块上的时间回报率是最高的。第三条metadata 是隐藏的宝藏。时间、来源、部门、文档类型这些字段在过滤和溯源时价值巨大。加载阶段就顺手存好别等到需要时才发现没存。第四条评测集要早建。别等系统做完了才想怎么评估。从第一天起就攒测试问题每次改动都跑一遍。没有评测集的调优本质上是在赌博。第五条延迟和质量的平衡要提前想清楚。重排、查询改写都会增加延迟。如果你的场景要求秒级响应就得在质量上做取舍。这个取舍要在设计阶段就定别等上线了才发现太慢。最后分享一个我常用的小技巧在检索结果里保留相似度分数并在日志里记录每次检索的 Top-K 和分数分布。当用户反馈答得不对时翻日志就能看出是检索没召回还是召回了但分数都很低说明知识库里根本没有还是召回了高分但生成错了。这个日志习惯帮我省了无数次盲猜的时间。RAG 这条管道说穿了就是把找资料和用资料这两件事做到极致。工具会变模型会换但切好块、检得准、拼得对、可验证这四件事是任何 RAG 系统都绕不开的基本功。把这篇里的每一步都亲手跑一遍你对 Agent 的知识获取就有了真正扎实的底子。