ARTICLE DETAIL

资讯详情

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

TypeScript 实战:从零搭建 AI Agent 的 RAG 知识获取管道

TypeScript 实战:从零搭建 AI Agent 的 RAG 知识获取管道 1. 为什么知识获取管道是 AI Agent 的分水岭做 AI Agent 开发的人迟早会撞上一堵墙模型本身很聪明但它不知道你公司内部的业务规则、不知道你上周刚更新的产品文档、更不知道你那个跑了八年的老系统里埋着什么样的字段命名习惯。你问它一个非常具体的问题它要么一本正经地胡说八道要么礼貌地告诉你“我无法获取实时信息”。这不是模型不行而是你缺了一条管道——把外部知识喂给模型的那条管道。这条管道业内叫RAG全称检索增强生成。名字听着唬人拆开看特别朴素检索就是去你的知识库里找相关资料增强就是把找到的资料塞进模型的上下文里生成就是让模型基于这些资料来回答问题。三个动作串起来就是一条完整的知识获取管道。我见过太多团队在搭建 AI Agent 时把百分之八十的精力花在提示词调优和工具调用上结果上线后发现用户问十个问题有六个答不准。根因往往不在模型而在知识获取管道没搭好。模型再强你给它喂的是过期文档、错误切片、无关段落它也只能输出垃圾。Garbage in, garbage out这句话在 RAG 场景下比任何时候都成立。这篇文章面向的是正在从零搭建 AI Agent 的开发者尤其是用TypeScript做技术栈的团队。我会把 RAG 的基础链路拆开从文档加载、文本切片、向量化、存储、检索到最终拼装上下文每一步讲清楚为什么这么做、怎么做、容易踩什么坑。你不需要有向量数据库的经验也不需要懂 embedding 的数学原理只要你会写 TypeScript跟着走就能把这条管道跑通。提示RAG 不是银弹。它解决的是“模型不知道特定知识”的问题不解决“模型推理能力不足”的问题。如果你的 Agent 连基本的逻辑推理都做不好先别急着上 RAG先把模型选型和提示词工程做扎实。2. RAG 基础链路的整体设计与选型思路2.1 一条完整的知识获取管道长什么样很多人把 RAG 理解成“向量数据库 相似度搜索”这个理解太窄了。一条真正能用的知识获取管道至少包含六个环节文档加载把 PDF、Markdown、HTML、数据库记录等各种来源的内容读进来统一成纯文本。文本切片把长文档切成合适大小的块每块既要语义完整又不能超出模型的上下文窗口。向量化用 embedding 模型把每个文本块转成一串浮点数也就是向量。存储与索引把向量和原始文本一起存进向量数据库建立索引以便快速检索。检索用户提问时把问题也向量化去数据库里找最相似的若干个文本块。上下文拼装与生成把检索到的文本块按一定策略拼进提示词交给大模型生成最终回答。这六个环节里切片和检索是最容易出问题的两个。切片切得不好语义被拦腰截断检索出来的东西驴唇不对马嘴检索策略太单一只做向量相似度遇到关键词精确匹配的场景就会漏掉关键信息。后面我会逐个展开。2.2 为什么用 TypeScript 来做这条管道选 TypeScript 做 RAG 管道有几个非常实际的理由。第一如果你的 AI Agent 本身是 Web 服务或者 Node.js 后端用 TypeScript 可以做到前后端同构embedding 调用、向量检索、提示词拼装全在一套语言里完成不用在 Python 和 JavaScript 之间来回切换。第二TypeScript 的类型系统在拼装复杂上下文时非常有用检索结果的结构、元数据的字段、提示词模板的参数全都可以用类型约束住减少运行时错误。第三Node.js 生态里有不少成熟的向量数据库客户端和 LLM SDK比如 LangChain.js、LlamaIndex.TS开箱即用。当然Python 生态在 RAG 领域确实更丰富很多最新的 embedding 模型和检索算法都是 Python 先落地。但如果你团队的主力技术栈是 TypeScript硬切 Python 带来的协作成本可能比收益更大。我的建议是管道用 TypeScript 写如果遇到某个特定算法只有 Python 实现再单独起一个微服务调用不要为了一个功能把整个技术栈推翻。2.3 向量检索和关键词检索到底选哪个这是新手最容易纠结的问题。向量检索擅长语义匹配你问“怎么退款”它能找到“退货流程说明”即使两者没有一个字相同。关键词检索擅长精确匹配你搜“订单号 A12345”它能精准定位到包含这个订单号的记录而向量检索可能会给你一堆语义相似但订单号不对的结果。实际项目中混合检索才是正解。先用向量检索召回一批语义相关的候选再用关键词检索补充精确匹配的结果最后用重排序模型把两路结果合并排序。LangChain.js 里已经内置了EnsembleRetriever可以把多个检索器的结果按权重融合用起来很方便。注意混合检索不是简单地把两路结果拼在一起。你需要考虑权重分配、去重策略、以及最终返回给模型的数量。返回太多会撑爆上下文窗口返回太少可能漏掉关键信息。一般建议最终返回 3 到 8 个文本块具体数量取决于你的切片大小和模型上下文窗口。3. 核心细节解析与实操要点3.1 文档加载别小看这一步文档加载看起来最简单实际上坑最多。PDF 里的表格、扫描件里的图片文字、HTML 里的导航栏和广告这些噪声如果不处理会直接污染你的知识库。我见过一个项目把产品手册的 PDF 直接丢进去结果检索出来的内容里夹杂着页眉页脚的版权声明和页码模型回答问题时把这些也当成了有效信息。在 TypeScript 里加载不同格式的文档需要不同的库。Markdown 和纯文本直接用fs.readFile就行PDF 可以用pdf-parse或者pdfjs-distHTML 可以用cheerio提取正文。如果你用的是 LangChain.js它提供了PDFLoader、TextLoader、CheerioWebBaseLoader等封装好的加载器省去不少手写解析的功夫。加载完之后一定要做清洗。至少要做这几件事去掉多余的空白字符和换行、去掉页眉页脚、把连续的短行合并成段落、统一标点符号。清洗的规则要根据你的文档来源定制没有万能方案。我的习惯是先把加载后的文本打印出来看一遍肉眼扫一遍噪声长什么样再针对性地写清洗逻辑。3.2 文本切片RAG 效果的分水岭切片策略直接决定了检索质量的上限。切得太碎每个块只有一两句话语义不完整检索出来也拼不成有意义的上下文切得太粗一个块几千字里面混杂了好几个主题向量化之后语义被平均掉检索精度下降。最常用的切片策略是递归字符切片。它的思路是先按段落切如果某个段落还是太长再按句子切如果句子还是太长再按字符切。这样能尽量保证每个块在语义边界上断开。LangChain.js 的RecursiveCharacterTextSplitter就是这个策略的实现你可以指定chunkSize和chunkOverlap两个参数。chunkSize是每个块的最大字符数chunkOverlap是相邻块之间的重叠字符数。重叠的作用是防止关键信息刚好落在切割点上被切成两半。我的经验值是chunkSize 设在 500 到 1000 字符之间chunkOverlap 设在 chunkSize 的 10% 到 20%。如果你的文档是技术文档、法律条款这种逻辑严密的类型chunkSize 可以小一点保证每个块聚焦一个点如果是叙述性的文章chunkSize 可以大一点保留更多上下文。import { RecursiveCharacterTextSplitter } from langchain/text_splitter; const splitter new RecursiveCharacterTextSplitter({ chunkSize: 800, chunkOverlap: 120, separators: [\n\n, \n, 。, , , ., , ], }); const chunks await splitter.splitText(rawText);注意separators的顺序很重要它决定了切片的优先级。中文文档要把中文标点放在前面否则会按空格切把句子切得乱七八糟。3.3 向量化选对 embedding 模型向量化的质量取决于 embedding 模型。选模型时主要看三个指标语义表达能力、向量维度、推理成本。语义表达能力决定了相似度计算的准确度向量维度影响存储成本和检索速度维度越高存储越大但表达能力通常越强推理成本包括调用费用和延迟如果你有大量文档要处理这个成本不能忽略。对于中文场景我一般推荐先用通用的多语言 embedding 模型跑一版基线看看检索效果。如果效果不理想再考虑针对中文优化的模型。选模型时不要只看排行榜一定要用你自己的数据做评测。我见过排行榜上排名很高的模型在特定领域的垂直语料上表现还不如一个中等模型因为排行榜的评测集和你的业务数据分布可能差很远。在 TypeScript 里调用 embedding 模型通常是通过 HTTP API。你需要把文本分批发送每批不要超过模型的最大输入长度。批处理的时候要注意并发控制别一次性发几百个请求把 API 限流了。我的做法是用p-limit这样的库控制并发数一般设在 5 到 10 之间比较稳妥。import pLimit from p-limit; const limit pLimit(5); const embeddings await Promise.all( chunks.map((chunk) limit(() embedText(chunk)) ) );3.4 向量存储选本地还是选云服务向量数据库的选择取决于你的部署场景。如果是本地开发或者小规模应用用内存向量存储就够了比如 LangChain.js 的MemoryVectorStore零配置重启数据就没了适合快速验证。如果要持久化可以用HNSWLib它把索引存到本地文件检索速度也很快。如果是生产环境数据量大、需要多实例共享那就得用独立的向量数据库服务。选型时重点看这几点是否支持元数据过滤、是否支持混合检索、是否有成熟的 TypeScript 客户端、运维成本如何。元数据过滤非常重要比如你只想在某个产品线的文档里检索就需要按product_line字段过滤如果数据库不支持这个你就得把所有结果捞回来再在应用层过滤性能会很差。提示不要一上来就上重型向量数据库。先用内存存储把链路跑通验证检索效果等数据量和并发上来了再迁移。迁移成本没有你想象的那么高因为向量数据本身是通用的换个数据库重新导入就行。4. 实操过程与核心环节实现4.1 从零搭建一条最小可用的 RAG 管道我现在带你走一遍完整的搭建流程。假设你有一个 Markdown 格式的产品文档目录要做一个能回答产品问题的 Agent。整个流程分五步加载文档、切片、向量化、存入向量库、检索并生成回答。第一步加载文档。遍历目录下所有.md文件读成字符串同时记录每个文件的路径作为元数据。元数据在后续检索时可以用来过滤和溯源非常重要。import fs from fs/promises; import path from path; interface RawDoc { content: string; metadata: { source: string }; } async function loadDocs(dir: string): PromiseRawDoc[] { const files await fs.readdir(dir); const docs: RawDoc[] []; for (const file of files) { if (!file.endsWith(.md)) continue; const fullPath path.join(dir, file); const content await fs.readFile(fullPath, utf-8); docs.push({ content, metadata: { source: fullPath } }); } return docs; }第二步切片。对每个文档调用切片器把长文本切成块同时把元数据继承到每个块上。这样检索出来的每个块都知道自己来自哪个文件。async function splitDocs(docs: RawDoc[]) { const splitter new RecursiveCharacterTextSplitter({ chunkSize: 800, chunkOverlap: 120, }); const allChunks []; for (const doc of docs) { const chunks await splitter.splitText(doc.content); for (const chunk of chunks) { allChunks.push({ pageContent: chunk, metadata: doc.metadata }); } } return allChunks; }第三步向量化并存入向量库。这里用MemoryVectorStore做演示生产环境换成持久化的数据库即可。import { MemoryVectorStore } from langchain/vectorstores/memory; import { OpenAIEmbeddings } from langchain/openai; const embeddings new OpenAIEmbeddings({ modelName: text-embedding-3-small, }); const vectorStore await MemoryVectorStore.fromDocuments( allChunks, embeddings );第四步检索。用户提问时把问题向量化去向量库里找最相似的几个块。const retriever vectorStore.asRetriever({ k: 5, }); const relevantDocs await retriever.invoke(如何申请退款);第五步拼装上下文并生成回答。把检索到的文本块拼成一个字符串塞进提示词模板交给大模型。const context relevantDocs .map((doc) doc.pageContent) .join(\n\n---\n\n); const prompt 你是一个产品客服助手。请根据以下资料回答用户问题。 如果资料中没有相关信息请如实告知不要编造。 资料 ${context} 用户问题如何申请退款; const answer await llm.invoke(prompt);这五步跑通你就有了一个最小可用的 RAG 管道。但能用和好用之间还差很多调优工作。4.2 检索参数怎么调k 值、阈值和重排序k值是检索返回的文本块数量。设太小可能漏掉关键信息设太大无关内容会稀释有效信息还可能撑爆上下文窗口。我的做法是先设k5跑一版然后看检索结果的相关性。如果前三个都很相关后两个明显跑题就把k降到 3如果经常出现关键信息没被召回的情况就升到 8 或 10同时加一个相似度阈值过滤掉低分结果。相似度阈值是另一个重要参数。向量检索总会返回k个结果哪怕这些结果和问题毫不相关。加一个阈值比如只保留相似度大于 0.7 的结果可以过滤掉大量噪声。但阈值设太高会漏召回设太低等于没设。这个值需要根据你的 embedding 模型和数据类型来调没有通用值。重排序是提升检索精度的利器。它的思路是先用向量检索召回一批候选比如 20 个然后用一个重排序模型对这 20 个候选重新打分排序取前 5 个给大模型。重排序模型比 embedding 模型更重但只对少量候选打分总成本可控。实测下来加了重排序之后检索精度通常能提升 10% 到 20%。4.3 上下文拼装的三个实用技巧检索到相关文本块之后怎么拼进提示词也有讲究。第一个技巧是加来源标注。在每个文本块前面加上来源文件名模型回答时可以引用来源用户也能追溯。第二个技巧是按相关性排序。把最相关的块放在最前面因为模型对上下文开头的注意力通常更强。第三个技巧是控制总长度。拼装之前先算一下总字符数如果超出模型上下文窗口的限制就动态减少返回的块数。function buildContext(docs: { pageContent: string; metadata: any }[]) { const MAX_CHARS 6000; let total 0; const parts: string[] []; for (const doc of docs) { const part 【来源${doc.metadata.source}】\n${doc.pageContent}; if (total part.length MAX_CHARS) break; parts.push(part); total part.length; } return parts.join(\n\n---\n\n); }注意上下文不是越长越好。有研究表明当上下文超过一定长度后模型对中间部分的注意力会下降关键信息如果落在中间位置反而容易被忽略。所以宁可少而精不要多而杂。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么一步步排查检索不相关是最常见的问题。排查时按这个顺序走先看切片质量把检索到的块打印出来看内容是否语义完整、是否包含答案再看 embedding 模型是否适合你的语言和领域可以拿几个典型问题手动算一下相似度然后看k值和阈值是否合理最后看是否需要加关键词检索或重排序。我遇到过一个典型案例用户问“怎么修改绑定的手机号”检索出来的全是“如何注册账号”。排查发现切片时把“注册”和“修改手机号”切到了同一个块里因为它们在文档里是相邻的小节而切片器按固定长度切没有识别出标题边界。解决办法是在切片时把 Markdown 标题作为强制分隔符保证每个小节独立成块。5.2 模型回答“我不知道”但资料里明明有这种情况通常是检索没召回或者召回了但模型没注意到。先确认检索结果里有没有包含答案的块如果没有就是检索环节的问题按上面的排查顺序走。如果有但模型还是说不知道可能是提示词的问题。检查你的提示词是否明确要求模型“基于资料回答”以及是否给了模型足够的指令来定位信息。还有一个隐蔽的原因资料里的表述和用户提问的表述差异太大。比如资料里写的是“解除手机绑定”用户问的是“怎么换手机号”。向量检索对这种语义差异的容忍度有限如果 embedding 模型不够强就可能召回失败。解决办法是在切片时给每个块加上标题或摘要增强语义信号或者用查询改写把用户问题改写成多个不同表述再分别检索。5.3 常见问题速查表问题现象可能原因排查方向解决思路检索结果完全不相关切片太碎或太粗打印检索块看内容调整 chunkSize 和分隔符关键词精确匹配失败只用了向量检索测试含专有名词的查询加入关键词检索做混合模型回答编造信息提示词未约束检查提示词模板明确要求“仅基于资料回答”响应速度慢检索块太多或模型太大看各环节耗时减少 k 值、换轻量模型相似问题召回不一致embedding 模型不稳定同一问题多次检索换模型或加缓存上下文超长报错拼装未做长度控制统计上下文字符数动态截断或减少块数5.4 几个我踩过的坑第一个坑是元数据丢失。切片之后忘了把元数据继承到每个块上导致检索出来的结果不知道来源没法做过滤也没法溯源。这个错误很低级但很常见写代码时一定要检查。第二个坑是embedding 模型和检索模型不一致。入库时用了一个模型检索时用了另一个模型向量空间不匹配相似度计算完全失效。这个错误不会报错只会表现为检索结果莫名其妙排查起来很费时间。一定要确保入库和检索用的是同一个 embedding 模型。第三个坑是忽略文档更新。知识库里的文档更新了但向量库没有重新索引导致检索到的是旧内容。生产环境一定要建立文档更新触发重新索引的机制可以是定时任务也可以是文件变更监听。第四个坑是过度依赖向量检索。有些团队把所有检索都交给向量相似度结果遇到订单号、错误码、产品型号这类精确匹配的场景就翻车。记住向量检索和关键词检索是互补的不是替代关系。6. 从基础 RAG 到 Agentic RAG 的演进方向基础 RAG 跑通之后你会遇到新的瓶颈用户的问题需要多步推理或者需要结合多个知识源或者需要根据中间结果动态调整检索策略。这时候就该考虑Agentic RAG了。它的核心思路是把检索本身也交给 Agent 来决策——Agent 判断需不需要检索、检索什么、检索几次、要不要换关键词重新检索。比如用户问“我们上个季度退款率最高的产品是什么它的退款政策是怎样的”这个问题需要两步先查退款率数据再查对应产品的退款政策。基础 RAG 一次性检索很难同时召回这两类信息而 Agentic RAG 可以先检索退款率数据根据结果确定产品再检索该产品的退款政策。实现 Agentic RAG 的关键是给 Agent 提供检索工具并设计好工具调用的提示词。在 TypeScript 里可以用 LangChain.js 的 Agent 框架把 retriever 包装成一个 tool让 Agent 自主决定何时调用。这个方向的内容比较多后面可以单独展开讲。提示不要一上来就做 Agentic RAG。基础 RAG 的切片、检索、拼装没调好Agentic RAG 只会让问题更复杂。先把基础链路的每个环节做到 80 分再考虑加 Agent 决策层。我个人在实际项目中的体会是RAG 的效果提升往往不来自换更贵的模型而来自把切片和检索这两个基础环节做扎实。我见过太多团队花大价钱买最贵的 embedding 和最大的模型结果切片策略一塌糊涂检索出来的东西根本没法用。反过来用中等模型配上精心调优的切片和混合检索效果往往超出预期。这条管道没有捷径每个环节都得亲手调、亲手测用你自己的数据去验证。
返回列表