资讯详情

资讯详情

TypeScript 实战:从零构建 RAG 知识获取管道,打通 AI Agent 落地第一道坎

1. 为什么知识获取管道是 AI Agent 落地的第一道坎做 AI Agent 开发的人绕不开一个尴尬的现实模型本身很聪明但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私有的那套数据长什么样。你问它一个关于内部流程的问题它要么一本正经地胡说八道要么直接告诉你“我无法回答”。这不是模型不行而是它缺少一条把外部知识喂给它的管道。这条管道就是RAGRetrieval-Augmented Generation检索增强生成。名字听起来学术拆开看特别朴素检索 增强 生成。先从你的知识库里把相关内容捞出来再把捞出来的内容塞进提示词最后让模型基于这些内容生成回答。整个过程的核心价值在于——让模型的回答有据可依而不是凭空捏造。我见过太多团队在 Agent 项目上踩的第一个大坑就是一上来就折腾多智能体协作、工具调用编排、复杂的状态机结果基础的知识获取管道没搭好Agent 回答问题时满嘴跑火车用户用两次就再也不打开了。知识获取管道是 Agent 的地基地基不稳上面盖什么都是危楼。这篇内容适合谁看如果你正在用 TypeScript 做 AI Agent 开发想从零搭一套能用的 RAG 管道或者你已经有了一个 RAG 原型但效果不理想想找问题那接下来的内容应该能帮到你。我会从整体设计思路讲到具体实现细节包括分块策略、向量化选型、检索逻辑、重排序以及那些文档里不会写但实际开发中一定会遇到的坑。2. RAG 管道的整体设计与核心思路拆解2.1 一条完整的知识获取管道长什么样很多人对 RAG 的理解停留在“把文档切一切、向量化、存进向量库、查询时做相似度匹配”这个层面。这个理解不算错但太粗糙了。一条真正能用的知识获取管道至少包含以下几个环节文档加载从各种来源本地文件、数据库、API把原始内容读进来内容清洗去掉无关的格式标记、页眉页脚、重复内容分块把长文档切成适合检索和生成的小块向量化把文本块转成向量表示存储把向量和原文一起存进向量数据库检索根据用户查询找出最相关的文本块重排序对检索结果做二次排序把真正有用的排前面上下文组装把选中的文本块拼成提示词的一部分生成模型基于组装好的上下文生成回答这九个环节里分块和检索是最容易出问题的两个点。分块切得不好检索再准也捞不到完整信息检索策略太单一明明库里有的内容就是匹配不上。后面我会重点展开这两块。2.2 为什么选 TypeScript 来做这条管道热词里出现了 TypeScript这不是偶然。用 TypeScript 做 RAG 管道有几个实打实的好处类型安全在数据处理管道里价值巨大。RAG 管道涉及大量的数据结构转换——文档对象、文本块、向量、检索结果、上下文组装结果每一层都有不同的字段和类型。用 TypeScript 把这些类型定义清楚编译期就能发现大部分字段拼写错误和类型不匹配的问题。我试过用纯 JavaScript 写同样的逻辑调试时间至少多出三分之一。生态成熟度足够。Node.js 生态里有 LangChain.js、LlamaIndex.TS 这些专门做 RAG 的框架也有原生的向量数据库客户端。你不需要自己造轮子但需要理解这些轮子是怎么转的才能在出问题时知道去哪里找原因。前后端统一语言。如果你的 Agent 产品有前端界面用 TypeScript 可以让前后端共享类型定义。检索结果的格式、文档块的接口前后端用同一套类型联调时少吵很多架。注意TypeScript 5.x 之后一些旧的编译选项比如moduleResolution: node10已经被标记为弃用新项目建议直接用bundler或node16。这个细节在搭建项目时如果忽略后面升级 TypeScript 7.0 时会遇到编译报错。2.3 方案选型的几个关键决策点搭 RAG 管道时有几个决策会直接影响最终效果和开发成本我逐个说下我的选择和理由。向量数据库选型。可选的有 Pinecone、Weaviate、Qdrant、Chroma还有 Postgres 的 pgvector 扩展。我的建议是如果你只是做原型验证用 Chroma 或 pgvector 就够了部署简单、成本低如果要上生产且数据量在百万级以上Qdrant 或 Weaviate 的性能和运维成熟度更好。Pinecone 是托管服务省心但费用不低适合不想碰运维的团队。Embedding 模型选型。OpenAI 的 text-embedding-3-small 性价比很高1536 维的向量每百万 token 成本很低。如果对中文支持要求高可以考虑 BGE 系列或 M3E 这类中文优化的模型。选型时重点看两个指标检索命中率和向量维度。维度越高存储和计算成本越大但检索精度不一定线性提升。分块策略。固定长度分块最简单但效果往往最差。我推荐用递归分块按段落、句子、字符逐级切分尽量保持语义完整性。块大小控制在 500-1000 个 token 之间块之间保留 10%-20% 的重叠避免关键信息被切断。检索策略。纯向量检索在语义匹配上表现好但对精确关键词的匹配能力弱。我的做法是混合检索向量检索 关键词检索BM25两路结果合并后做重排序。这样既能匹配语义相似的内容也能精确命中专有名词和代码片段。3. 核心细节解析与实操要点3.1 文档分块切得好比检得准更重要分块是 RAG 管道里最容易被低估的环节。很多人随便按 500 字切一刀就完事了结果检索出来的块要么缺头少尾要么包含大量无关内容。我踩过的坑是一份产品需求文档按固定长度切分后某个功能点的描述被切成了两半检索时只命中了一半模型给出的回答缺了关键约束条件差点导致线上事故。递归分块的具体做法是这样的先按双换行段落切分如果某个段落还是太长再按单换行切分还长就按句号切分最后才按字符硬切。这样能最大程度保持语义单元的完整性。interface ChunkConfig { chunkSize: number; // 目标块大小token 数 chunkOverlap: number; // 块间重叠 token 数 separators: string[]; // 分隔符优先级列表 } const defaultConfig: ChunkConfig { chunkSize: 800, chunkOverlap: 120, separators: [\n\n, \n, 。, , , ., , ], };分块时有个细节要注意代码块和表格不要切开。如果你的知识库里有技术文档代码示例被切碎后检索出来毫无意义。我的做法是在分块前先识别代码块和表格把它们作为不可分割的单元处理如果超过块大小限制就单独成块并标记类型。另一个经验是给每个块加上元数据。至少包含来源文档名、块在文档中的位置、块的类型正文/代码/表格。这些元数据在检索时可以用来过滤比如用户问的是 API 用法就优先检索代码类型的块。3.2 向量化不只是调个 API 那么简单向量化看起来简单——把文本传给 Embedding API拿回一个浮点数数组。但实际做的时候有几个关键决策批量处理与速率控制。Embedding API 通常有速率限制如果你有几千个文档块要处理不能一次性全发出去。我的做法是用队列控制并发数同时做好失败重试。重试时要区分错误类型速率限制错误要退避重试内容格式错误重试也没用直接记录并跳过。async function embedWithRetry( texts: string[], maxRetries: number 3 ): Promisenumber[][] { for (let attempt 0; attempt maxRetries; attempt) { try { const response await embeddingClient.embed(texts); return response.embeddings; } catch (error) { if (isRateLimitError(error) attempt maxRetries - 1) { await sleep(Math.pow(2, attempt) * 1000); continue; } throw error; } } throw new Error(Embedding failed after max retries); }向量归一化。如果你用的是余弦相似度做检索记得把向量归一化到单位长度。有些向量数据库会自动做这件事有些不会。不归一化的话相似度计算会受向量模长影响导致检索结果偏差。我一般会在存入数据库前统一做 L2 归一化。缓存机制。开发阶段你会反复对同一批文档做向量化每次都调 API 既慢又费钱。我的做法是用内容哈希做缓存键把向量化结果存本地。内容没变就直接读缓存内容变了才重新调 API。这个小优化能让开发迭代速度快好几倍。3.3 检索策略单一向量检索不够用纯向量检索有个致命弱点它对精确匹配不敏感。比如用户搜“ERR_CONNECTION_REFUSED”向量检索可能返回一堆关于“网络错误处理”的文档但就是找不到包含这个具体错误码的那一篇。这时候就需要混合检索来补位。混合检索的实现思路是并行跑两路检索向量检索把查询向量化在向量库里找余弦相似度最高的 top-K 个块关键词检索用 BM25 或全文索引找包含查询关键词的 top-K 个块然后把两路结果合并用 RRFReciprocal Rank Fusion算法重新排序。RRF 的公式很简单每个文档的得分是1 / (k rank)的累加k 通常取 60。这个算法不需要调参对两路检索的分数尺度不敏感实测下来很稳。function reciprocalRankFusion( vectorResults: SearchResult[], keywordResults: SearchResult[], k: number 60 ): SearchResult[] { const scoreMap new Mapstring, number(); vectorResults.forEach((result, index) { const score 1 / (k index 1); scoreMap.set(result.id, (scoreMap.get(result.id) || 0) score); }); keywordResults.forEach((result, index) { const score 1 / (k index 1); scoreMap.set(result.id, (scoreMap.get(result.id) || 0) score); }); return Array.from(scoreMap.entries()) .sort((a, b) b[1] - a[1]) .map(([id]) allResultsMap.get(id)!); }检索数量怎么定。top-K 不是越大越好。K 太大无关内容会稀释有效信息模型反而容易被带偏K 太小可能漏掉关键内容。我的经验值是向量检索取 top-20关键词检索取 top-20融合后取 top-5 到 top-8 送给模型。具体数字要根据你的文档密度和查询复杂度来调。3.4 重排序把真正有用的排到最前面检索出来的 top-K 个块相关性是有差异的。向量相似度高不代表内容真的有用。重排序就是用一个小模型Cross-Encoder对查询和每个候选块做精细的相关性打分然后重新排序。重排序模型比 Embedding 模型慢但比大模型快得多。它只处理查询和候选块的组合计算量可控。我一般用 BGE-Reranker 或 Cohere Rerank效果提升很明显。实测下来加了重排序之后检索命中率能提升 15%-25%。注意重排序不是必须的。如果你的检索结果已经足够好或者对延迟极其敏感可以跳过这一步。但在知识库内容复杂、查询意图模糊的场景下重排序带来的收益远大于成本。4. 实操过程与核心环节实现4.1 项目初始化与依赖安装先把项目骨架搭起来。我用的是 Node.js TypeScript 的组合包管理用 pnpm比 npm 快磁盘占用也小。mkdir rag-pipeline cd rag-pipeline pnpm init pnpm add langchain langchain/openai langchain/community pnpm add -D typescript types/node tsxtsconfig.json的配置要注意几个点{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: ./dist, rootDir: ./src }, include: [src/**/*] }moduleResolution用bundler而不是已弃用的node10这是为了兼容现代打包工具也避免未来升级 TypeScript 7.0 时出问题。strict一定要开RAG 管道里类型错误很隐蔽严格模式能帮你提前发现。4.2 文档加载与清洗假设我们的知识库是一批 Markdown 文件。加载逻辑要处理编码问题、过滤空文件、提取元数据。import { readFileSync, readdirSync } from fs; import { join, basename } from path; interface RawDocument { content: string; metadata: { source: string; fileName: string; loadedAt: string; }; } function loadDocuments(dirPath: string): RawDocument[] { const files readdirSync(dirPath).filter((f) f.endsWith(.md)); const documents: RawDocument[] []; for (const file of files) { const filePath join(dirPath, file); const content readFileSync(filePath, utf-8); if (content.trim().length 0) { console.warn(Skipping empty file: ${file}); continue; } documents.push({ content: cleanContent(content), metadata: { source: filePath, fileName: basename(file), loadedAt: new Date().toISOString(), }, }); } return documents; } function cleanContent(raw: string): string { return raw .replace(/\r\n/g, \n) .replace(/\n{3,}/g, \n\n) .replace(/[ \t]$/gm, ) .trim(); }清洗这步看着简单但实际项目中经常被忽略。我遇到过文档里混入 HTML 标签、零宽字符、重复页眉的情况不清洗的话这些噪音会进入向量库影响检索质量。4.3 分块实现与参数调优分块用 LangChain 的RecursiveCharacterTextSplitter就够用但参数要根据你的文档特点调。import { RecursiveCharacterTextSplitter } from langchain/text_splitter; interface TextChunk { content: string; metadata: { source: string; chunkIndex: number; totalChunks: number; }; } async function splitDocuments( documents: RawDocument[] ): PromiseTextChunk[] { const splitter new RecursiveCharacterTextSplitter({ chunkSize: 800, chunkOverlap: 120, separators: [\n\n, \n, 。, , , ., , ], lengthFunction: (text) text.length, }); const allChunks: TextChunk[] []; for (const doc of documents) { const splits await splitter.splitText(doc.content); splits.forEach((content, index) { allChunks.push({ content, metadata: { source: doc.metadata.source, chunkIndex: index, totalChunks: splits.length, }, }); }); } return allChunks; }参数怎么调我的经验是中文文档的 chunkSize 可以比英文小一些因为中文信息密度高800 个字符已经包含不少内容了。chunkOverlap 设成 chunkSize 的 15% 左右比较合适太小了上下文断裂太大了存储和检索成本上升。4.4 向量化与存储向量化用 OpenAI 的 embedding 接口存储用内存向量库做演示生产环境换成 Qdrant 或 pgvector。import { OpenAIEmbeddings } from langchain/openai; import { MemoryVectorStore } from langchain/vectorstores/memory; import { Document } from langchain/document; async function buildVectorStore( chunks: TextChunk[] ): PromiseMemoryVectorStore { const embeddings new OpenAIEmbeddings({ modelName: text-embedding-3-small, batchSize: 100, }); const documents chunks.map( (chunk) new Document({ pageContent: chunk.content, metadata: chunk.metadata, }) ); const store await MemoryVectorStore.fromDocuments( documents, embeddings ); return store; }batchSize设成 100 是个折中值。太小了请求次数多太大了容易触发速率限制。实际跑的时候要根据 API 返回的速率限制头动态调整。4.5 检索与上下文组装检索部分把向量检索和关键词检索结合起来。关键词检索可以用简单的 BM25 实现也可以用数据库的全文索引。interface RetrievalResult { content: string; metadata: Recordstring, unknown; score: number; } async function hybridRetrieve( query: string, vectorStore: MemoryVectorStore, keywordIndex: KeywordIndex, topK: number 5 ): PromiseRetrievalResult[] { const [vectorResults, keywordResults] await Promise.all([ vectorStore.similaritySearchWithScore(query, 20), keywordIndex.search(query, 20), ]); const fused reciprocalRankFusion( vectorResults.map(([doc, score]) ({ id: doc.metadata.source doc.metadata.chunkIndex, content: doc.pageContent, metadata: doc.metadata, score, })), keywordResults ); return fused.slice(0, topK); }上下文组装时要注意总长度控制。模型的上下文窗口有限检索出来的内容加上系统提示词和用户问题不能超过窗口限制。我的做法是给检索内容设一个 token 预算比如 3000 token按相关性从高到低填充填满为止。function assembleContext( results: RetrievalResult[], maxTokens: number 3000 ): string { const parts: string[] []; let currentTokens 0; for (const result of results) { const tokens estimateTokens(result.content); if (currentTokens tokens maxTokens) break; parts.push([来源: ${result.metadata.source}]\n${result.content}); currentTokens tokens; } return parts.join(\n\n---\n\n); }4.6 完整查询流程串联把上面的环节串起来一个完整的查询流程是这样的async function query( question: string, vectorStore: MemoryVectorStore, keywordIndex: KeywordIndex ): Promisestring { const results await hybridRetrieve(question, vectorStore, keywordIndex); const context assembleContext(results); const prompt 基于以下参考资料回答问题。如果资料中没有相关信息请明确说明。 参考资料 ${context} 问题${question} 回答; const response await llm.invoke(prompt); return response.content as string; }这个流程跑通之后你就有了一个基础可用的 RAG 管道。但别急着上线先做一轮评估看看检索命中率和回答质量到底怎么样。5. 常见问题与排查技巧实录5.1 检索命中率低怎么排查检索命中率低是最常见的问题。排查时按这个顺序来先看分块是否合理。把检索出来的块打印出来看看内容是否完整。如果块被切得支离破碎先调分块参数。我遇到过一个案例文档里全是短段落按默认的\n\n切分后每块只有一两句话检索时匹配到的块信息量太少。后来把多个短段落合并成一块效果立刻好转。再看 Embedding 模型是否适合你的语言。用英文模型处理中文文档效果会打折扣。如果你的知识库以中文为主换一个中文优化的 Embedding 模型试试。然后检查查询改写。用户的原始查询往往很短、很模糊。在检索前先用大模型把查询改写成更明确的检索语句能显著提升命中率。比如用户问“那个报错怎么解决”改写成“ERR_CONNECTION_REFUSED 错误的解决方法”检索效果完全不同。最后考虑加混合检索和重排序。如果前面几步都做了还是不行混合检索和重排序通常能带来明显提升。5.2 模型回答与检索内容不一致有时候检索出来的内容是对的但模型回答时却忽略了这些内容或者把不同块的内容混淆了。这个问题通常出在提示词上。我的做法是在提示词里明确要求模型只基于参考资料回答并且要求它引用来源。引用来源这个要求很关键它迫使模型关注检索内容而不是依赖自己的参数化知识。另一个技巧是把检索内容用明确的分隔符包起来比如context和/context让模型清楚知道哪部分是参考资料哪部分是问题。5.3 处理速度慢的优化思路RAG 管道的延迟主要来自三个地方Embedding 调用、向量检索、大模型生成。优化时逐个击破。Embedding 调用可以缓存。相同内容的向量化结果存本地下次直接读缓存。向量检索的优化取决于你用的数据库建好索引、控制检索范围都能提速。大模型生成这块如果用的是流式输出用户感知的延迟会低很多。还有一个容易被忽略的点并行化。向量检索和关键词检索可以并行跑不用串行等待。上下文组装和提示词构建也可以提前准备。5.4 常见问题速查表问题现象可能原因排查方向解决思路检索不到相关内容分块不合理或 Embedding 不匹配打印检索结果看内容完整性调整分块参数换 Embedding 模型检索到无关内容向量相似度阈值太低检查 top-K 结果的相关性提高相似度阈值加重排序回答忽略检索内容提示词不够明确检查提示词是否强调基于资料回答强化提示词约束要求引用来源回答内容矛盾检索到冲突信息检查知识库是否有过时内容清理知识库加时间过滤处理速度慢串行调用过多分析各环节耗时并行化、加缓存、流式输出长文档检索效果差关键信息被切散检查分块边界调整分块策略增大重叠5.5 几个我踩过的坑坑一忽略文档更新。知识库里的文档更新了但向量库没同步检索出来的还是旧内容。我的做法是给每个文档块加内容哈希定期扫描文档目录发现哈希变化就重新向量化。这个同步机制一定要做否则知识库会越来越“脏”。坑二元数据丢失。分块时忘了把来源信息带到每个块上检索出来不知道内容来自哪个文档排查问题时两眼一抹黑。元数据一定要在分块时就带上后面每一步都透传。坑三过度依赖默认参数。LangChain 的默认分块参数是给英文通用场景设计的直接拿来处理中文技术文档效果很差。参数一定要根据自己的数据特点调没有万能配置。坑四不做评估就上线。RAG 管道的效果很难靠感觉判断。我建议至少准备一组测试问题覆盖不同类型事实查询、推理、多跳每次调整参数后跑一遍看命中率和回答质量的变化。没有评估数据调参就是盲人摸象。6. 从基础 RAG 到 Agentic RAG 的演进方向基础 RAG 管道跑通之后你会发现它有一些固有的局限。比如它只能做单轮检索不能根据中间结果决定下一步查什么它对所有查询用同一套检索策略不会根据问题类型动态调整它不能组合多个知识源的信息做推理。这些局限催生了Agentic RAG的方向。简单说就是把 RAG 的检索过程交给 Agent 来编排。Agent 可以决定什么时候检索、检索什么、检索几次、要不要换查询词重新检索。它还可以把检索结果和工具调用结合起来比如先查数据库拿到订单号再根据订单号检索相关文档。从基础 RAG 往 Agentic RAG 演进我建议的路径是第一步把检索做成工具。把向量检索、关键词检索、数据库查询都封装成 Agent 可以调用的工具让 Agent 自己决定用哪个。第二步加查询规划。让 Agent 在检索前先分析问题决定需要哪些信息、分几步检索。复杂问题拆成多个子查询分别检索后合并结果。第三步加自我反思。Agent 拿到检索结果后评估一下这些内容是否足够回答问题。不够的话调整查询词再检索一轮。这个反思循环能显著提升复杂问题的回答质量。第四步多知识源融合。当你有多个知识库产品文档、客服记录、代码仓库时Agent 需要知道什么问题该查哪个库以及如何把多个库的结果融合起来。这条路走下来RAG 管道就从一条固定的流水线变成了一个能自主决策的知识获取系统。但前提是基础管道足够扎实——分块、检索、重排序这些环节都调到位了上层 Agent 的编排才有意义。基础不牢Agentic RAG 只会让问题更复杂。我在实际项目中的体会是先把基础 RAG 的检索命中率做到 80% 以上再考虑上 Agent 编排。否则你会在 Agent 层花大量时间调试最后发现根因还是检索没做好。这个顺序不能反。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →