TypeScript 实战:AI Agent 的 RAG 知识获取管道设计与踩坑

发布时间:2026/11/3 17:44:32
TypeScript 实战:AI Agent 的 RAG 知识获取管道设计与踩坑

1. 为什么知识获取管道是 AI Agent 的分水岭

做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它不知道你公司上个月刚改的产品定价,不知道你手里那份三百页的设备维护手册写了什么,更不知道你昨天在群里跟同事确认的那条业务规则。大模型的知识截止在训练数据那一天,而真实业务的知识每天都在变。这个矛盾不解决,Agent 就只能是个“看起来很聪明但一问三不知”的花架子。

知识获取管道要干的事,说白了就是给 Agent 装一套“随时能查资料”的能力。用户问一个问题,Agent 先去知识库里把相关的资料捞出来,再把资料和问题一起交给模型,让模型基于真实资料来回答。这套机制就是RAG(Retrieval-Augmented Generation,检索增强生成)。名字听着学术,拆开看特别朴素:Retrieval 是检索,Augmented 是增强,Generation 是生成。先检索、再增强、后生成,三步走。

我见过太多团队在这一步翻车。有人觉得 RAG 就是“把文档塞进向量数据库,然后搜一下”,结果上线之后答非所问、检索命中率惨不忍睹;有人把 RAG 当成万能药,什么场景都往上套,最后发现结构化查询用 SQL 两行就搞定的事,硬是用 RAG 绕了一大圈。这篇内容就是把我自己在 TypeScript 技术栈下搭建 RAG 知识获取管道的完整思路和踩坑记录摊开来讲,从整体架构怎么设计、文档怎么切、向量怎么存、检索怎么做,到实际跑起来之后遇到的各种幺蛾子怎么排查。适合正在做 AI Agent 开发、准备给自己的 Agent 接知识库的工程师,也适合想搞清楚 RAG 到底怎么回事的技术负责人。

2. RAG 知识获取管道的整体设计与技术选型

2.1 为什么是 RAG 而不是微调

给 Agent 补充知识有两条路:一条是微调(Fine-tuning),把知识“烧”进模型参数里;另一条就是 RAG,把知识放在外部,用的时候现查。这两条路我都走过,说几个实际的判断依据。

微调的问题在于:第一,成本高,每次知识更新都得重新训练一轮;第二,不可控,你没法精确知道模型到底记住了哪条、忘了哪条;第三,容易产生幻觉,模型会把训练时见过的相似内容“脑补”成答案。RAG 的优势恰恰在这三点上反过来:知识更新只需要更新文档库,检索结果可追溯、可验证,模型基于原文回答,幻觉概率大幅降低。

一个简单的判断标准:如果你的知识是“静态的、风格化的”(比如让模型学会某种写作风格、某种代码规范),微调更合适;如果知识是“动态的、事实性的”(比如产品文档、FAQ、业务规则),RAG 是首选。绝大多数 Agent 场景属于后者。

2.2 TypeScript 技术栈下的 RAG 架构分层

整个知识获取管道我把它拆成四层,每层职责清晰,方便单独调试和替换。

层级职责常用方案
文档处理层加载、解析、切分原始文档自定义 Loader + 递归切分器
向量化层把文本块转成向量Embedding API / 本地模型
存储检索层存向量、做相似度检索向量数据库 + 元数据过滤
编排层串联检索与生成,管理上下文Agent 框架 / 手写编排逻辑

为什么用 TypeScript 而不是 Python?这是个被问烂的问题。我的答案很直接:如果你的 Agent 本身就跑在 Node.js 服务里,或者你的前端团队要直接参与 Agent 开发,那 TypeScript 的端到端类型安全能省掉大量联调成本。Python 在 AI 生态上确实更成熟,但 TypeScript 在工程化、类型约束、前后端统一上优势明显。选哪个取决于你的团队和现有技术栈,不是绝对的。

2.3 一个容易被忽略的设计决策:检索的粒度

新手最容易犯的错,是把“文档”当成检索的最小单位。一份 50 页的 PDF 作为一个检索单元,检索出来一大坨,模型根本没法用。正确的做法是把文档切成Chunk(文本块),以 Chunk 为检索单位。

但 Chunk 切多大?这是个需要反复调参的活。切太小,语义不完整,检索出来的片段缺上下文;切太大,噪声多,检索精度下降。我的经验值是 300 到 800 个 token 之间,具体要看文档类型。技术文档可以小一点,叙事性内容可以大一点。后面会详细讲切分策略。

3. 文档处理与向量化:管道的第一公里

3.1 文档加载:别小看格式解析这一关

知识库的原始文档格式五花八门:PDF、Word、Markdown、HTML、Excel、甚至数据库里的记录。加载层要做的事就是把这些异构数据统一成纯文本。这一步看着简单,实际上坑最多。

PDF 解析是重灾区。扫描版 PDF 需要 OCR,带复杂表格的 PDF 解析出来经常错位,多栏排版的 PDF 文字顺序会乱。我试过好几个解析库,最后的选择是:能用结构化格式就用结构化格式,实在只有 PDF 就上专门的解析服务,别指望一个通用库能搞定所有 PDF。

// 文档加载的统一接口设计 interface Document { content: string; metadata: { source: string; // 来源文件路径 title?: string; // 文档标题 section?: string; // 所属章节 page?: number; // 页码 updatedAt?: string; // 更新时间 }; } interface DocumentLoader { load(filePath: string): Promise<Document[]>; }

元数据(metadata)的设计特别重要,后面检索的时候要靠它做过滤和溯源。比如用户问“最新的定价策略”,你可以用updatedAt过滤掉过期文档;用户问“第三章讲了什么”,你可以用section定位。

3.2 文本切分:递归切分为什么比固定长度好

最简单的切分是按固定字符数硬切,每 500 字一刀。问题是它会把一句话、一个段落从中间劈开,语义直接断裂。我推荐用递归字符切分(Recursive Character Splitting),思路是:优先按段落切,段落太长就按句子切,句子还长就按逗号切,最后才硬切。

function recursiveSplit( text: string, chunkSize: number = 500, overlap: number = 50 ): string[] { const separators = ["\n\n", "\n", "。", "!", "?", ".", " ", ""]; function split(text: string, sepIndex: number): string[] { if (text.length <= chunkSize || sepIndex >= separators.length) { return [text]; } const sep = separators[sepIndex]; const parts = text.split(sep); const chunks: string[] = []; let current = ""; for (const part of parts) { const candidate = current ? current + sep + part : part; if (candidate.length <= chunkSize) { current = candidate; } else { if (current) chunks.push(current); // 单个 part 就超长,递归用更细的分隔符 if (part.length > chunkSize) { chunks.push(...split(part, sepIndex + 1)); current = ""; } else { current = part; } } } if (current) chunks.push(current); return chunks; } return split(text, 0); }

Overlap(重叠)是个关键参数。相邻两个 Chunk 之间保留一部分重叠内容,是为了防止关键信息正好落在切分边界上被割裂。比如一句话前半段在 Chunk A 结尾,后半段在 Chunk B 开头,没有重叠的话两个 Chunk 都不完整。重叠量一般是 chunkSize 的 10% 到 20%。

实操心得:切分完之后一定要抽样检查。我习惯随机抽 20 个 Chunk 打印出来看,如果发现有大量“半句话”或者“孤立标题”,说明切分参数需要调。这个检查花五分钟,能省掉后面几小时的检索调试。

3.3 向量化:Embedding 模型怎么选

文本块切好之后,要转成向量才能做相似度检索。Embedding 模型的选择直接影响检索质量。选型时看三个维度:语义表达能力、维度、成本。

维度不是越高越好。高维度理论上能表达更细的语义,但存储和检索成本也高。常见的维度在 768 到 1536 之间。成本方面,如果文档量大、更新频繁,用 API 调用的费用会累积得很快,这时候可以考虑本地部署的 Embedding 模型。

// 向量化接口,方便切换不同模型 interface EmbeddingProvider { embed(texts: string[]): Promise<number[][]>; dimension: number; } class EmbeddingService { constructor(private provider: EmbeddingProvider) {} async embedDocuments(chunks: string[]): Promise<number[][]> { // 批量处理,注意 API 的 batch size 限制 const batchSize = 100; const results: number[][] = []; for (let i = 0; i < chunks.length; i += batchSize) { const batch = chunks.slice(i, i + batchSize); const vectors = await this.provider.embed(batch); results.push(...vectors); } return results; } }

注意:文档向量化和查询向量化必须用同一个模型。用 A 模型把文档转成向量,用 B 模型把查询转成向量,两个向量空间根本对不上,检索结果会完全随机。这个错误我见过不止一个团队犯,排查起来特别费劲,因为代码不报错,只是结果莫名其妙。

4. 向量存储与检索策略:决定 RAG 效果的核心环节

4.1 向量数据库选型:从轻量到生产

向量数据库的选择范围很广,从内存级的到分布式的都有。我的建议是按数据量和部署环境来选,别一上来就上重型方案。

方案适用场景特点
内存向量索引原型验证、小数据量零依赖,重启丢失
SQLite + 向量扩展单机、中小数据量轻量,可持久化
专用向量数据库生产环境、大数据量高性能,支持分布式
云服务向量库快速上线、免运维按量付费,有网络延迟

原型阶段我强烈建议先用内存方案跑通流程,确认检索逻辑没问题了再换持久化方案。很多团队一上来就搭分布式向量库,结果连切分参数都没调好,白白浪费时间。

4.2 相似度计算:余弦相似度为什么是默认选择

向量检索的核心是算两个向量的相似度。常用的有余弦相似度、欧氏距离、点积。余弦相似度衡量的是方向上的接近程度,不受向量长度影响,所以是文本检索的默认选择。

function cosineSimilarity(a: number[], b: number[]): number { let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dotProduct += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }

实际生产中不会用这种朴素实现,向量库内部会用近似最近邻(ANN)算法加速。但理解底层计算方式,对调试检索问题很有帮助。

4.3 检索策略:Top-K、阈值与混合检索

最基础的检索是“取相似度最高的 K 个 Chunk”。K 取多少?太小可能漏掉关键信息,太大则引入噪声。我的经验是 K 取 3 到 5 起步,然后根据实际效果调。

光有 Top-K 还不够,还要加相似度阈值。如果所有 Chunk 的相似度都很低,说明知识库里根本没有相关内容,这时候应该让 Agent 老实说“我不知道”,而不是硬凑一个答案。阈值设多少取决于你的 Embedding 模型,一般 0.7 左右是个起点,需要实测调整。

混合检索(Hybrid Search)是提升效果的重要手段。纯向量检索擅长语义匹配,但对精确的关键词、专有名词、编号不敏感。比如用户搜“ERR-4021 错误码”,向量检索可能返回一堆语义相近但错误码不对的内容。这时候把关键词检索(BM25)和向量检索结合起来,效果会好很多。

async function hybridSearch( query: string, topK: number = 5 ): Promise<SearchResult[]> { // 向量检索 const vectorResults = await vectorStore.search(query, topK * 2); // 关键词检索 const keywordResults = await keywordIndex.search(query, topK * 2); // 融合排序(RRF 倒数排名融合) const fused = reciprocalRankFusion(vectorResults, keywordResults); return fused.slice(0, topK); } function reciprocalRankFusion( listA: SearchResult[], listB: SearchResult[], k: number = 60 ): SearchResult[] { const scores = new Map<string, number>(); listA.forEach((item, rank) => { scores.set(item.id, (scores.get(item.id) || 0) + 1 / (k + rank + 1)); }); listB.forEach((item, rank) => { scores.set(item.id, (scores.get(item.id) || 0) + 1 / (k + rank + 1)); }); return [...scores.entries()] .sort((a, b) => b[1] - a[1]) .map(([id]) => findResultById(id, listA, listB)); }

RRF 的好处是不需要归一化两个不同来源的分数,直接用排名来融合,简单且鲁棒。

4.4 重排序:检索之后的第二道关

检索出来的 Top-K 结果,顺序未必最优。重排序(Rerank)是用一个更精细的模型对候选结果重新打分排序。向量检索用的是双塔模型(查询和文档分别编码),速度快但精度有限;重排序用的是交叉编码器(查询和文档一起编码),精度高但速度慢。所以典型流程是:向量检索粗筛出 20 个候选,重排序精排出 Top 5。

这一步对效果的提升非常明显,尤其是候选结果多、噪声大的场景。代价是增加了一次模型调用,延迟会上升。要不要上重排序,取决于你对延迟和精度的权衡。

5. 从检索到生成:把知识喂给 Agent 的正确姿势

5.1 上下文组装:Prompt 里放什么、怎么放

检索出来的 Chunk 不能直接一股脑塞给模型,要组装成结构化的上下文。我的做法是给每个 Chunk 加上来源标注,让模型知道每段话出自哪里。

function buildContext(chunks: SearchResult[]): string { return chunks .map((chunk, i) => { const source = chunk.metadata.source; const section = chunk.metadata.section || ""; return `[资料 ${i + 1}] 来源:${source} ${section}\n${chunk.content}`; }) .join("\n\n---\n\n"); } function buildPrompt(query: string, context: string): string { return `你是一个基于知识库回答问题的助手。 请严格根据下面提供的资料回答问题。 如果资料中没有相关信息,请明确说明"根据现有资料无法回答",不要编造。 资料: ${context} 问题:${query} 回答:`; }

Prompt 里的约束很关键。“严格根据资料回答”和“无法回答时明确说明”这两句,能显著降低幻觉。我试过不加这两句,模型经常把资料和它自己的训练知识混在一起,答案真假参半。

5.2 引用溯源:让答案可验证

生产环境的 RAG 系统,答案必须可溯源。用户看到答案之后,要能点进去看到原始文档。这不仅是体验问题,更是信任问题。实现方式是在生成答案时,让模型标注引用了哪几段资料,然后前端把标注映射回原始文档链接。

interface RAGResponse { answer: string; citations: Array<{ index: number; source: string; content: string; }>; }

5.3 多轮对话下的检索:查询改写不能少

单轮问答的检索很直接,用户问什么就检索什么。但多轮对话里,用户的追问往往省略了上下文。比如第一轮问“你们的产品定价是多少”,第二轮问“那企业版呢”,如果直接拿“那企业版呢”去检索,什么都搜不到。

解决办法是查询改写(Query Rewriting):用模型把当前问题和对话历史结合,改写成独立的、完整的查询。上面那个例子会被改写成“企业版产品的定价是多少”,再去检索就正常了。

async function rewriteQuery( history: Message[], currentQuery: string ): Promise<string> { const prompt = `根据对话历史,把用户的最新问题改写成一个独立的、完整的问题。 只输出改写后的问题,不要其他内容。 对话历史: ${history.map(m => `${m.role}: ${m.content}`).join("\n")} 最新问题:${currentQuery} 改写后的问题:`; return await llm.complete(prompt); }

这一步会增加一次模型调用,但多轮场景下几乎是必需的。我踩过的坑就是早期没做查询改写,多轮对话的检索命中率低得离谱,用户稍微追问一下就答不上来。

6. 常见问题与排查技巧实录

6.1 检索命中率低的排查思路

检索效果差是最常见的问题,排查要按顺序来,别乱试。

现象可能原因排查方法
检索结果完全不相关文档和查询用了不同 Embedding 模型检查两处模型配置是否一致
检索结果部分相关切分粒度不合适抽样检查 Chunk 完整性
关键词搜不到纯向量检索对专有名词不敏感加入关键词混合检索
相似度普遍偏低阈值设置过高或模型不匹配打印相似度分布,调整阈值
多轮对话检索失败缺少查询改写检查是否做了 Query Rewriting

我的排查习惯是先打印检索结果和相似度分数,肉眼看一下。如果 Top 5 里一个相关的都没有,那是 Embedding 或切分的问题;如果相关的排在第 8、第 9 位,那是排序的问题,上重排序就能解决。

6.2 幻觉问题的三层防御

RAG 能降低幻觉,但不能消除。我总结了三层防御:

第一层是Prompt 约束,明确要求“只根据资料回答”。第二层是相似度阈值,检索结果质量太差时直接拒答。第三层是答案校验,生成完答案后,再用一次模型调用检查答案是否能在资料中找到依据。第三层成本高,一般只在对准确性要求极高的场景用。

6.3 性能优化:延迟从哪来,怎么降

RAG 的延迟主要来自三块:Embedding 调用、向量检索、生成。优化手段对应如下:

  • Embedding 调用:做缓存,相同查询不重复调用;批量处理文档向量化。
  • 向量检索:用 ANN 索引;合理设置索引参数;元数据过滤前置。
  • 生成:控制上下文长度,别把 Top 20 全塞进去;流式输出改善体感。

一个实测经验:上下文长度对生成延迟的影响是超线性的。从 5 个 Chunk 加到 10 个 Chunk,延迟可能翻倍还不止。所以检索精度比检索数量重要得多,宁可 Top 5 精准,不要 Top 20 凑数。

6.4 知识更新的处理

知识库不是一次建好就完事,文档会更新、会新增、会删除。我的做法是给每个 Chunk 记录来源文件的哈希值,更新时对比哈希,只重新处理变化的文件。删除文档时,按来源路径批量删除对应的向量。这样增量更新,不用每次全量重建。

async function incrementalUpdate(filePath: string): Promise<void> { const newHash = await computeFileHash(filePath); const oldHash = await metadataStore.getHash(filePath); if (newHash === oldHash) { return; // 文件没变,跳过 } // 删除旧向量 await vectorStore.deleteBySource(filePath); // 重新加载、切分、向量化 const docs = await loader.load(filePath); const chunks = docs.flatMap(d => recursiveSplit(d.content)); const vectors = await embeddingService.embedDocuments(chunks); await vectorStore.insert(chunks, vectors); await metadataStore.setHash(filePath, newHash); }

这套增量更新逻辑看着简单,但能省掉大量重复计算。文档库上千份文件的时候,全量重建一次要几十分钟,增量更新通常几秒钟就搞定。

7. 我在实际项目里踩过的几个坑

第一个坑是切分参数照搬教程。网上教程说 chunkSize 设 1000,我直接用了,结果我的文档是 API 文档,每个接口说明就两三百字,1000 的块把好几个接口混在一起,检索出来全是噪声。后来改成 300,效果立刻好了。参数没有标准答案,必须结合自己的文档特点调。

第二个坑是忽略元数据过滤。早期检索是全局搜,用户问“V2 版本的接口”,结果 V1 的文档也混进来。加上版本号元数据过滤之后,准确率提升了一大截。元数据不是可有可无的装饰,是检索精度的重要保障。

第三个坑是没有做检索结果的去重。相邻 Chunk 有重叠,检索出来经常是同一段内容的好几个片段,白白占用上下文。后来加了基于内容相似度的去重,把重复度高的结果合并,上下文利用率高了很多。

第四个坑是低估了文档解析的难度。我以为 PDF 解析是成熟技术,随便找个库就行,结果表格全乱、公式全丢。最后老老实实针对不同格式写了不同的解析逻辑,PDF 用专门的解析服务,Markdown 直接读,HTML 用 DOM 解析。没有银弹,只有针对性的处理。

这套知识获取管道搭下来,我的体会是:RAG 的难点不在某一个环节的技术,而在整条链路的配合。切分影响检索,检索影响生成,生成效果又反过来指导切分和检索的调整。它是一个需要反复迭代的系统工程,不是调几个参数就能一劳永逸的。先把最简单的版本跑通,然后拿着真实的查询去测,看哪里出问题就改哪里,这比一开始就追求完美架构要高效得多。