☰
TypeScript 实战:从零搭建 RAG 知识获取管道,打通 AI Agent 私有知识瓶颈
2026/11/4 9:35:36 网站建设 项目流程

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

做 AI Agent 开发的人,迟早会撞上一堵墙:模型本身很聪明,但它不知道你公司内部的业务规则、不知道你上周刚更新的产品文档、更不知道你私有的那套运维手册里写了什么。你问它一个非常具体的问题,它要么一本正经地胡说八道,要么给你一个“根据我的训练数据”这种让人血压升高的回答。这个问题的本质不是模型不够强,而是它的知识边界被锁死在了训练截止的那一刻。

知识获取管道就是用来打破这堵墙的。而RAG(检索增强生成)是目前最主流、最务实、也最容易落地的一条路径。它的核心思路用一句话就能说清楚:在模型回答问题之前,先去你的知识库里把相关内容找出来,塞进模型的上下文里,让它“看着材料答题”。这就像开卷考试和闭卷考试的区别——闭卷考的是记忆力,开卷考的是检索和整合能力。我们做 Agent 的时候,要的不是一个记忆力超群的模型,而是一个能随时查阅资料、基于事实回答问题的助手。

这一篇是“走进 AI Agent”系列的第四篇,前面我们聊了 Agent 的基本架构、工具调用、记忆机制,现在轮到知识获取管道了。为什么说它是分水岭?因为在此之前,你的 Agent 只是一个“会聊天的程序”;在此之后,它才真正具备了“基于私有知识做决策”的能力。这个能力一旦打通,你能做的事情就完全不一样了——企业内部知识问答、客服工单自动回复、代码库语义搜索、合同条款比对,这些场景全部依赖 RAG 管道。

我选择用TypeScript来写这一篇的示例代码,原因很直接:Agent 开发的前端交互层、Node.js 服务层、甚至很多 Serverless 部署场景,TypeScript 都是第一选择。而且 TypeScript 的类型系统能在你拼接 Prompt、处理检索结果、调用模型 API 的时候帮你挡掉大量低级错误。如果你之前用 Python 做 RAG,思路完全一样,只是换了个语言表达。

这篇文章适合谁看?如果你已经了解 AI Agent 的基本概念,知道什么是 LLM、什么是 Prompt,但还没动手搭过一套完整的 RAG 管道,那这篇就是写给你的。如果你已经用过一些 RAG 框架但总觉得效果不稳定、检索命中率上不去,这篇里的调优经验和踩坑记录也能帮你省不少时间。我会从零开始,把知识获取管道的每一个环节拆开讲清楚,包括文档加载、文本切分、向量化、存储、检索、重排序、Prompt 组装,最后给出一个可以直接跑的 TypeScript 实现。

注意:RAG 不是银弹。它解决的是“模型不知道”的问题,不解决“模型不理解”的问题。如果你的问题需要复杂推理,RAG 只能提供素材,推理还得靠模型本身或者 Agent 的规划能力。

2. RAG 管道的整体架构与核心环节拆解

2.1 从原始文档到可检索知识库的完整链路

很多人第一次接触 RAG 的时候,脑子里只有一个模糊的概念:“把文档扔进去,然后问问题”。但真正动手的时候会发现,从“文档”到“能回答问题”之间,有一条相当长的链路,每一个环节都有坑。我把这条链路拆成六个核心阶段,每个阶段都有明确的输入和输出。

第一阶段是文档加载。你的知识可能散落在 PDF、Word、Markdown、HTML、数据库、甚至飞书文档和 Notion 页面里。加载器的任务就是把这些异构数据统一成纯文本或者结构化文本。这一步看起来简单,但 PDF 里的表格、扫描件里的图片、HTML 里的导航栏噪音,都会直接影响后续的检索质量。

第二阶段是文本切分。你不能把一整本 200 页的手册直接塞给模型,上下文窗口放不下,而且检索精度会急剧下降。切分的目的是把长文档拆成语义完整的短片段,每个片段大概几百个 token,既能独立表达一个完整意思,又不会太长导致检索时引入噪音。

第三阶段是向量化。把每个文本片段通过 Embedding 模型转换成一个高维向量,这个向量在数学空间里的位置代表了这段文字的语义。语义相近的文本,向量距离就近。这是 RAG 能实现“语义检索”而不是“关键词匹配”的关键。

第四阶段是存储与索引。向量需要存在一个专门的地方,通常是向量数据库。除了向量本身,还要存储原始文本、元数据(来源、页码、时间等),并且建立索引来加速检索。索引结构的选择直接影响检索速度和召回率。

第五阶段是检索与重排序。用户提问后,先把问题向量化,然后在向量数据库里找最相似的 Top-K 个片段。但向量相似度高不代表真的相关,所以通常还需要一个重排序环节,用更精细的模型对这 K 个结果重新打分排序。

第六阶段是Prompt 组装与生成。把检索到的片段按照一定策略拼接到 Prompt 里,连同用户问题一起发给 LLM,让它基于这些材料生成回答。这一步的 Prompt 模板设计非常讲究,直接决定了模型会不会“跑偏”。

这六个阶段串起来,就是一条完整的知识获取管道。下面我会逐个拆解每个环节的技术选型、参数计算和实操要点。

2.2 为什么选择 TypeScript 而不是 Python

这个问题我被问过很多次。Python 在 AI 领域的生态确实更成熟,LangChain、LlamaIndex 这些框架都是 Python 优先。但 TypeScript 在 Agent 开发中有几个不可替代的优势。

首先是类型安全。RAG 管道里涉及大量的数据结构转换:文档对象、切分后的片段、向量数组、检索结果、Prompt 模板。用 TypeScript 把这些类型定义清楚,编译器会在你写代码的时候就告诉你哪里对不上,而不是等到运行时才发现“哦,这个字段名写错了”。我自己的经验是,用 TypeScript 写 RAG 管道,调试时间至少减少三分之一。

其次是全栈统一。如果你的 Agent 最终要以 Web 应用的形式交付,前端用 React 或 Vue,后端用 Node.js,那整个技术栈统一用 TypeScript,代码复用和类型共享会非常顺畅。你可以在前端直接复用后端定义的检索结果类型,不用手动维护两套接口定义。

第三是部署轻量。Node.js 的 Serverless 部署体验比 Python 好很多,冷启动更快,依赖打包更简单。对于中小规模的 RAG 应用,用 TypeScript 写一个 Edge Function 就能跑起来,不需要维护复杂的 Python 环境。

当然,如果你要做模型微调、大规模数据处理、或者用一些只有 Python 才有的 NLP 工具,那 Python 仍然是更好的选择。但对于大多数 Agent 应用的知识获取管道来说,TypeScript 完全够用,而且开发体验更好。

2.3 核心参数速查:切分长度、重叠窗口与 Top-K 的取值逻辑

在动手写代码之前,有几个核心参数必须先想清楚。这些参数没有“标准答案”,但有明确的取值逻辑和调整方向。

参数典型取值影响调整方向
切分长度(chunk size)256-1024 token太短语义不完整,太长引入噪音技术文档 512,对话记录 256,法律合同 1024
重叠窗口(overlap)chunk size 的 10%-20%防止边界信息丢失一般设 50-100 token
Top-K3-10太少可能漏掉关键信息,太多引入噪音先用 5,根据命中率调整
相似度阈值0.7-0.8过滤掉不相关结果太高会漏,太低会引入噪音
重排序候选数Top-K 的 2-3 倍给重排序模型更多选择先检索 20 个,重排后取 5 个

切分长度的选择有一个很实用的判断标准:一个 chunk 应该能独立回答一个完整的问题。比如你问“XX 功能的配置步骤是什么”,那一个 chunk 就应该包含完整的配置步骤,而不是被切成两半。对于技术文档,512 token 通常能覆盖一个完整的小节;对于法律合同,条款往往很长,可能需要 1024 token;对于客服对话记录,每轮对话很短,256 token 就够了。

重叠窗口的作用是防止关键信息刚好落在切分边界上被切断。比如一句话的前半段在 chunk A 的末尾,后半段在 chunk B 的开头,如果没有重叠,两个 chunk 都读不懂这句话。设 10%-20% 的重叠,能有效缓解这个问题,但也会增加存储和检索的开销,所以不要设太大。

Top-K 的取值逻辑是:宁可多召回,不可漏召回。因为后续还有重排序环节可以过滤,但如果在检索阶段就漏掉了相关片段,后面再怎么重排也救不回来。所以第一轮检索可以取大一点,比如 20 个,然后用重排序模型精选出 5 个送给 LLM。

3. 文档加载与文本切分的实操细节

3.1 不同格式文档的加载策略与清洗要点

文档加载是整条管道的入口,入口没处理好,后面全是白费功夫。我按常见格式分别说一下加载策略和清洗要点。

Markdown 和纯文本是最简单的,直接读文件内容就行。但要注意去掉 YAML front matter、HTML 注释、多余的空白行。Markdown 的标题层级信息很有价值,可以在切分的时候利用标题作为 chunk 的元数据,检索时能提供额外的上下文。

PDF是最麻烦的。PDF 本质上是排版格式,不是语义格式,所以提取出来的文本经常是乱的。表格会变成一堆散乱的数字,双栏排版会交错在一起,页眉页脚会混入正文。我的经验是:优先用基于布局分析的 PDF 解析库,而不是简单的文本提取。如果 PDF 是扫描件,还需要先做 OCR。清洗的时候要重点处理:去掉重复的页眉页脚、修复断行、把表格转换成 Markdown 表格格式。

HTML的噪音最多。导航栏、侧边栏、广告、评论区,这些内容对知识问答毫无价值,但如果不清理,它们会被切分、向量化、检索出来,严重干扰结果。用 Readability 类的库提取正文,或者用 CSS 选择器精准定位内容区域。清洗后还要把 HTML 标签去掉,保留纯文本。

Word 文档相对好处理,但要注意批注、修订记录、文本框里的内容。这些内容有时候是重要的补充信息,有时候是废弃的草稿,需要根据实际情况决定是否保留。

数据库和 API返回的数据通常是结构化的,加载的时候要考虑怎么把结构化数据“扁平化”成文本。比如一条用户记录,可以拼接成“用户张三,年龄 30 岁,注册时间 2024 年 1 月,最近一次登录是 2025 年 3 月”。这样向量化之后才能被语义检索到。

实操心得:文档加载阶段一定要保留元数据。来源文件名、页码、章节标题、更新时间,这些信息在后续检索和 Prompt 组装时非常有用。比如你可以让模型在回答时标注“根据《XX 手册》第 3 章”,这能大幅提升回答的可信度。

3.2 文本切分的四种主流方法对比

文本切分不是简单地按字数切,不同的切分方法对检索质量影响巨大。我对比一下四种主流方法。

固定长度切分是最简单的,按 token 数或者字符数切,每段固定长度,加上重叠窗口。优点是实现简单、速度快、chunk 大小均匀。缺点是经常在句子中间切断,语义不完整。适合对检索精度要求不高的场景,或者作为 baseline 快速验证。

递归字符切分是 LangChain 默认的方法,它按照一组分隔符的优先级递归切分:先按段落切,如果段落太长就按句子切,如果句子还太长就按逗号切,最后才按字符切。这样能尽量保持语义边界。实现也不复杂,是我最推荐的方法。

语义切分是用 Embedding 模型判断相邻句子的语义相似度,在相似度骤降的地方切分。这种方法切出来的 chunk 语义最完整,但计算成本高,因为要对每个句子做向量化。适合对质量要求极高的场景,比如法律合同、医学文献。

基于文档结构的切分是利用文档本身的层级结构来切分,比如 Markdown 的标题、HTML 的 section、PDF 的章节。这种方法切出来的 chunk 天然带有层级上下文,检索时可以附带父级标题信息。对于结构良好的技术文档,这是效果最好的方法。

实际项目中,我通常用递归字符切分 + 文档结构信息的组合:先按文档结构切出大块,再用递归字符切分把大块切成合适大小的 chunk,同时保留章节标题作为元数据。

3.3 切分参数的实测调优记录

我拿一份 200 页的技术产品手册做过一组对比实验,固定其他条件,只调整切分参数,看检索命中率的变化。命中率的定义是:对于 50 个测试问题,检索结果中包含正确答案所在片段的比例。

切分长度重叠窗口命中率平均检索耗时
256062%45ms
2565071%48ms
512068%52ms
51210083%56ms
1024065%61%
102420079%68ms

从数据可以看出几个规律。第一,有重叠比没重叠命中率明显更高,512 长度加 100 重叠是这组实验的最优组合。第二,切分长度不是越大越好,1024 的命中率反而比 512 低,因为 chunk 太长引入了噪音,向量表示被稀释了。第三,重叠窗口会增加检索耗时,因为 chunk 数量变多了,但增幅在可接受范围内。

当然这只是一份文档的实验结果,不同文档类型的最优参数可能不同。我的建议是:先用 512/100 作为默认值,然后拿一批真实问题做测试,根据命中率微调。如果发现经常漏掉关键信息,就增大重叠;如果发现检索结果太杂,就减小 chunk size。

4. 向量化、存储与检索的工程实现

4.1 Embedding 模型选型:维度、成本与中文支持的权衡

Embedding 模型的选择直接决定了检索质量的上限。选型的时候主要看四个维度:向量维度、语义质量、中文支持、调用成本。

向量维度影响存储成本和检索速度。维度越高,表达能力越强,但存储和计算开销也越大。常见的维度有 384、768、1024、1536、3072。对于大多数应用场景,768 到 1536 维已经足够。除非你的知识库非常庞大且语义细微,否则没必要追求最高维度。

语义质量是最核心的指标,通常看 MTEB 等基准测试的排名。但要注意,基准测试的排名不一定完全对应你的实际场景。最好的方法是拿你自己的数据做小规模测试,看检索命中率。

中文支持是很多国内开发者容易忽略的点。有些 Embedding 模型在英文上表现很好,但中文语义理解能力很弱。选型的时候一定要用中文问题测试。目前中文表现较好的模型有 BGE 系列、M3E 系列、以及一些多语言模型。

调用成本包括 API 费用和延迟。如果知识库很大,每次更新都要重新向量化,成本会很高。这时候可以考虑本地部署 Embedding 模型,虽然初始投入大,但长期成本更低,而且没有网络延迟。

我的实际选择是:中小规模知识库用 API 调用,大规模或高频更新用本地部署。本地部署推荐用 ONNX Runtime 跑量化后的模型,在 Node.js 里也能获得不错的推理速度。

4.2 向量数据库的选型对比与 TypeScript 接入

向量数据库的选择很多,我列几个主流的,从 TypeScript 接入的友好度、部署复杂度、功能完整度三个维度对比。

数据库TypeScript 支持部署方式特点适用场景
Pinecone官方 SDK全托管零运维,性能好快速验证,中小规模
Weaviate官方 SDK自托管/云功能全,支持混合检索中大规模,需要混合检索
Qdrant官方 SDK自托管/云性能强,过滤功能好大规模,需要复杂过滤
Chroma社区 SDK自托管轻量,易上手本地开发,小规模
pgvector通过 pg 客户端自托管和关系数据在一起已有 PostgreSQL 的项目

如果你刚开始做 RAG,我建议先用Chroma在本地跑通流程,它的 API 非常简单,几行代码就能建库、插入、查询。等验证了效果,再根据规模迁移到 Qdrant 或 Pinecone。

TypeScript 接入向量数据库的时候,有几个工程细节要注意。第一是批量插入,不要一条一条插,要攒一批一起插,能大幅提升速度。第二是索引配置,大多数向量数据库支持 HNSW 或 IVF 索引,建索引的时候要调参,平衡检索速度和召回率。第三是元数据过滤,检索的时候可以加过滤条件,比如只搜某个来源的文档、只搜某个时间段的内容,这能显著提升检索精度。

4.3 检索策略:纯向量检索、关键词检索与混合检索

检索策略的选择是 RAG 效果的关键分水岭。很多人以为向量检索就是全部,其实不然。

纯向量检索适合语义匹配的场景。比如用户问“怎么配置超时时间”,文档里写的是“timeout 参数设置方法”,关键词不匹配但语义匹配,向量检索能找出来。但向量检索的弱点是:对于精确匹配的场景,比如产品型号、错误码、人名,它可能不如关键词检索。

关键词检索(BM25 等)适合精确匹配的场景。用户搜“ERR_4032”,关键词检索能精准找到包含这个错误码的文档。但它无法处理语义变体,用户问“权限不足报错”,文档写的是“访问被拒绝”,关键词检索就找不到。

混合检索是把两者结合起来,各取所长。通常的做法是:同时执行向量检索和关键词检索,然后用 RRF(Reciprocal Rank Fusion)等算法融合两路结果。混合检索的命中率通常比单一策略高 10%-20%,是目前生产环境的主流选择。

在 TypeScript 里实现混合检索,你可以用向量数据库自带的混合检索功能(比如 Weaviate 的 hybrid search),也可以自己分别调用两路检索再融合。自己实现的好处是灵活,可以控制两路的权重和融合算法。

4.4 重排序:让 Top-K 结果更精准的最后一道关卡

检索出来的 Top-K 结果,向量相似度高不代表真的相关。重排序就是用更精细的模型对这 K 个结果重新打分,把真正相关的排到前面。

重排序模型通常比 Embedding 模型更大、更慢,但更准。它的输入是“问题 + 候选片段”,输出是一个相关性分数。因为只对 K 个候选做计算,所以即使模型很大,总耗时也可控。

常见的重排序方案有两种。一种是交叉编码器,把问题和片段拼在一起输入模型,输出相关性分数。精度高但速度慢。另一种是基于 LLM 的重排序,让 LLM 对每个候选片段打分。灵活但成本高,适合对精度要求极高的场景。

我的实践经验是:先用交叉编码器做重排序,如果效果还不够,再考虑 LLM 重排序。交叉编码器在大多数场景下已经能带来明显的提升,而且延迟增加在可接受范围内。

重排序的候选数一般是最终 Top-K 的 2-3 倍。比如你最终要 5 个片段送给 LLM,那就先检索 15-20 个,重排序后取前 5。这样既保证了召回率,又保证了精度。

5. 从零搭建 TypeScript RAG 管道的完整实操

5.1 项目初始化与依赖安装

先把项目骨架搭起来。我用的是 Node.js 20 + TypeScript 5,包管理用 pnpm。

mkdir rag-pipeline && cd rag-pipeline pnpm init pnpm add typescript tsx @types/node pnpm add openai chromadb pnpm add -D @types/node

这里我用了openai包来调用 Embedding 和 LLM,用chromadb作为本地向量数据库。如果你用其他模型服务,把openai换成对应的 SDK 就行。

初始化 TypeScript 配置:

npx tsc --init

然后修改tsconfig.json,重点设置这几个选项:

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"] }

注意:TypeScript 7.0 之后moduleResolution: "node10"和baseUrl选项会被弃用,新项目直接用bundler或node16,避免以后迁移麻烦。

5.2 文档加载与切分模块实现

先定义核心类型:

// src/types.ts export interface Document { id: string; content: string; metadata: { source: string; title?: string; page?: number; updatedAt?: string; }; } export interface Chunk { id: string; documentId: string; content: string; metadata: Document['metadata'] & { chunkIndex: number; }; }

然后是文档加载器,这里以 Markdown 为例:

// src/loader.ts import fs from 'fs/promises'; import path from 'path'; import { Document } from './types.js'; export async function loadMarkdown(filePath: string): Promise<Document> { const content = await fs.readFile(filePath, 'utf-8'); const fileName = path.basename(filePath, '.md'); // 去掉 YAML front matter const cleaned = content.replace(/^---[\s\S]*?---\n/, ''); return { id: fileName, content: cleaned, metadata: { source: filePath, title: fileName, updatedAt: new Date().toISOString(), }, }; }

切分模块用递归字符切分,保留章节标题作为元数据:

// src/splitter.ts import { Document, Chunk } from './types.js'; const DEFAULT_CHUNK_SIZE = 512; const DEFAULT_OVERLAP = 100; export function splitDocument( doc: Document, chunkSize = DEFAULT_CHUNK_SIZE, overlap = DEFAULT_OVERLAP ): Chunk[] { const chunks: Chunk[] = []; const paragraphs = doc.content.split(/\n\n+/); let currentChunk = ''; let chunkIndex = 0; let currentTitle = doc.metadata.title || ''; for (const para of paragraphs) { // 检测标题行,更新当前章节标题 const titleMatch = para.match(/^#{1,3}\s+(.+)/); if (titleMatch) { currentTitle = titleMatch[1].trim(); } // 如果加入这段会超长,先保存当前 chunk if (currentChunk.length + para.length > chunkSize && currentChunk) { chunks.push({ id: `${doc.id}-${chunkIndex}`, documentId: doc.id, content: currentChunk.trim(), metadata: { ...doc.metadata, title: currentTitle, chunkIndex, }, }); chunkIndex++; // 保留重叠部分 const overlapText = currentChunk.slice(-overlap); currentChunk = overlapText + '\n\n' + para; } else { currentChunk += (currentChunk ? '\n\n' : '') + para; } } // 处理最后一个 chunk if (currentChunk.trim()) { chunks.push({ id: `${doc.id}-${chunkIndex}`, documentId: doc.id, content: currentChunk.trim(), metadata: { ...doc.metadata, title: currentTitle, chunkIndex, }, }); } return chunks; }

这段代码有几个关键点。第一,按段落切分而不是按字符切分,尽量保持语义完整。第二,检测 Markdown 标题行,把章节标题存到元数据里,检索时可以用。第三,重叠部分取上一个 chunk 的末尾,拼到下一个 chunk 的开头。

5.3 向量化与入库的完整代码

向量化和入库的流程是:把 chunk 内容批量发给 Embedding API,拿到向量后连同原文和元数据一起存入向量数据库。

// src/indexer.ts import OpenAI from 'openai'; import { ChromaClient } from 'chromadb'; import { Chunk } from './types.js'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const chroma = new ChromaClient({ path: 'http://localhost:8000' }); const COLLECTION_NAME = 'knowledge_base'; const BATCH_SIZE = 20; export async function indexChunks(chunks: Chunk[]): Promise<void> { const collection = await chroma.getOrCreateCollection({ name: COLLECTION_NAME, metadata: { 'hnsw:space': 'cosine' }, }); // 分批处理,避免单次请求过大 for (let i = 0; i < chunks.length; i += BATCH_SIZE) { const batch = chunks.slice(i, i + BATCH_SIZE); const embeddings = await openai.embeddings.create({ model: 'text-embedding-3-small', input: batch.map(c => c.content), }); await collection.add({ ids: batch.map(c => c.id), embeddings: embeddings.data.map(e => e.embedding), documents: batch.map(c => c.content), metadatas: batch.map(c => ({ source: c.metadata.source, title: c.metadata.title || '', chunkIndex: c.metadata.chunkIndex, })), }); console.log(`已索引 ${Math.min(i + BATCH_SIZE, chunks.length)} / ${chunks.length}`); } }

这里有几个工程细节值得展开。批量大小设 20 是一个经验值,太小会导致请求次数多、速度慢,太大可能触发 API 的 token 限制。距离度量用 cosine 余弦距离,这是文本 Embedding 最常用的度量方式。元数据只存必要的字段,因为元数据也会占用存储空间,而且检索时可以用于过滤。

5.4 检索、重排序与 Prompt 组装的串联

检索模块负责把用户问题向量化,在数据库里找相似片段,然后重排序,最后组装成 Prompt。

// src/retriever.ts import OpenAI from 'openai'; import { ChromaClient } from 'chromadb'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const chroma = new ChromaClient({ path: 'http://localhost:8000' }); export interface RetrievedChunk { content: string; source: string; title: string; score: number; } export async function retrieve( query: string, topK = 5, candidateK = 20 ): Promise<RetrievedChunk[]> { const collection = await chroma.getCollection({ name: 'knowledge_base' }); // 问题向量化 const queryEmbedding = await openai.embeddings.create({ model: 'text-embedding-3-small', input: query, }); // 第一轮检索,取较多候选 const results = await collection.query({ queryEmbeddings: [queryEmbedding.data[0].embedding], nResults: candidateK, }); const candidates: RetrievedChunk[] = results.documents[0].map((doc, i) => ({ content: doc || '', source: (results.metadatas[0][i]?.source as string) || '', title: (results.metadatas[0][i]?.title as string) || '', score: 1 - (results.distances?.[0]?.[i] || 0), })); // 简单重排序:按相似度分数排序后取 Top-K // 生产环境建议接入交叉编码器做精细重排序 return candidates .sort((a, b) => b.score - a.score) .slice(0, topK); }

Prompt 组装模块把检索结果拼接成结构化的上下文:

// src/prompt.ts import { RetrievedChunk } from './retriever.js'; export function buildPrompt( query: string, chunks: RetrievedChunk[] ): string { const context = chunks .map((c, i) => { const source = c.title ? `《${c.title}》` : c.source; return `[片段 ${i + 1}] 来源:${source}\n${c.content}`; }) .join('\n\n---\n\n'); return `你是一个知识助手。请基于以下参考资料回答用户问题。 如果参考资料中没有相关信息,请明确说明"根据现有资料无法回答",不要编造。 参考资料: ${context} 用户问题:${query} 请给出准确、简洁的回答,并在回答中标注信息来源。`; }

最后把所有模块串起来:

// src/main.ts import { loadMarkdown } from './loader.js'; import { splitDocument } from './splitter.js'; import { indexChunks } from './indexer.js'; import { retrieve } from './retriever.js'; import { buildPrompt } from './prompt.js'; import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); async function buildKnowledgeBase(filePaths: string[]) { for (const filePath of filePaths) { const doc = await loadMarkdown(filePath); const chunks = splitDocument(doc); await indexChunks(chunks); console.log(`已处理 ${filePath},切分为 ${chunks.length} 个片段`); } } async function ask(query: string) { const chunks = await retrieve(query, 5, 20); const prompt = buildPrompt(query, chunks); const response = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], temperature: 0.1, }); return response.choices[0].message.content; } // 使用示例 await buildKnowledgeBase(['./docs/manual.md', './docs/faq.md']); const answer = await ask('如何配置超时时间?'); console.log(answer);

这套代码可以直接跑起来,把docs目录换成你自己的文档就行。temperature设 0.1 是为了让回答更稳定、更贴近参考资料,减少模型自由发挥的空间。

6. 常见问题排查与效果调优实录

6.1 检索命中率低的五个典型原因

检索命中率低是 RAG 最常见的问题。我按排查优先级列一下典型原因和对策。

原因一:切分粒度不对。chunk 太大,关键信息被淹没在大量无关文字里;chunk 太小,语义不完整。排查方法是把检索结果打印出来,人工看是否包含答案。对策是调整 chunk size 和 overlap,用第 3 节的方法做对比实验。

原因二:Embedding 模型不适合中文。有些模型英文很强但中文很弱,导致中文问题的向量和中文文档的向量对不上。排查方法是用几个中文问题测试,看检索结果是否相关。对策是换成中文表现更好的模型。

原因三:缺少重排序。纯向量检索的 Top-K 里可能混入了不相关的结果,把真正相关的挤到了后面。排查方法是看 Top-20 里有没有正确答案,如果有但 Top-5 里没有,那就是重排序的问题。对策是加入重排序环节。

原因四:元数据过滤缺失。如果知识库里有多个来源的文档,检索时没有按来源过滤,可能搜到不相关来源的内容。对策是在检索时加上元数据过滤条件。

原因五:问题表述和文档表述差异太大。用户用口语提问,文档用专业术语,向量相似度可能不高。对策是加入查询改写环节,用 LLM 把用户问题改写成更适合检索的形式。

6.2 回答质量不稳定的排查思路

检索没问题但回答质量不稳定,通常是 Prompt 组装或模型调用的问题。

问题一:模型不按参考资料回答。明明检索到了正确内容,模型却用自己的知识回答。对策是在 Prompt 里强调“必须基于以下参考资料回答”,并且降低 temperature。如果还不行,可以在 Prompt 里加入 few-shot 示例,展示正确的回答格式。

问题二:模型编造信息。参考资料里没有的内容,模型自己编了一个。对策是在 Prompt 里明确要求“如果资料中没有相关信息,请说明无法回答”,并且加入“不要编造”的指令。更严格的做法是让模型在回答时引用具体的片段编号,方便人工核查。

问题三:回答太长或太短。对策是在 Prompt 里指定回答长度范围,比如“用 2-3 句话回答”或“详细说明每个步骤”。

问题四:多个片段信息冲突。检索到的不同片段说法不一致,模型不知道信哪个。对策是在 Prompt 里加入冲突处理指令,比如“如果资料之间有冲突,请指出冲突并说明”。

6.3 性能优化:从 3 秒到 300 毫秒的实操记录

RAG 管道的延迟主要来自三个环节:Embedding 调用、向量检索、LLM 生成。我记录了一次优化过程,把端到端延迟从 3 秒降到了 300 毫秒(不含 LLM 生成)。

优化项优化前优化后手段
Embedding 调用800ms50ms本地部署量化模型 + 缓存
向量检索200ms30ms调整 HNSW 索引参数
重排序1500ms150ms换用更小的交叉编码器
Prompt 组装50ms10ms减少字符串拼接开销
网络传输500ms60ms服务同区域部署

最大的优化来自 Embedding 的本地化和缓存。对于高频重复的问题,Embedding 结果可以缓存,直接命中缓存能省掉整个调用过程。向量检索的优化主要是调整 HNSW 的efSearch参数,在召回率和速度之间找平衡。重排序换用更小的模型后,精度略有下降但速度提升 10 倍,整体性价比更高。

实操心得:不要过早优化。先把管道跑通,确认效果达标,再针对瓶颈环节优化。很多时候瓶颈不在你以为的地方,用 profiling 工具测一下再动手。

6.4 常见问题速查表

现象可能原因排查方法解决方案
检索不到相关内容切分粒度不对打印检索结果人工检查调整 chunk size 和 overlap
检索结果不相关Embedding 模型不适合用中文问题测试换中文表现好的模型
正确答案在 Top-20 但不在 Top-5缺少重排序对比重排序前后结果加入交叉编码器重排序
模型不按资料回答Prompt 指令不明确检查 Prompt 模板强调基于资料回答,降低 temperature
模型编造信息缺少防幻觉指令测试无答案的问题加入“无法回答”指令和引用要求
回答延迟高某个环节耗时过长分环节计时针对性优化,加缓存
多来源信息冲突缺少冲突处理构造冲突测试用例Prompt 中加入冲突处理指令

7. 知识获取管道的扩展方向

7.1 从基础 RAG 到 Agentic RAG 的演进路径

基础 RAG 是“一次检索,一次生成”,但很多问题需要多轮检索、多步推理。Agentic RAG就是把检索能力交给 Agent 来规划:Agent 可以先分析问题,决定需要检索什么,检索后评估结果是否充分,不充分就换个查询再检索,直到信息足够才生成回答。

这种模式适合复杂问题,比如“对比 A 产品和 B 产品在超时配置上的差异”,需要分别检索两个产品的文档,然后对比。基础 RAG 可能只检索到其中一个产品的信息,而 Agentic RAG 会规划两次检索。

实现 Agentic RAG 的关键是给 Agent 提供检索工具,并设计好规划 Prompt。Agent 需要知道什么时候该检索、检索什么、检索几次。这比基础 RAG 复杂,但能处理的问题类型也丰富得多。

7.2 知识图谱与 RAG 的结合思路

向量检索擅长语义匹配,但不擅长关系推理。比如“A 的上级的上级是谁”这种问题,向量检索很难直接回答。GraphRAG的思路是把知识构建成图结构,实体是节点,关系是边,检索时沿着图遍历,能找到向量检索找不到的关联信息。

实际落地的时候,通常是混合方案:向量检索负责语义召回,知识图谱负责关系推理,两者结果融合后送给 LLM。构建知识图谱需要额外的实体抽取和关系抽取步骤,成本较高,适合实体关系密集的场景,比如企业组织架构、产品依赖关系、法律条款引用网络。

7.3 多模态知识库的展望

现在的 RAG 主要处理文本,但企业知识大量存在于图片、表格、PPT、视频里。多模态 RAG 是下一个方向:用多模态 Embedding 模型把图片和文本映射到同一向量空间,检索时可以跨模态匹配。比如用户问“那个架构图里数据库放在哪”,系统能检索到架构图并理解图中的内容。

目前多模态 RAG 还在早期阶段,工具链不够成熟,成本也较高。但对于设计文档、产品手册、培训材料这类图文混排的知识库,多模态 RAG 的价值很大。我的建议是先把文本 RAG 做扎实,等业务确实有需求了再考虑多模态扩展。

我个人在实际操作中的体会是,RAG 管道的效果 20% 靠模型,80% 靠工程细节。切分策略、检索参数、Prompt 模板,这些看起来不起眼的地方,才是决定最终效果的关键。不要迷信“换个更强的模型就能解决问题”,先把管道里的每个环节调到位,效果自然就上来了。

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

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

立即咨询