拓冰建站拓冰建站
首页 / 资讯中心 / 正文

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

1. 为什么知识获取管道是 AI Agent 从玩具走向工具的分水岭做 AI Agent 开发的人大概都经历过这样一个阶段Demo 跑得飞起一旦接入真实业务数据就立刻露馅。你问它公司内部的报销标准它一本正经地编出一个数字你让它查某个产品的技术参数它把几个型号的参数混在一起说得头头是道。这不是模型不够聪明而是它压根不知道你的数据长什么样。这就是知识获取管道要解决的问题。在 AI Agent 的整个架构里知识获取管道承担的是“把外部信息变成模型能用的上下文”这个职责。没有这条管道Agent 就是一个只会背书的复读机有了这条管道它才有可能基于你的私有数据做出靠谱的判断。我见过太多团队在搭建 Agent 时把大量精力花在提示词工程和工具调用上却对知识获取管道敷衍了事。结果就是 Agent 在演示时表现惊艳上线后错误率居高不下。原因很简单提示词决定的是“怎么表达”工具决定的是“能做什么”而知识获取管道决定的是“知道什么”。三者缺一不可但知识获取是最容易被低估的那一环。这一篇作为 AI Agent 系列的第四篇重点聊 RAG 的基础设施建设。我不会一上来就丢一堆框架名词而是从“为什么需要 RAG”“RAG 的核心链路是什么”“用 TypeScript 怎么从零搭一条能跑通的知识获取管道”这几个角度把这件事讲透。如果你正在用 TypeScript 做 AI Agent 开发或者想从零到一搭建一个能接入私有知识库的 Agent这篇内容应该能帮你少走不少弯路。提示本文涉及的代码示例以 TypeScript 为主但核心思路与语言无关用 Python 或其他语言同样可以复现。2. RAG 到底在解决什么问题从模型的知识边界说起2.1 大模型的知识冻结与幻觉根源大语言模型有一个根本性的限制它的知识在训练完成的那一刻就被冻结了。你问它 2024 年之后发生的事情它要么说不知道要么就开始编。这不是模型故意骗人而是它的训练数据里确实没有这些信息但它的生成机制又要求它必须输出一个“看起来合理”的答案。更麻烦的是私有数据。你公司内部的规章制度、产品文档、客户案例这些东西从来没有出现在公开训练语料里模型不可能知道。你硬要问它它只能用通用知识去“猜”猜出来的结果往往似是而非。有人可能会说那我直接把文档内容贴到提示词里不就行了短文档确实可以这么干但一旦文档超过几千字你就会遇到两个问题一是上下文窗口有限二是成本急剧上升。而且每次提问都把所有文档塞进去既浪费 token 又容易让模型抓不住重点。RAG 的思路很直接既然模型不知道那我就在它回答问题之前先把相关的资料找出来放到它的上下文里。模型看到这些资料后再基于资料内容来回答。这样既绕开了知识冻结的问题又避免了把整个知识库塞进提示词的浪费。2.2 检索增强生成的核心链路拆解RAG 的全称是 Retrieval-Augmented Generation翻译过来就是“检索增强生成”。这个名字已经把它的工作方式说得很清楚了先检索再生成。检索负责从知识库里找到相关内容生成负责基于这些内容产出答案。整条链路可以拆成两个阶段。第一个阶段是离线索引也就是在你使用 Agent 之前先把知识库里的文档处理成可检索的格式。这个阶段包括文档加载、文本切分、向量化、存入向量数据库几个步骤。第二个阶段是在线查询也就是用户提问时系统先把问题向量化然后在向量数据库里找最相似的文本片段最后把这些片段和用户问题一起交给大模型生成答案。这两个阶段里离线索引决定了知识库的质量上限在线查询决定了响应速度和准确度。很多人只关注在线查询的优化却忽略了离线索引才是根基。如果文档切分得乱七八糟向量化模型选得不对后面再怎么优化检索策略也是白搭。2.3 为什么 TypeScript 生态值得关注提到 RAG 和 AI Agent 开发大多数人第一反应是 Python。确实Python 在 AI 领域的生态更成熟LangChain、LlamaIndex 这些框架都是 Python 优先。但如果你本身是做前端或全栈的TypeScript 其实是一个被低估的选择。TypeScript 做 RAG 有几个天然优势。第一类型系统能在编译期帮你发现很多低级错误尤其是在处理复杂的文档结构和元数据时类型检查能省下大量调试时间。第二如果你用 Next.js 或 Node.js 做后端整个技术栈可以统一不需要在 Python 和 TypeScript 之间来回切换。第三Vercel AI SDK 这类工具正在快速完善 TypeScript 的 AI 开发生态流式响应、工具调用这些功能都有现成的封装。当然TypeScript 生态也有短板比如某些向量数据库的客户端不如 Python 版本完善一些最新的 RAG 技术论文的参考实现也是 Python 的。但基础的知识获取管道用 TypeScript 完全够用而且对于前端背景的开发者来说上手成本更低。3. 知识获取管道的四个核心环节与 TypeScript 实现3.1 文档加载把各种格式的原始数据变成纯文本知识获取管道的第一步是把原始文档变成纯文本。这一步看起来简单实际上坑很多。你的知识库可能包含 PDF、Word、Markdown、HTML、Excel 等各种格式每种格式的解析方式都不一样。PDF 是最麻烦的。有些 PDF 是扫描件需要 OCR 才能提取文字有些 PDF 有复杂的表格和分栏直接提取会打乱阅读顺序还有些 PDF 的字体编码有问题提取出来全是乱码。我在实际项目里遇到过一份产品手册用常规工具提取出来的文字顺序完全错乱后来发现是因为 PDF 里用了多栏布局解析器按从左到右的顺序读取把两栏的内容混在了一起。对于 Markdown 和纯文本加载就简单得多直接读文件内容就行。HTML 需要去掉标签保留正文。Word 文档可以用 mammoth 这类库转成 HTML 再提取文本。在 TypeScript 里我通常会把文档加载封装成一个统一的接口不同格式用不同的 loader 实现。这样上层代码不需要关心底层是什么格式只需要调用 load 方法拿到文本和元数据。interface Document { content: string; metadata: { source: string; format: string; loadedAt: Date; [key: string]: unknown; }; } interface DocumentLoader { load(filePath: string): PromiseDocument[]; }元数据这部分很多人会忽略但它其实很重要。后面检索的时候你可能需要根据来源过滤或者把来源信息展示给用户。如果加载阶段没保留这些信息后面就补不回来了。3.2 文本切分切得好不好直接决定检索质量文本切分是 RAG 管道里最容易被低估的环节。很多人随便按固定长度切一刀就完事了结果检索出来的片段要么缺头少尾要么把不相关的内容混在一起。切分的核心目标是每个片段应该是一个语义完整的单元同时长度要适中。太短了信息量不够太长了检索精度下降。业界常用的 chunk size 在 500 到 1000 个 token 之间但这只是一个参考值具体要看你的文档类型和查询特点。我一般会采用递归切分的策略先按段落切如果某个段落还是太长再按句子切如果句子还是太长最后才按固定长度硬切。这样能最大程度保留语义完整性。function splitText( text: string, maxChunkSize: number 800, overlap: number 100 ): string[] { const paragraphs text.split(/\n\n/); const chunks: string[] []; let currentChunk ; for (const para of paragraphs) { if ((currentChunk para).length maxChunkSize) { currentChunk (currentChunk ? \n\n : ) para; } else { if (currentChunk) chunks.push(currentChunk); if (para.length maxChunkSize) { const sentences para.split(/(?[。.!?])/); let subChunk ; for (const sentence of sentences) { if ((subChunk sentence).length maxChunkSize) { subChunk sentence; } else { if (subChunk) chunks.push(subChunk); subChunk sentence; } } if (subChunk) chunks.push(subChunk); currentChunk ; } else { currentChunk para; } } } if (currentChunk) chunks.push(currentChunk); return chunks; }overlap 这个参数值得单独说一下。它指的是相邻两个片段之间重叠的字符数。为什么要重叠因为如果刚好在某个关键信息中间切开检索时可能两个片段都匹配不上。加上重叠后关键信息至少会完整出现在其中一个片段里。overlap 一般设为 chunk size 的 10% 到 20% 比较合适。还有一个经验如果你的文档有天然的层级结构比如 Markdown 的标题层级最好在切分时保留标题信息。这样检索出来的片段能知道它属于哪个章节展示给用户时也更清晰。3.3 向量化把文本变成可计算的高维向量向量化是 RAG 的技术核心。它的原理是把文本映射到一个高维空间里语义相近的文本在这个空间里的距离也相近。这样检索的时候只需要计算向量之间的距离就能找到语义上最相关的内容。做向量化需要用到 embedding 模型。常见的选择有 OpenAI 的 text-embedding-3-small、text-embedding-3-large以及各种开源模型。选模型时主要看三个指标维度、效果、成本。维度越高表达能力越强但存储和计算成本也越高。text-embedding-3-small 是 1536 维large 是 3072 维对于大多数场景来说 small 已经够用了。在 TypeScript 里调用 embedding API 很简单但有几个细节要注意。第一是批量处理不要一条一条调那样太慢。第二是错误重试网络请求难免失败要有重试机制。第三是速率限制很多 API 有 QPS 限制需要控制并发数。async function embedTexts( texts: string[], batchSize: number 100 ): Promisenumber[][] { const embeddings: number[][] []; for (let i 0; i texts.length; i batchSize) { const batch texts.slice(i, i batchSize); const response await fetch(https://api.openai.com/v1/embeddings, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.OPENAI_API_KEY}, }, body: JSON.stringify({ model: text-embedding-3-small, input: batch, }), }); const data await response.json(); embeddings.push(...data.data.map((d: any) d.embedding)); } return embeddings; }这里有个坑要提醒不同 embedding 模型产出的向量不能混用。如果你中途换了模型之前索引的向量全部要重新生成。所以选模型的时候要慎重尽量选一个效果和成本都满意的不要频繁更换。3.4 向量存储与检索选对数据库写好查询逻辑向量存到哪里小规模场景可以直接放内存或者用 SQLite 加扩展中等规模可以用 Chroma、Qdrant、Weaviate 这些专门的向量数据库大规模场景可以考虑 Milvus 或 Pinecone。选向量数据库时主要看几个维度数据量、查询延迟、过滤能力、运维成本。如果只是个人项目或者小团队内部使用Chroma 就够用了它支持本地运行不需要额外部署。如果要做生产级应用Qdrant 和 Weaviate 的过滤功能更完善能支持复杂的元数据过滤。检索的时候最基础的是余弦相似度搜索也就是找与查询向量最接近的 top-k 个片段。但实际应用中单纯靠向量相似度往往不够。比如用户问“2024 年的销售政策”你不仅需要语义匹配还需要按年份过滤。这时候就需要向量检索加元数据过滤的组合查询。async function retrieveRelevantChunks( query: string, topK: number 5, filters?: Recordstring, unknown ): PromiseArray{ content: string; score: number; source: string } { const queryEmbedding await embedTexts([query]); const results await vectorStore.search({ vector: queryEmbedding[0], topK, filter: filters, }); return results.map((r) ({ content: r.payload.content, score: r.score, source: r.payload.source, })); }topK 的选择也有讲究。太小了可能漏掉关键信息太大了会引入噪声。一般 3 到 5 是一个比较平衡的值。如果检索结果质量不稳定可以考虑加一个相似度阈值低于阈值的直接丢弃宁可少给也不要给错。4. 从零跑通一条最小可用管道TypeScript 实战4.1 项目结构与依赖选择说了这么多原理不如直接动手跑一遍。下面我用 TypeScript 搭一条最小可用的知识获取管道从加载文档到检索出结果完整走一遍。项目结构大概是这样rag-pipeline/ ├── src/ │ ├── loaders/ │ │ └── markdownLoader.ts │ ├── splitter.ts │ ├── embedder.ts │ ├── store.ts │ ├── retriever.ts │ └── index.ts ├── data/ │ └── knowledge.md ├── package.json └── tsconfig.json依赖方面核心需要这几个qdrant/js-client-rest用于向量存储openai用于调用 embedding APIgray-matter用于解析 Markdown 的 frontmatter。如果你不想用 Qdrant也可以用chromadb的 JavaScript 客户端本地跑更方便。npm init -y npm install qdrant/js-client-rest openai gray-matter npm install -D typescript tsx types/nodetsconfig 里记得把module设为ESNextmoduleResolution设为Bundler这样能避免一些模块解析的报错。如果你看到 TypeScript 提示moduleResolutionnode10已弃用那就是版本较新按提示改成Bundler或NodeNext即可。4.2 索引流程的完整代码实现先写一个简单的 Markdown 加载器把文件读进来并解析 frontmatterimport fs from fs/promises; import matter from gray-matter; export async function loadMarkdown(filePath: string) { const raw await fs.readFile(filePath, utf-8); const { content, data } matter(raw); return { content, metadata: { source: filePath, format: markdown, ...data, }, }; }然后是切分和向量化把前面写的函数串起来import { loadMarkdown } from ./loaders/markdownLoader; import { splitText } from ./splitter; import { embedTexts } from ./embedder; import { QdrantClient } from qdrant/js-client-rest; const client new QdrantClient({ url: http://localhost:6333 }); async function indexDocument(filePath: string) { const doc await loadMarkdown(filePath); const chunks splitText(doc.content, 800, 100); const embeddings await embedTexts(chunks); const points chunks.map((chunk, i) ({ id: i, vector: embeddings[i], payload: { content: chunk, source: doc.metadata.source, chunkIndex: i, }, })); await client.upsert(knowledge, { points }); console.log(Indexed ${points.length} chunks from ${filePath}); }这段代码跑通的前提是本地已经启动了 Qdrant 服务。如果你用 Docker一条命令就能起来docker run -p 6333:6333 qdrant/qdrant4.3 查询流程与结果验证索引完成后查询就简单了。把用户问题向量化然后去 Qdrant 里搜async function query(question: string, topK: number 3) { const [queryEmbedding] await embedTexts([question]); const results await client.search(knowledge, { vector: queryEmbedding, limit: topK, with_payload: true, }); return results.map((r) ({ content: r.payload?.content as string, score: r.score, source: r.payload?.source as string, })); }跑一下试试const results await query(报销标准是什么); console.log(results);如果一切正常你应该能看到几个相关的文本片段每个片段带一个相似度分数。分数越接近 1 表示越相关。如果分数普遍偏低说明要么切分有问题要么 embedding 模型不适合你的文档类型。验证检索质量有一个简单方法准备一组问题和对应的标准答案看检索出来的片段里是否包含答案。如果 top-3 里都找不到答案那说明索引阶段有问题需要回头检查切分策略和 embedding 模型。5. 那些文档里不会写的踩坑经验5.1 切分粒度与检索精度的权衡切分粒度这件事我踩过最大的坑是“一刀切”。最开始我按固定 500 字符切分结果发现有些片段把两个不相关的主题混在一起检索时经常匹配到一半相关一半不相关的内容。后来改成按段落切又发现有些段落特别长一个段落就超过 2000 字检索出来的片段信息密度太低。最后的方案是分层切分先按标题切分成大块再在大块内部按段落切段落太长再按句子切。这样每个片段既有明确的主题归属又不会太长。同时我在每个片段的开头加上了所属标题的路径比如“产品手册 报销制度 差旅报销”这样即使片段本身没有明确提到“差旅”检索时也能通过标题路径匹配上。还有一个细节中文和英文的切分策略不一样。英文按单词边界切分比较自然中文没有空格按字符数切分容易把词语切断。对于中文文档我建议按标点符号切分句号、问号、感叹号、分号都是天然的切分点。5.2 embedding 模型选型中的隐性成本选 embedding 模型时大多数人只看 API 价格但隐性成本往往更高。比如你选了一个效果很好的模型但它的维度是 3072存储成本是 1536 维模型的两倍检索时的计算量也更大。如果你的知识库有几十万条片段这个差距就很明显了。另一个隐性成本是迁移成本。如果你一开始用了某个模型后来发现效果不好想换所有向量都要重新生成。如果知识库很大重新索引可能需要几个小时甚至几天。所以我的建议是在项目初期就用真实数据做一次小规模测试对比几个候选模型的效果选定之后尽量不要换。还有一个容易被忽略的点embedding 模型对语言的支持。有些模型在英文上表现很好但中文效果一般。如果你的知识库是中英混合的一定要选多语言模型比如 text-embedding-3-small 对中文的支持就不错。5.3 检索结果为空或答非所问的排查思路检索结果为空或者答非所问是 RAG 系统最常见的问题。排查的时候我一般按这个顺序来先看查询本身。用户的问题是不是太短了比如只输入“报销”两个字信息量太少embedding 出来的向量可能跟任何片段都不太像。这时候可以考虑做查询扩展把短查询改写成更完整的句子再检索。再看索引数据。用同样的查询去向量数据库里搜看返回的分数分布。如果所有分数都很低说明要么 embedding 模型不适合要么索引数据本身有问题。可以手动拿一个已知相关的片段用它的内容去搜看能不能搜到自己。如果搜不到那肯定是索引阶段出了问题。最后看切分策略。有时候答案确实在知识库里但被切分到了两个片段里每个片段都只包含一半信息检索时都匹配不上。这时候需要调整切分参数或者增加 overlap。我遇到过一个典型案例用户问“年假有多少天”知识库里明明有“员工入职满一年后享有 5 天年假”这句话但检索就是搜不到。后来发现是因为切分时这句话被切成了“员工入职满一年后享有”和“5 天年假”两个片段查询“年假有多少天”跟这两个片段的相似度都不高。把 chunk size 调大之后问题就解决了。5.4 增量更新与索引一致性维护知识库不是一成不变的文档会新增、修改、删除。如果每次更新都全量重建索引成本太高。所以需要支持增量更新。增量更新的核心是给每个片段一个稳定的 ID。如果文档内容变了对应的片段 ID 也要变这样才能知道哪些片段需要更新。我一般用“文档路径 片段序号”作为 ID文档修改后重新切分对比新旧片段的 ID 集合新增的插入删除的移除修改的更新。但这里有个坑如果文档只是改了一个字切分后的片段可能完全不一样导致大量片段被判定为“修改”。这时候可以考虑用内容哈希作为 ID内容没变就不更新。但内容哈希的问题是如果文档里加了一句话导致后面所有片段都偏移了哈希也会全变。所以实际项目中我通常结合两者用路径加序号做主要 ID用内容哈希做变更检测。还有一个一致性问题是向量和原文的同步。如果向量更新了但原文没更新或者反过来检索出来的内容就会对不上。所以更新操作最好做成事务性的要么全成功要么全失败。Qdrant 支持批量操作可以把相关更新放在一个请求里。6. 知识获取管道之后RAG 的下一步往哪走6.1 从基础 RAG 到 Agentic RAG 的演进逻辑基础 RAG 的流程是线性的检索、拼接、生成。但真实场景中用户的问题往往需要多步推理。比如“对比一下 A 产品和 B 产品的报销政策差异”这就需要先分别检索两个产品的政策再做对比。基础 RAG 一次性检索可能只能拿到其中一个产品的信息。Agentic RAG 的思路是让 Agent 自己决定什么时候检索、检索什么、检索几次。Agent 可以先检索 A 产品的政策发现信息不够再检索 B 产品的政策最后综合两个结果生成答案。这种模式更灵活但也更复杂需要 Agent 具备规划和反思能力。从基础 RAG 到 Agentic RAG 的演进本质上是从“被动检索”到“主动获取”的转变。基础 RAG 是“你问什么我搜什么”Agentic RAG 是“我知道我需要什么我去找什么”。这个转变对知识获取管道提出了更高要求检索接口要更灵活要支持多轮检索要能处理检索失败的情况。6.2 知识图谱与向量检索的结合点纯向量检索有一个天然缺陷它擅长语义匹配但不擅长精确的关系推理。比如你问“张三的上级的上级是谁”向量检索很难直接回答因为这需要沿着“上级”这个关系走两步。知识图谱擅长这个但知识图谱构建成本高而且对自然语言查询的支持不如向量检索灵活。GraphRAG 这类方案试图把两者结合起来用知识图谱存储实体和关系用向量检索做入口。查询时先用向量检索找到相关实体再在知识图谱里做关系推理最后把推理结果交给大模型生成答案。这种混合方案在需要多跳推理的场景下效果明显更好但实现复杂度也更高。对于大多数应用场景来说基础 RAG 已经能解决 80% 的问题。剩下 20% 需要复杂推理的场景再考虑引入知识图谱。不要一上来就追求最复杂的方案先把基础管道跑通再根据实际需求逐步演进。6.3 评估体系怎么知道你的管道好不好用知识获取管道搭好之后怎么判断它好不好用不能只靠感觉需要有一套评估体系。最基础的指标是检索命中率对于一组测试问题检索出来的 top-k 片段里是否包含正确答案。这个指标衡量的是检索阶段的质量。一般要求 top-3 命中率在 80% 以上top-5 在 90% 以上。进阶一点的指标是答案准确率把检索结果交给大模型生成答案看生成的答案是否正确。这个指标衡量的是整条管道的端到端效果。评估时最好人工标注一批标准答案然后对比模型输出。还有一个容易被忽略的指标是响应延迟。检索加生成的总时间如果超过 3 秒用户体验就会明显下降。优化延迟可以从几个方面入手减少 top-k、使用更快的 embedding 模型、给向量数据库加缓存、流式返回生成结果。我一般会在项目初期就建一个小的评估集包含 20 到 50 个典型问题和标准答案。每次调整切分策略或换 embedding 模型后都跑一遍评估集看指标是升是降。这样能避免凭感觉做决策也能及时发现回归问题。7. 一些关于工程落地的个人体会做知识获取管道这件事技术选型只是一部分更多时候是在跟数据质量较劲。我见过太多项目框架选得很 fancy但知识库里的文档本身格式混乱、内容过时导致检索效果怎么调都上不去。所以在搭建管道之前先花时间清理和整理知识库往往比优化算法更有效。另一个体会是不要追求一步到位。我刚开始做 RAG 的时候总想一次性把所有优化都加上混合检索、重排序、查询改写、多路召回。结果系统复杂度飙升出了问题很难定位。后来学乖了先用最基础的方案跑通然后根据实际 bad case 逐个优化。每次只改一个变量改完跑评估集确认有效再继续。这样虽然慢一点但每一步都走得踏实。TypeScript 做 RAG 的生态确实不如 Python 丰富但核心环节都有可用的库。而且 TypeScript 的类型系统在维护大型项目时优势明显尤其是当你的知识库结构复杂、元数据字段很多的时候类型检查能帮你避免很多运行时错误。如果你本身是前端或全栈背景用 TypeScript 做 AI Agent 开发是一个很自然的选择。最后分享一个小技巧在检索结果里保留原文的链接或位置信息展示给用户时附上来源。这样即使用户发现答案有问题也能快速定位到原文去核实。这个小小的设计能大幅提升用户对系统的信任度也能帮你快速发现知识库里的错误内容。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门