☰
AI Agent知识获取管道:TypeScript从零搭建RAG检索增强生成系统
2026/11/4 9:36:10 网站建设 项目流程

1. 为什么知识获取管道是 AI Agent 落地的第一道坎

做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它对你私有的业务知识一无所知。你问它公司内部的报销流程,它只能给你编一段听起来很合理但完全错误的答案。这不是模型不行,而是它的知识边界停在了训练数据截止的那一天。

知识获取管道要解决的就是这个问题。它的核心任务只有一句话:在用户提问的那一刻,把最相关的私有知识塞进模型的上下文窗口,让模型基于真实材料回答,而不是靠记忆瞎猜。这套机制有个更广为人知的名字——RAG(Retrieval-Augmented Generation,检索增强生成)。

我见过太多团队在 Agent 项目上栽跟头,不是因为模型选错了,也不是因为 Prompt 写得不好,而是知识管道没搭好。检索出来的内容驴唇不对马嘴,模型再强也只能基于垃圾输入产出垃圾输出。所以我把 RAG 放在这个系列靠前的位置,因为它是 Agent 从"能聊天"进化到"能干活"的分水岭。

这篇文章适合谁看?如果你正在用 TypeScript 搭建 AI Agent,想给自己的 Agent 接上私有知识库,或者你已经听说过 RAG 但不知道从哪下手,那这篇内容就是为你准备的。我会从管道设计讲到代码落地,把每个环节的取舍逻辑讲清楚,让你不只是抄一份代码,而是真正理解为什么这么写。

先说一个反直觉的结论:RAG 的难点从来不在"生成",而在"检索"。很多人把精力花在调 Prompt、换模型上,结果检索环节一塌糊涂,召回的内容跟问题八竿子打不着。我踩过这个坑,后来才明白,检索质量决定了 RAG 系统的天花板,生成质量只是逼近这个天花板而已。

2. RAG 管道的三段式结构拆解

2.1 索引阶段:把非结构化文档变成可检索的向量

RAG 管道的第一段是索引(Indexing),这一步在用户提问之前就要完成,属于离线预处理。它的工作流程是:加载文档 → 切分成块 → 向量化 → 存入向量数据库。

听起来简单,但每一步都有坑。先说文档加载,你的知识可能散落在 Markdown、PDF、Word、网页、数据库里,格式五花八门。加载器的任务是把它们统一成纯文本。这里最容易忽略的是元数据保留——文档的来源、标题、更新时间、章节层级,这些信息在后续检索和引用时非常关键,但很多人在加载阶段就把它们丢了。

切分(Chunking)是索引阶段最考验经验的地方。为什么要切分?因为模型的上下文窗口有限,你不可能把整本手册塞进去。而且检索的粒度越细,匹配越精准。但切得太碎,语义就断了;切得太大,噪声就多了。

我常用的策略是递归字符切分,配合重叠窗口。具体参数上,块大小设在 500 到 1000 个字符之间,重叠 100 到 200 个字符。为什么要有重叠?因为一句话可能正好被切在边界上,重叠能保证语义完整性。这个参数没有标准答案,得根据你的文档类型调。技术文档句子短,块可以小一点;法律合同句子长,块就得大一些。

向量化就是把文本块转成一串数字(向量),语义相近的文本在向量空间里距离更近。这里要选一个嵌入模型(Embedding Model),它决定了检索的语义理解能力。选型时重点看两个指标:向量维度(影响存储和计算成本)和多语言支持(如果你的知识库有中文,必须选中文能力强的模型)。

2.2 检索阶段:在向量空间里找最相关的知识

用户提问时,管道进入第二段——检索(Retrieval)。系统把用户的问题也转成向量,然后在向量数据库里找距离最近的若干个文本块。

这里有个关键概念叫相似度度量,常见的有余弦相似度和欧氏距离。余弦相似度关注方向,适合文本;欧氏距离关注绝对距离,对向量长度敏感。大多数文本检索场景用余弦相似度就够了。

检索的Top-K 参数决定了返回多少个文本块。K 太小,可能漏掉关键信息;K 太大,噪声多,还会挤占上下文窗口。我的经验是先从 K=5 开始试,然后根据实际效果调整。如果发现模型经常答不全,就加大 K;如果发现模型被无关信息带偏,就减小 K。

但纯向量检索有个硬伤:它对精确匹配不敏感。比如用户问"错误码 E5021 是什么意思",向量检索可能返回一堆讲错误处理的通用文档,却漏掉了那条精确提到 E5021 的记录。这时候就需要混合检索(Hybrid Search),把向量检索和关键词检索(如 BM25)结合起来,各取所长。

2.3 生成阶段:把检索结果喂给模型

第三段是生成(Generation)。系统把检索到的文本块和用户问题一起组装成 Prompt,发给大模型,让模型基于这些材料回答。

Prompt 的组装方式直接影响输出质量。我习惯用这样的结构:先给模型一个明确的角色和任务说明,然后列出检索到的参考资料(标注来源编号),最后提出用户问题,并要求模型"只基于提供的资料回答,如果资料中没有相关信息,就明确说不知道"。

这句话非常重要。不加这句约束,模型很容易在资料不足时开始编造。加了之后,虽然偶尔会显得"不够聪明",但至少不会给你假信息。在 Agent 场景里,可靠性比流畅度重要得多。

生成阶段还要考虑引用溯源。让模型在回答时标注信息来自哪个资料块,这样用户能验证答案的可信度。实现方式是在 Prompt 里要求模型用[1][2]这样的标记引用资料,然后在后处理时把标记替换成实际的来源链接。

3. 用 TypeScript 从零搭一条最小可用管道

3.1 环境准备与依赖选型

我选 TypeScript 来演示,因为它在类型安全上对 Agent 开发帮助很大。RAG 管道涉及大量数据结构转换,类型系统能在编译期帮你挡掉很多低级错误。

先建项目,装依赖。核心需要三样东西:一个嵌入模型客户端、一个向量存储、一个 LLM 客户端。为了让你能直接跑起来,我用最通用的接口来演示,你可以替换成任何兼容的服务。

mkdir rag-pipeline && cd rag-pipeline npm init -y npm install typescript ts-node @types/node npx tsc --init

tsconfig.json里我建议开启严格模式,把strict设为true。RAG 管道里到处是可选字段和异步操作,严格模式能帮你提前发现空值问题。

{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist" } }

注意:如果你看到moduleResolution=node10或baseUrl相关的弃用警告,别慌,这是 TypeScript 版本升级带来的提示,不影响运行。按提示改成node16或bundler即可。

3.2 文档加载与切分的实现细节

先定义核心类型。类型定义清楚了,后面写逻辑就是水到渠成的事。

interface Document { id: string; content: string; metadata: { source: string; title?: string; chunkIndex: number; }; } interface ChunkOptions { chunkSize: number; chunkOverlap: number; }

切分函数我推荐用递归的方式,优先按段落切,段落太长再按句子切,句子还长才按字符硬切。这样能最大程度保留语义边界。

function splitText(text: string, options: ChunkOptions): string[] { const { chunkSize, chunkOverlap } = options; const separators = ["\n\n", "\n", "。", "!", "?", ".", " ", ""]; const chunks: string[] = []; function recursiveSplit(segment: string, sepIndex: number): void { if (segment.length <= chunkSize) { if (segment.trim()) chunks.push(segment.trim()); return; } if (sepIndex >= separators.length) { for (let i = 0; i < segment.length; i += chunkSize - chunkOverlap) { chunks.push(segment.slice(i, i + chunkSize).trim()); } return; } const sep = separators[sepIndex]; const parts = segment.split(sep); let buffer = ""; for (const part of parts) { const candidate = buffer ? buffer + sep + part : part; if (candidate.length > chunkSize && buffer) { recursiveSplit(buffer, sepIndex + 1); buffer = part; } else { buffer = candidate; } } if (buffer) recursiveSplit(buffer, sepIndex + 1); } recursiveSplit(text, 0); return chunks; }

这段代码的逻辑是:从最大的分隔符(段落)开始尝试,如果切出来的块还是太大,就换更细的分隔符继续切。这样能保证块的大小可控,同时尽量不破坏语义。

3.3 向量化与存储的落地

向量化就是调嵌入模型的接口,把文本转成向量数组。这里我抽象成一个接口,方便你替换不同的服务。

interface EmbeddingClient { embed(texts: string[]): Promise<number[][]>; } async function indexDocuments( docs: Document[], embedder: EmbeddingClient ): Promise<Array<Document & { vector: number[] }>> { const texts = docs.map((d) => d.content); const vectors = await embedder.embed(texts); return docs.map((doc, i) => ({ ...doc, vector: vectors[i] })); }

存储层我建议先用内存数组实现,跑通逻辑后再换成真正的向量数据库。内存实现虽然不能持久化,但调试阶段非常方便,你能直接看到每个块的向量和检索结果。

class InMemoryVectorStore { private items: Array<Document & { vector: number[] }> = []; add(items: Array<Document & { vector: number[] }>): void { this.items.push(...items); } search(queryVector: number[], topK: number): Array<Document & { score: number }> { const scored = this.items.map((item) => ({ ...item, score: cosineSimilarity(queryVector, item.vector), })); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topK); } } function cosineSimilarity(a: number[], b: number[]): number { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-8); }

那个1e-8是防止除零的,虽然实际中很少遇到零向量,但加上更稳妥。

3.4 检索与生成的串联

最后把检索和生成串起来。检索部分把用户问题向量化,然后查库;生成部分组装 Prompt,调 LLM。

async function ragQuery( question: string, store: InMemoryVectorStore, embedder: EmbeddingClient, llm: (prompt: string) => Promise<string>, topK = 5 ): Promise<string> { const [queryVector] = await embedder.embed([question]); const results = store.search(queryVector, topK); const context = results .map((r, i) => `[${i + 1}] 来源:${r.metadata.source}\n${r.content}`) .join("\n\n"); const prompt = `你是一个严谨的知识助手。请只基于以下参考资料回答问题。 如果资料中没有相关信息,请明确说明"根据现有资料无法回答"。 参考资料: ${context} 用户问题:${question} 请用中文回答,并在引用资料时标注编号如 [1]。`; return llm(prompt); }

这套代码跑通之后,你就有了一个最小可用的 RAG 管道。但能跑通和好用之间,还隔着大量的调优工作。

4. 检索质量调优:那些文档里不会写的经验

4.1 为什么你的检索总是召回无关内容

检索召回不准,八成是这三个原因之一:切分粒度不对、嵌入模型不匹配、或者缺少重排序。

先说切分。我遇到过一个案例,团队把整篇技术文档按固定 2000 字符切分,结果每个块里混杂了好几个不相关的主题。用户问 A 主题,检索出来的块里 A 只占一小部分,其余都是 B、C 主题的内容,模型自然被带偏。后来改成按标题层级切分,每个小节独立成块,召回准确率立刻上去了。

嵌入模型不匹配也很常见。有些模型在英文上表现很好,但中文语义理解很弱。如果你的知识库是中文,一定要选中文能力经过验证的模型。判断方法很简单:拿几组语义相近但用词不同的中文句子,看它们的向量相似度是否明显高于无关句子。

重排序(Rerank)是提升检索质量的大杀器,但很多入门教程不讲。它的思路是:先用向量检索快速召回一批候选(比如 20 个),然后用一个更精细的模型对这 20 个逐一打分排序,取前 5 个给 LLM。向量检索快但粗,重排序慢但准,两者结合效果最好。

4.2 混合检索的实战配置

纯向量检索对精确匹配无能为力,这是它的先天缺陷。解决办法是引入关键词检索,做混合检索。

关键词检索的经典算法是 BM25,它根据词频和逆文档频率打分。实现上你可以用现成的库,也可以自己写一个简化版。核心思路是:一个词在当前文档出现越多、在所有文档中出现越少,它的区分度就越高。

混合检索的融合策略有两种:一种是加权求和,把向量相似度和 BM25 分数归一化后按权重相加;另一种是倒数排名融合(RRF),只看排名不看分数,把两路结果的排名做倒数加权。

我更喜欢 RRF,因为它不需要调权重,对分数尺度不敏感。公式是:每个文档的最终得分 = Σ 1/(k + rank),其中 k 通常取 60,rank 是它在某一路检索中的排名。

function reciprocalRankFusion( rankings: string[][], k = 60 ): Map<string, number> { const scores = new Map<string, number>(); for (const ranking of rankings) { ranking.forEach((id, rank) => { const prev = scores.get(id) ?? 0; scores.set(id, prev + 1 / (k + rank + 1)); }); } return scores; }

这个函数接收多路检索的 ID 排名列表,返回融合后的得分。实测下来,混合检索对包含专有名词、错误码、产品型号的查询提升非常明显。

4.3 上下文窗口的取舍艺术

检索回来的内容不是越多越好。上下文窗口是稀缺资源,塞太多无关内容不仅浪费 token,还会稀释关键信息,让模型抓不住重点。

我的做法是动态 Top-K:先检索一批候选,然后根据相似度分数做阈值过滤,只保留分数明显高于平均水平的那些。如果所有分数都很低,说明知识库里可能根本没有相关内容,这时候宁可返回"无法回答",也不要硬塞。

还有一个技巧是上下文压缩。检索回来的块里可能只有一两句话跟问题相关,其余都是噪声。可以用一个小模型先把每个块压缩成跟问题最相关的摘要,再喂给主模型。这样能在有限的窗口里塞进更多有效信息。

提示:上下文压缩会增加一次模型调用,成本和延迟都会上升。是否值得,取决于你的场景对准确率和响应速度的权衡。

5. 从基础 RAG 到 Agentic RAG 的演进路径

5.1 基础 RAG 的能力边界在哪里

基础 RAG 能解决"基于私有知识问答"的问题,但它的能力边界也很明显。

第一,它只能做单轮检索。用户问一个问题,检索一次,生成一次,结束。如果问题需要多步推理,比如"对比 A 产品和 B 产品在售后政策上的差异",单次检索很难同时召回两个产品的完整信息。

第二,它不会判断该不该检索。有些问题模型自己就能答,不需要查知识库;有些问题需要查,但基础 RAG 无脑查。这会导致不必要的延迟和成本。

第三,它无法处理检索失败。如果第一次检索没找到有用信息,基础 RAG 就直接说"不知道"了,不会换个关键词再试一次。

这些边界,正是Agentic RAG要突破的方向。

5.2 Agentic RAG 的核心思路

Agentic RAG 的本质是把 RAG 从一条固定管道,变成一个由 Agent 自主决策的循环。Agent 会判断:这个问题需要检索吗?用哪个知识库检索?检索结果够不够?不够的话要不要换个查询重试?

实现上,这通常意味着把检索工具化,让 Agent 通过调用工具的方式使用检索。Agent 可以有多个工具:向量检索、关键词检索、数据库查询、网页搜索。它根据问题类型自主选择。

interface RetrievalTool { name: string; description: string; execute(query: string): Promise<Document[]>; } const tools: RetrievalTool[] = [ { name: "vector_search", description: "语义检索,适合概念性、描述性问题", execute: async (q) => vectorSearch(q), }, { name: "keyword_search", description: "精确检索,适合专有名词、错误码、型号", execute: async (q) => keywordSearch(q), }, ];

Agent 拿到这些工具后,会根据问题特征决定调用哪个。这种设计让 RAG 系统从"被动管道"变成了"主动决策者"。

5.3 多轮检索与查询改写

Agentic RAG 最实用的能力是查询改写。用户的问题往往口语化、模糊,直接拿去检索效果不好。Agent 可以先把问题改写成更适合检索的形式。

比如用户问"那个新出的功能怎么用",Agent 会先判断"那个新出的功能"指什么,可能需要结合对话历史,改写成"XX 功能的使用方法",再去检索。

还有多轮检索:第一轮检索后,Agent 评估结果质量,如果发现信息不足,就基于已有信息生成新的查询,再检索一轮。这个过程可以循环几次,直到信息足够或达到上限。

async function agenticRetrieve( question: string, maxRounds = 3 ): Promise<Document[]> { let query = question; const collected: Document[] = []; for (let round = 0; round < maxRounds; round++) { const results = await vectorSearch(query); collected.push(...results); const assessment = await assessSufficiency(question, collected); if (assessment.sufficient) break; query = await rewriteQuery(question, collected, assessment.gap); } return deduplicate(collected); }

这段伪代码展示了核心逻辑:检索、评估、改写、再检索。评估和改写都需要调用 LLM,所以成本比基础 RAG 高,但换来的是更强的复杂问题处理能力。

6. 上线前必须验证的几个关键指标

6.1 命中率与召回率怎么测

RAG 系统上线前,必须有一套评估方法。最核心的两个指标是命中率(Hit Rate)和召回率(Recall)。

命中率的定义是:在测试集中,正确文档出现在 Top-K 结果里的比例。比如你有 100 个测试问题,每个问题都有标注的标准答案文档,检索后看标准文档有没有进前 5,进了就算命中。命中率低于 80% 就说明检索环节有问题。

召回率关注的是:所有相关文档中,被检索出来的比例。如果一个问题的答案分散在 3 个文档块里,检索只召回了 2 个,召回率就是 2/3。

测这两个指标的前提是有一份标注数据集。这份数据集需要人工标注每个测试问题的相关文档。标注很费时间,但这是唯一能客观衡量检索质量的方法。我的建议是先从 50 到 100 个问题开始,覆盖你的主要业务场景。

6.2 端到端评估的陷阱

检索指标好,不代表最终答案好。端到端评估要看的,是模型基于检索结果生成的答案是否正确、完整、无幻觉。

这里有个常见陷阱:用 LLM 评估 LLM。让一个模型给另一个模型的答案打分,听起来很方便,但评估模型本身也有偏见,可能偏爱某些风格的答案。更可靠的做法是人工抽检,或者用规则化的指标,比如答案里是否包含了标准答案的关键实体。

我通常的做法是三层评估:自动指标看趋势,人工抽检看质量,线上反馈看真实效果。三者结合,才能全面判断系统状态。

6.3 线上监控该盯哪些数据

上线不是终点,而是起点。线上环境会遇到测试集里没有的问题类型,必须持续监控。

我重点盯这几个数据:检索空结果率(多少比例的查询没召回到任何内容)、低分检索率(召回了但相似度都很低)、用户追问率(用户对答案不满意继续追问的比例)、响应延迟分布。

检索空结果率突然升高,可能是知识库更新导致索引失效,或者用户开始问新领域的问题。用户追问率高,说明答案质量有问题,需要回头查检索和 Prompt。这些指标能帮你快速定位问题环节。

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

第一个坑是元数据丢失。早期版本我只存了文本块和向量,没存来源信息。结果模型回答时无法标注引用,用户不信任。后来在切分阶段就把来源、标题、章节路径都带上,检索时一并返回,问题才解决。

第二个坑是嵌入模型和 LLM 不匹配。我试过用一个多语言嵌入模型配一个中文能力一般的 LLM,结果检索召回的中文内容质量不错,但 LLM 理解不到位,答案还是不对。后来统一换成中文能力都强的组合,整体效果才上来。嵌入模型和生成模型要一起评估,不能分开看。

第三个坑是过度依赖向量检索。有个项目里用户经常查产品型号,向量检索总是召回一堆泛泛的产品介绍,就是找不到那个具体型号。加了关键词检索做混合之后,型号查询的准确率从 60% 提到了 95%。这个教训让我明白,没有银弹,得根据查询类型组合使用不同检索方式。

第四个坑是忽略冷启动。新知识库刚建好时,文档少,检索效果还行。但随着文档增多,噪声也增多,检索质量反而下降。这时候需要定期做索引清理,把过时、重复的内容剔除,保持知识库的"信噪比"。

最后一个坑是Prompt 里的指令冲突。我一开始在 Prompt 里既要求模型"基于资料回答",又要求它"回答要详细全面"。结果模型为了详细,开始补充资料里没有的内容,产生幻觉。后来改成"只基于资料回答,资料不足时明确说明",幻觉率大幅下降。指令要单一、明确,不能既要又要。

这套知识获取管道搭下来,你会发现 RAG 不是一个可以一劳永逸的组件,而是一个需要持续调优的系统。索引策略、检索方式、Prompt 设计、评估方法,每个环节都有优化空间。但只要你把基础管道跑通,理解了每个环节的作用和取舍逻辑,后续的迭代就有了方向。Agentic RAG 那些更高级的玩法,也都是在这个基础上生长出来的。

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

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

立即咨询