☰
TypeScript 实战:从零搭建 AI Agent 的 RAG 知识获取管道
2026/9/29 19:55:38 网站建设 项目流程

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 理解成“向量数据库 + 相似度搜索”,这个理解太窄了。一条真正能用的知识获取管道,至少包含六个环节:

  1. 文档加载:把 PDF、Markdown、HTML、数据库记录等各种来源的内容读进来,统一成纯文本。
  2. 文本切片:把长文档切成合适大小的块,每块既要语义完整,又不能超出模型的上下文窗口。
  3. 向量化:用 embedding 模型把每个文本块转成一串浮点数,也就是向量。
  4. 存储与索引:把向量和原始文本一起存进向量数据库,建立索引以便快速检索。
  5. 检索:用户提问时,把问题也向量化,去数据库里找最相似的若干个文本块。
  6. 上下文拼装与生成:把检索到的文本块按一定策略拼进提示词,交给大模型生成最终回答。

这六个环节里,切片和检索是最容易出问题的两个。切片切得不好,语义被拦腰截断,检索出来的东西驴唇不对马嘴;检索策略太单一,只做向量相似度,遇到关键词精确匹配的场景就会漏掉关键信息。后面我会逐个展开。

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-dist;HTML 可以用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): Promise<RawDoc[]> { 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值是检索返回的文本块数量。设太小,可能漏掉关键信息;设太大,无关内容会稀释有效信息,还可能撑爆上下文窗口。我的做法是先设k=5跑一版,然后看检索结果的相关性。如果前三个都很相关,后两个明显跑题,就把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 和最大的模型,结果切片策略一塌糊涂,检索出来的东西根本没法用。反过来,用中等模型配上精心调优的切片和混合检索,效果往往超出预期。这条管道没有捷径,每个环节都得亲手调、亲手测,用你自己的数据去验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询