☰
TypeScript 实战:从零搭建 AI Agent 知识获取管道与 RAG 检索优化
2026/11/3 17:39:29 网站建设 项目流程

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

做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它不知道你公司内部的业务规则、不知道你昨天刚更新的产品文档、更不知道你私域知识库里那些沉淀了三五年的经验条目。你问它一个非常具体的业务问题,它要么一本正经地胡说八道,要么给你一个“正确的废话”式回答。这不是模型不行,而是它的知识边界就停在那里。

知识获取管道要解决的核心问题就一个:怎么让 Agent 在需要的时候,拿到正确、新鲜、可溯源的外部知识,并且把这些知识有效地“喂”进模型的推理过程。RAG(检索增强生成)是目前工程上最成熟、性价比最高的一条路径。它不需要你去微调模型,不需要你准备海量标注数据,只需要你把知识整理好、索引建好、检索链路搭好,就能让 Agent 的回答质量上一个台阶。

这篇文章适合谁看?如果你正在用 TypeScript 做 AI Agent 开发,已经跑通了基础的对话链路,但发现 Agent 在专业问题上表现拉胯,那这篇内容就是为你准备的。如果你还在观望 RAG 到底是什么、值不值得投入,我也会用最直白的方式把它的原理、边界和实操路径讲清楚。整篇内容会围绕一个核心线索展开:从零搭建一条可用的知识获取管道,让 Agent 真正“知道”你的业务。

我自己的技术栈以 TypeScript 为主,所以后续的代码示例和工程实践都会基于 TS 生态来写。Python 生态的 RAG 方案确实更丰富,但 TypeScript 在 Agent 编排、前端集成、全栈一致性上的优势也很明显,尤其是当你需要把 Agent 嵌入到已有的 Web 产品里时,TS 的工程化能力会让你省很多事。

2. RAG 的核心原理与知识获取管道的整体设计

2.1 RAG 到底在做什么:一个生活化的类比

很多人第一次接触 RAG 的时候,会被“检索增强生成”这个术语吓到。其实它的逻辑特别简单,你可以把它想象成开卷考试。

闭卷考试的时候,模型只能靠脑子里记住的东西答题,记错了就答错,没记过就空着。开卷考试就不一样了,你允许模型在答题之前先翻书,找到相关章节,然后把书上的内容和题目一起交给它,让它基于这些材料来回答。RAG 就是给模型安排了一场开卷考试,而知识获取管道就是那套“翻书”的系统。

具体来说,RAG 的流程分成三个阶段:

  • 索引阶段:把你的原始知识(文档、网页、数据库记录等)切分成小块,转成向量,存进向量数据库。这一步是离线做的,相当于把书拆成一个个知识点卡片,编好目录。
  • 检索阶段:用户提问时,把问题也转成向量,去向量数据库里找最相似的几个知识块。这一步是在线做的,相当于根据题目去目录里找相关卡片。
  • 生成阶段:把检索到的知识块和用户问题拼成一个完整的提示词,交给大模型生成回答。这一步相当于把找到的卡片内容抄到答题纸上,让模型基于这些内容组织语言。

整个链路听起来不复杂,但每一个环节都有大量的工程细节需要打磨。切分策略不对,检索出来的内容就是断章取义的;向量模型选得不好,相似度计算就是失灵的;提示词拼得不好,模型就会忽略检索到的内容自己瞎编。

2.2 知识获取管道的四层架构

在实际工程中,我会把知识获取管道拆成四层来看,这样排查问题和做优化的时候思路会清晰很多。

第一层是数据接入层。这一层负责把各种来源的原始知识统一收集进来。来源可能包括 Markdown 文档、PDF 文件、Confluence 页面、数据库表、API 返回的 JSON 等等。不同来源的数据格式差异很大,需要做统一的预处理。我的习惯是先统一转成纯文本加元数据的结构,元数据里保留来源、更新时间、作者、分类等字段,后面检索的时候可以用来做过滤。

第二层是索引构建层。这一层负责把文本切块、向量化、存入向量库。切块策略是这里最关键的决策点。切得太粗,检索精度下降;切得太细,上下文丢失。向量模型的选择也很重要,不同模型对中文、英文、代码的语义理解能力差异很大。

第三层是检索层。这一层负责接收用户查询,执行相似度搜索,可能还会叠加关键词检索、重排序等策略。纯向量检索在某些场景下会漏掉关键词精确匹配的结果,所以生产环境里通常会做混合检索。

第四层是生成层。这一层负责把检索结果和用户问题组装成提示词,调用大模型生成最终回答。提示词的设计直接决定了模型会不会好好利用检索到的知识。

这四层每一层都可以独立优化,也都可以独立出问题。我在实际项目里踩过的坑,大部分都集中在索引构建层和检索层。

2.3 为什么选择 TypeScript 来做这条管道

Python 在 RAG 领域的生态确实更成熟,LangChain、LlamaIndex 这些框架的 Python 版本功能最全。但如果你本身就是一个 TypeScript 开发者,或者你的 Agent 产品需要和前端深度集成,那用 TS 来做 RAG 管道是完全可行的,而且有一些独特的优势。

TypeScript 的类型系统在构建复杂管道的时候特别有用。RAG 管道里数据会在多个模块之间流转,从原始文档到切块、到向量、到检索结果、到最终提示词,每一步的数据结构都不一样。用 TS 把这些类型定义清楚,编译期就能发现很多低级错误,重构的时候也更有底气。

另外,Node.js 生态里的向量数据库客户端、Embedding API 封装、流式处理工具都已经比较完善了。你不需要自己造轮子,把现成的库组合起来就能搭出一条可用的管道。我后面会给出具体的库选型和代码示例。

3. 核心细节解析:切块、向量化与检索策略

3.1 文本切块:RAG 管道里最容易被低估的环节

切块(Chunking)这件事,看起来就是把长文本切成小段,但实际上它直接决定了检索质量的上限。我见过太多项目,向量模型选的是最好的,向量数据库用的是最贵的,但检索效果就是不行,最后排查下来问题出在切块策略上。

固定长度切块是最简单的做法,比如每 500 个字符切一刀。这种做法的问题是它会切断语义单元。一个完整的段落可能被切成两半,前半段在块 A,后半段在块 B,检索的时候只召回块 A,模型看到的就是半句话。

按语义切块是更合理的做法。Markdown 文档可以按标题层级切,代码可以按函数或类切,普通文本可以按段落切。这样每个块都是一个完整的语义单元,检索出来直接就能用。我通常会用递归切块的方式,优先按大标题切,如果某个章节还是太长,再按段落切,最后才按固定长度兜底。

重叠切块是另一个实用技巧。相邻的两个块之间保留一定的重叠内容,比如 50 到 100 个字符。这样即使切分点不太理想,关键信息也不会完全丢失。重叠的代价是存储和检索时会多一些冗余,但换来的是召回率的提升,通常值得。

下面是一个基于 TypeScript 的递归切块实现思路:

interface Chunk { content: string; metadata: { source: string; heading?: string; chunkIndex: number; }; } function recursiveChunk( text: string, maxChunkSize: number = 800, overlap: number = 100 ): Chunk[] { const chunks: Chunk[] = []; // 先按标题切分 const sections = text.split(/\n(?=#{1,3}\s)/); for (const section of sections) { if (section.length <= maxChunkSize) { chunks.push(createChunk(section)); } else { // 超长章节按段落切分 const paragraphs = section.split(/\n\n+/); let buffer = ''; for (const para of paragraphs) { if ((buffer + para).length > maxChunkSize && buffer) { chunks.push(createChunk(buffer)); // 保留重叠部分 buffer = buffer.slice(-overlap) + para; } else { buffer += (buffer ? '\n\n' : '') + para; } } if (buffer) chunks.push(createChunk(buffer)); } } return chunks; }

这段代码的核心逻辑是“优先按语义边界切,超长再降级”。实际项目里你还可以根据文档类型调整切分规则,比如 API 文档按接口切,FAQ 按问答对切。

注意:切块大小没有万能值。我的经验是中文文档 500 到 800 字比较合适,英文文档可以到 1000 到 1500 字符。代码文档要按函数切,不要按行数切。

3.2 向量化:选对 Embedding 模型比调参更重要

向量化就是把文本转成一串浮点数,这串数字代表了文本的语义。语义相近的文本,向量距离就近。检索的时候就是算向量距离,找最近的几个。

Embedding 模型的选择是这一步的关键。不同模型在中文语义理解、长文本处理、代码理解上的能力差异很大。我的建议是优先选专门针对中文优化的模型,或者至少在中英文混合场景下表现稳定的模型。如果你用的是云服务提供的 Embedding API,先拿一批真实业务问题做召回测试,看 Top 5 的召回结果是否相关,这比看模型排行榜靠谱得多。

向量维度也是一个需要考虑的因素。维度越高,表达能力越强,但存储和计算成本也越高。常见的维度有 768、1024、1536、3072。对于大多数业务场景,1024 或 1536 维已经足够。除非你的知识库特别大、语义特别复杂,否则没必要追求最高维度。

还有一个容易被忽略的点是查询和文档要用同一个 Embedding 模型。有些模型专门为查询优化,有些专门为文档优化,混用会导致向量空间不一致,检索效果直接崩掉。这个坑我踩过一次,排查了大半天才发现是模型不匹配。

3.3 检索策略:纯向量检索不够用怎么办

纯向量检索有一个天然缺陷:它对关键词精确匹配不敏感。比如用户搜“错误码 E5021”,向量检索可能会召回一堆语义相近但错误码不同的内容。这时候就需要混合检索来补位。

混合检索的思路是同时做向量检索和关键词检索(比如 BM25),然后把两路结果融合。融合算法常用的是 RRF(Reciprocal Rank Fusion),它根据两路结果的排名来计算综合得分,不需要两路分数在同一量纲上,工程上很好用。

function rrfFusion( vectorResults: SearchResult[], keywordResults: SearchResult[], k: number = 60 ): SearchResult[] { const scoreMap = new Map<string, number>(); vectorResults.forEach((r, i) => { scoreMap.set(r.id, (scoreMap.get(r.id) || 0) + 1 / (k + i + 1)); }); keywordResults.forEach((r, i) => { scoreMap.set(r.id, (scoreMap.get(r.id) || 0) + 1 / (k + i + 1)); }); return Array.from(scoreMap.entries()) .sort((a, b) => b[1] - a[1]) .map(([id]) => findResultById(id, vectorResults, keywordResults)); }

除了混合检索,重排序也是提升检索质量的重要手段。先用向量检索召回 Top 20 到 50 个候选,再用一个重排序模型对这几十个结果做精细打分,选出 Top 3 到 5 个送给大模型。重排序模型通常比 Embedding 模型更重,但只对少量候选做计算,延迟可以接受。

4. 实操过程:从零搭建一条可用的知识获取管道

4.1 环境准备与依赖选型

先把工程骨架搭起来。我用的是 Node.js 20 以上的版本,TypeScript 5.x,包管理用 pnpm。核心依赖包括向量数据库客户端、Embedding 服务 SDK、文本处理工具。

向量数据库我选的是 Qdrant,它的 TypeScript 客户端比较成熟,本地开发可以用 Docker 一键启动,生产环境也有云服务版本。Embedding 服务我用的是兼容 OpenAI 接口的国内服务,主要是考虑到访问稳定性和中文效果。文本处理方面,用gray-matter解析 Markdown 的 frontmatter,用js-tiktoken做 token 计数。

pnpm add @qdrant/js-client-rest openai gray-matter js-tiktoken pnpm add -D typescript tsx @types/node

项目结构我习惯这样组织:

src/ ingest/ # 数据接入与切块 loader.ts chunker.ts index/ # 向量化与入库 embedder.ts indexer.ts retrieve/ # 检索与重排序 searcher.ts reranker.ts generate/ # 提示词组装与生成 prompt.ts generator.ts types.ts # 全局类型定义

这样分层的好处是每一层可以独立测试和替换。比如你想换一个 Embedding 模型,只需要改embedder.ts,其他层不受影响。

4.2 数据接入与切块的完整实现

数据接入层要处理的第一件事是格式统一。我写了一个loader,把不同来源的数据都转成统一的RawDocument结构:

interface RawDocument { id: string; content: string; metadata: { source: string; title: string; updatedAt: string; category?: string; }; } async function loadMarkdownFile(filePath: string): Promise<RawDocument> { const raw = await fs.readFile(filePath, 'utf-8'); const { data, content } = matter(raw); return { id: hashContent(content), content, metadata: { source: filePath, title: data.title || path.basename(filePath), updatedAt: data.updatedAt || new Date().toISOString(), category: data.category, }, }; }

切块的时候,我会把文档的标题层级信息保留到每个块的元数据里。这样检索出来之后,模型不仅知道内容是什么,还知道这段内容属于哪个章节,上下文更完整。

function chunkDocument(doc: RawDocument): Chunk[] { const lines = doc.content.split('\n'); const chunks: Chunk[] = []; let currentHeading = ''; let buffer: string[] = []; for (const line of lines) { const headingMatch = line.match(/^(#{1,3})\s+(.+)/); if (headingMatch) { if (buffer.length) { chunks.push(...flushBuffer(buffer, currentHeading, doc)); buffer = []; } currentHeading = headingMatch[2]; } else { buffer.push(line); } } if (buffer.length) chunks.push(...flushBuffer(buffer, currentHeading, doc)); return chunks; }

flushBuffer函数负责把缓冲区里的内容按段落进一步切分,并加上重叠。这里有个细节:重叠部分我通常取上一个块的最后一到两个完整句子,而不是机械地截取固定字符数。这样重叠的内容也是语义完整的。

4.3 向量化与批量入库

向量化这一步要注意批量处理。一条一条调 Embedding API 效率太低,而且容易触发限流。我一般会攒够 20 到 50 个块批量调用,同时做好失败重试。

async function embedBatch(texts: string[]): Promise<number[][]> { const BATCH_SIZE = 32; const results: number[][] = []; for (let i = 0; i < texts.length; i += BATCH_SIZE) { const batch = texts.slice(i, i + BATCH_SIZE); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: batch, }); results.push(...response.data.map(d => d.embedding)); // 简单的限流保护 if (i + BATCH_SIZE < texts.length) { await sleep(200); } } return results; }

入库的时候,Qdrant 的 collection 需要提前创建,指定向量维度和距离度量。距离度量我一般用 Cosine,因为大多数 Embedding 模型的训练目标就是优化余弦相似度。

await qdrant.createCollection('knowledge_base', { vectors: { size: 1536, distance: 'Cosine', }, }); await qdrant.upsert('knowledge_base', { wait: true, points: chunks.map((chunk, i) => ({ id: i, vector: embeddings[i], payload: { content: chunk.content, source: chunk.metadata.source, heading: chunk.metadata.heading, }, })), });

注意:入库时一定要把原始文本存在 payload 里。检索的时候只需要返回 payload,不需要再回查数据库。这样检索延迟会低很多。

4.4 检索链路的组装与调优

检索链路我通常会做三层:向量召回、关键词召回、重排序。向量召回负责语义匹配,关键词召回负责精确匹配,重排序负责精细打分。

async function retrieve(query: string, topK: number = 5): Promise<Chunk[]> { // 第一路:向量检索 const queryVector = await embedSingle(query); const vectorResults = await qdrant.search('knowledge_base', { vector: queryVector, limit: 20, }); // 第二路:关键词检索(简化版,实际可以用 BM25) const keywordResults = await keywordSearch(query, 20); // 融合 const fused = rrfFusion(vectorResults, keywordResults); // 重排序 const reranked = await rerank(query, fused.slice(0, 20)); return reranked.slice(0, topK); }

重排序模型我用的是 Cohere 的 rerank 接口,它对中英文混合场景支持得不错。如果你不想引入外部服务,也可以用一个小型的交叉编码器模型本地部署,但延迟会高一些。

检索出来之后,还有一个容易被忽略的步骤:上下文组装。不要把检索到的块直接拼接就完事,要在每个块前面加上来源标注,让模型知道每段内容的出处。这样模型在生成回答时,可以引用来源,也更容易判断哪些内容更可信。

function assembleContext(chunks: Chunk[]): string { return chunks .map((c, i) => `[片段 ${i + 1}] 来源:${c.metadata.source}\n${c.content}`) .join('\n\n---\n\n'); }

4.5 生成层的提示词设计

提示词的设计目标是让模型基于检索到的内容回答,而不是凭自己的记忆回答。我通常会在系统提示词里明确几条规则:

  • 优先使用提供的片段内容回答
  • 如果片段中没有相关信息,明确说“根据现有资料无法回答”
  • 引用片段时标注来源编号
  • 不要编造片段中不存在的信息
const SYSTEM_PROMPT = `你是一个基于知识库回答问题的助手。 请严格基于下面提供的知识片段回答问题。 如果片段中没有相关信息,请直接说明无法回答,不要编造。 回答时请标注引用的片段编号。`; function buildPrompt(query: string, context: string): string { return `${SYSTEM_PROMPT}\n\n知识片段:\n${context}\n\n用户问题:${query}`; }

这套提示词看起来简单,但实测下来对抑制幻觉非常有效。关键点是“如果片段中没有相关信息,请直接说明无法回答”这一句,它给了模型一个安全的退路,模型就不会硬编答案。

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

5.1 检索结果不相关:从四个方向排查

检索不相关是最常见的问题。我的排查顺序是:先看切块,再看向量模型,再看检索策略,最后看查询改写。

切块问题的表现是检索出来的内容总是缺头少尾,或者一个块里混了好几个不相关的主题。解决办法是调整切块策略,确保每个块是完整的语义单元。

向量模型问题的表现是语义相近的内容检索不出来,或者完全不相关的内容排在最前面。解决办法是换一个更适合你业务语言的 Embedding 模型,或者用真实业务问题做一轮召回评测。

检索策略问题的表现是关键词精确匹配的内容排不到前面。解决办法是引入混合检索和重排序。

查询改写问题的表现是用户的口语化提问和文档的书面化表述对不上。解决办法是在检索前先用大模型把用户问题改写成更适合检索的形式,或者生成多个查询变体做多路召回。

5.2 模型不按检索内容回答:提示词和上下文长度都要查

有时候检索结果明明是对的,但模型就是不用,还是按自己的记忆回答。这种情况通常有两个原因。

一是提示词不够强硬。模型没有明确被告知“必须基于片段回答”,它就会优先用自己的知识。解决办法是在系统提示词里把规则写死,并且给出正反例。

二是上下文太长,模型注意力被稀释。如果你一次塞了十几个片段进去,模型可能只关注到其中一两个。解决办法是控制检索返回的片段数量,通常 3 到 5 个就够了,配合重排序保证质量。

5.3 知识更新不及时:增量索引与版本管理

知识库不是建一次就完事了,文档会更新,新内容会加入。如果每次更新都全量重建索引,成本高而且没必要。我的做法是做增量索引:给每个文档算一个内容哈希,更新时只处理哈希变化的文档,删除时按文档 ID 批量删除对应的向量。

async function incrementalIndex(doc: RawDocument) { const existingHash = await getDocHash(doc.id); const newHash = hashContent(doc.content); if (existingHash === newHash) return; // 无变化,跳过 // 删除旧向量 await qdrant.delete('knowledge_base', { filter: { must: [{ key: 'docId', match: { value: doc.id } }] }, }); // 重新切块、向量化、入库 const chunks = chunkDocument(doc); await indexChunks(chunks); await saveDocHash(doc.id, newHash); }

5.4 常见问题速查表

问题现象可能原因排查方向解决手段
检索结果不相关切块不合理检查块是否语义完整调整切块策略,增加重叠
关键词搜不到纯向量检索缺陷测试关键词精确匹配引入混合检索与重排序
模型不用检索内容提示词太弱检查系统提示词规则强化提示词,减少片段数量
回答有幻觉检索质量差人工评估 Top 5 召回优化切块与向量模型
检索延迟高候选集太大检查召回数量与重排序减少召回数,缓存查询向量
知识更新不生效索引未更新检查增量索引逻辑实现哈希比对与增量更新

5.5 几个我踩过的坑

第一个坑是向量维度和数据库配置不匹配。我换了一个 Embedding 模型,维度从 1536 变成了 1024,但忘了重建 collection,结果入库一直报错。后来养成了习惯,换模型必重建 collection。

第二个坑是查询向量和文档向量用了不同的模型。有一次我文档用 A 模型索引,查询用 B 模型编码,检索结果完全乱套。排查了半天才想起来是模型不一致。这个错误很低级,但真的容易犯。

第三个坑是切块时把代码块切断了。技术文档里经常有代码示例,如果按固定长度切,代码会被拦腰截断,检索出来完全没法用。后来我在切块逻辑里加了代码块保护,遇到 ``` 包裹的内容整体保留,不参与切分。

第四个坑是忽略了元数据过滤。早期版本我没有在检索时加过滤条件,结果用户问 A 产品的问题,检索出来一堆 B 产品的文档。后来在 payload 里加了产品线字段,检索时按产品线过滤,准确率立刻上来了。

6. 知识获取管道的扩展方向与个人体会

6.1 从基础 RAG 到 Agentic RAG

基础 RAG 的链路是固定的:检索一次,生成一次。但实际业务问题往往需要多步推理,一次检索不够用。这时候就需要Agentic RAG,让 Agent 自己决定什么时候检索、检索什么、检索几次。

比如用户问“我们产品和竞品在定价策略上的差异是什么”,Agent 可能需要先检索自家产品的定价文档,再检索竞品的定价信息,然后对比分析。这种多跳检索用固定链路很难做好,但用 Agent 编排就很自然。

在 TypeScript 里实现 Agentic RAG,核心是给 Agent 提供一组工具:search_knowledge_base、get_document_by_id、compare_documents等。Agent 根据问题自主决定调用哪些工具、按什么顺序调用。这部分内容展开会很长,后面可以单独写一篇来讲。

6.2 知识图谱与 RAG 的结合

纯向量检索擅长处理非结构化文本,但对实体之间的关系推理能力较弱。比如“A 公司的 CEO 是谁”这种问题,如果文档里没有直接写,向量检索就很难回答。但如果有一个知识图谱,记录了 A 公司和高管之间的关系,就能直接查出来。

GraphRAG 的思路就是把知识图谱和向量检索结合起来,用图谱处理关系型查询,用向量处理语义型查询。这个方向目前还在快速发展中,工程落地案例不算多,但值得关注。

6.3 我个人的一些经验

做 RAG 这段时间,最大的体会是:检索质量决定上限,生成质量决定下限。检索做不好,模型再强也白搭;检索做好了,即使用中等能力的模型,回答质量也很可观。所以优化精力应该优先花在检索链路上,而不是一味追求更大的生成模型。

另一个体会是:评测驱动优化。不要凭感觉调参,要建一个评测集,包含真实业务问题和标准答案,每次改动都跑一遍评测,看召回率和准确率的变化。没有评测的优化就是盲人摸象。

最后一点:从简单方案开始,按需迭代。不要一上来就上混合检索、重排序、查询改写全套,先把基础链路跑通,用真实数据测出瓶颈在哪里,再针对性地优化。我见过太多项目在早期过度设计,结果复杂度上去了,效果却没提升多少。

知识获取管道是 AI Agent 的基础设施,它不像模型能力那样引人注目,但它的质量直接决定了 Agent 在真实业务场景中能不能用、好不好用。把这条管道搭扎实,后面的 Agent 编排、多轮对话、工具调用才有意义。

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

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

立即咨询