☰
TypeScript 从 0 到 1 搭建 AI Agent 知识获取管道:RAG 检索增强生成实战
2026/9/29 18:39:48 网站建设 项目流程

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

做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它对你私有的东西一无所知。你问它公司内部的报销流程,它只能编;你问它上周刚更新的产品参数,它大概率给你一个似是而非的答案。这不是模型不行,而是它的知识被冻结在训练那一刻。知识获取管道要解决的,就是让 Agent 在运行时能够"现学现卖"——从外部知识源里把相关信息捞出来,塞进上下文,再让模型基于这些真实材料作答。这套机制,业内叫RAG(检索增强生成)。

我在过去一年里陆续搭过几个 Agent 项目,从最简单的文档问答到带工具调用的多步推理,踩坑最多的环节从来不是模型选型,而是这条管道。模型换一个无非是成本和效果的权衡,但管道设计得不好,整个 Agent 就是空中楼阁——检索召回一堆无关内容,模型再强也只能一本正经地胡说。所以我把这一篇定位成"基础",不是因为它简单,而是因为它是后面所有高级玩法(Agentic RAG、GraphRAG、多路召回)的地基。地基没打牢,上面盖什么都会塌。

这篇文章适合谁看?如果你正在用TypeScript从 0 到 1 搭建 AI Agent,或者已经有一个能跑通的对话机器人但回答质量不稳定,再或者你听说过 RAG 但一直没搞明白"检索"和"生成"到底怎么衔接,那这篇就是写给你的。我会把管道的每一段拆开讲,配上可复现的代码思路和参数选择的理由,尽量让你看完就能动手改自己的项目。全文不涉及任何特定云服务或敏感工具,纯粹讲通用原理和工程实践。

先说清楚一个概念边界。很多人把 RAG 等同于"向量数据库 + 相似度搜索",这是片面的。完整的知识获取管道至少包含四个环节:文档摄入与切分、向量化与索引、检索与重排、上下文组装与生成。向量库只是第三环里的一个组件。我见过太多项目在切分这一步就埋了雷,后面检索再优化也救不回来。所以下面我会按管道的实际数据流来讲,而不是按技术名词的流行度来讲。

2. 知识获取管道的整体设计与选型思路

2.1 管道的四段式结构拆解

把知识获取管道想象成一条自来水线:源头是各种格式的原始文档,经过过滤和切分变成标准化的"水分子"(chunk),再被打上向量标签存进水塔(索引),用户提问时从水塔里抽水(检索),最后把抽出来的水调配好送到用户杯子里(生成)。任何一段出问题,最终喝到的水都不对味。

第一段是摄入与切分。原始文档可能是 PDF、Markdown、网页、数据库记录,格式五花八门。这一步的核心任务是把非结构化内容转成干净的纯文本,再切成大小合适的片段。切分策略直接决定了检索的上限——切得太碎,语义不完整;切得太粗,噪声太多。

第二段是向量化与索引。把每个 chunk 通过嵌入模型转成高维向量,存进向量数据库。这里的关键决策是嵌入模型的选择和索引结构的类型。嵌入模型决定了"语义相似"的度量准不准,索引结构决定了检索的速度和召回率。

第三段是检索与重排。用户的问题也转成向量,在库里找最相似的 top-k 个 chunk。但纯向量检索有个通病:它擅长抓语义相近,却容易漏掉关键词精确匹配的内容。所以工业级管道通常会在向量检索之后加一层重排(rerank),用更精细的模型对候选集重新打分。

第四段是上下文组装与生成。把检索到的 chunk 按一定策略拼进 prompt,连同用户问题一起送给大模型。这一步看似简单,实则有很多讲究:chunk 怎么排序、放几个、要不要加引用标记、超长怎么截断,都会影响最终答案质量。

2.2 为什么用 TypeScript 而不是 Python

这是个被问烂的问题。Python 在 AI 生态里确实占优,大量模型库和实验框架都是 Python 优先。但如果你做的是产品级 Agent 应用,TypeScript 的优势就体现出来了:前后端同构、类型安全、异步流处理天然顺手、部署到边缘运行时方便。我自己的选择逻辑很简单——实验阶段用 Python 快速验证想法,产品化阶段用 TypeScript 重写核心管道。

TypeScript 的类型系统在管道开发里是实打实的生产力。一个 chunk 对象从摄入到检索到组装,字段会不断变化,用 interface 把每一段的输入输出定义清楚,编译期就能挡掉大量低级错误。而且现在主流的向量库和模型服务都提供了 TypeScript SDK,生态已经不是短板了。

提示:如果你在 TypeScript 项目里看到baseUrl或moduleResolution=node10的弃用警告,别慌,这是编译器选项在向新版本迁移。按提示改成bundler或node16即可,不影响管道逻辑。

2.3 方案选型的三个核心权衡

搭管道之前,有三个权衡必须先想清楚,否则后面会反复返工。

第一个权衡:切分粒度 vs 检索精度。chunk 越小,向量越聚焦,检索越精准,但单个 chunk 携带的上下文越少,模型可能因为信息不全而答偏。chunk 越大,上下文越完整,但向量被稀释,检索容易召回一堆"沾边但不相关"的内容。我的经验值是中文文档 300 到 500 字一个 chunk,英文 200 到 400 词,再配合 10% 到 20% 的重叠。这个数字不是拍脑袋来的,是因为主流嵌入模型的上下文窗口和语义压缩能力在这个区间表现最均衡。

第二个权衡:召回数量 vs 上下文预算。top-k 设太小,可能漏掉关键信息;设太大,噪声涌入,还会挤占模型的上下文窗口,增加成本和延迟。一般先用 k=10 做召回,再用重排模型压到 3 到 5 个送进生成。这样既保证了召回率,又控制了噪声。

第三个权衡:实时性 vs 成本。每次提问都重新向量化并全库检索,延迟和费用都不低。常见做法是缓存高频问题的检索结果,或者对文档做增量索引,只更新变化的部分。这个权衡没有标准答案,取决于你的业务对时效的要求。

3. 核心细节解析与实操要点

3.1 文档切分:管道里最容易被低估的一环

切分这件事,看起来就是按字数切,实际上决定了整个管道的天花板。我见过一个项目,文档按固定 1000 字硬切,结果一个完整的操作步骤被切成两半,用户问"第三步怎么做",检索到的 chunk 只有前半段,模型只能瞎猜。后来改成按语义边界切,效果立刻不一样。

切分策略大致分三档。最粗的是固定长度切分,按字符数或 token 数硬切,实现简单但容易切断语义。中间档是递归字符切分,按段落、句子、标点的优先级依次尝试,尽量在自然边界断开。最精细的是语义切分,用嵌入模型判断相邻句子的语义相似度,相似度骤降的地方就是切分点。语义切分效果最好,但计算成本高,适合文档量不大且质量要求高的场景。

实操上我推荐一个折中方案:先用递归字符切分做基础切分,再对每个 chunk 做一次"边界修正"——如果 chunk 结尾是逗号或连接词,就把它和下一个 chunk 合并。这个修正逻辑用 TypeScript 写起来很清爽:

interface Chunk { id: string; content: string; metadata: { source: string; startIndex: number; endIndex: number; }; } function splitByRecursive(text: string, maxLen: number, overlap: number): Chunk[] { const separators = ['\n\n', '\n', '。', '!', '?', '.', '!', '?', ';', ';']; const chunks: Chunk[] = []; let start = 0; while (start < text.length) { let end = Math.min(start + maxLen, text.length); if (end < text.length) { for (const sep of separators) { const lastSep = text.lastIndexOf(sep, end); if (lastSep > start + maxLen * 0.5) { end = lastSep + sep.length; break; } } } chunks.push({ id: `chunk-${chunks.length}`, content: text.slice(start, end).trim(), metadata: { source: '', startIndex: start, endIndex: end }, }); start = end - overlap; if (start <= chunks[chunks.length - 1].metadata.startIndex) { start = end; } } return chunks; }

这段代码的关键在lastIndexOf那一行:它在 chunk 末尾附近找一个自然分隔符,如果找到的位置超过 chunk 长度的一半,就在那里断开。这样既保证了 chunk 不会太短,又尽量在语义边界切分。overlap 参数控制重叠,防止关键信息正好落在切缝上被丢掉。

注意:切分时一定要保留 metadata,尤其是来源和位置。后面做引用标注、溯源、增量更新都靠它。我早期偷懒没存,后来想加"答案出处"功能时只能全量重建索引,血的教训。

3.2 向量化:嵌入模型怎么选、怎么用

嵌入模型是把文本变成向量的"翻译官"。它的质量直接决定了语义检索准不准。选型时看三个指标:维度、上下文窗口、多语言能力。维度越高表达能力越强,但存储和计算成本也越高;上下文窗口决定了单个 chunk 能有多长;多语言能力决定了中英文混合文档能不能处理好。

我的选型原则是:中文为主的场景优先选对中文优化过的模型,中英混合场景选多语言模型,纯英文场景选择面就宽很多。维度方面,768 到 1024 维是当前性价比最高的区间,再高收益递减明显。

向量化时有个容易忽略的点:查询和文档必须用同一个模型。我见过有人文档用 A 模型嵌入,查询用 B 模型,结果检索出来的东西驴唇不对马嘴。因为不同模型的向量空间是不兼容的,就像用中文词典去查英文单词,根本对不上。

批量向量化时要注意速率限制。大多数模型服务对每分钟请求数有限制,一次性把几千个 chunk 全发出去会被限流。正确做法是分批发送,每批几十到几百个,配合重试和退避。TypeScript 里可以用Promise.all配合分批:

async function embedBatch(texts: string[], batchSize = 64): Promise<number[][]> { const results: number[][] = []; for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); let retries = 3; while (retries > 0) { try { const vectors = await embedModel.embed(batch); results.push(...vectors); break; } catch (err) { retries--; if (retries === 0) throw err; await new Promise((r) => setTimeout(r, 1000 * (4 - retries))); } } } return results; }

退避时间用1000 * (4 - retries)实现指数增长,第一次失败等 1 秒,第二次等 2 秒,第三次等 3 秒。这个简单的重试逻辑能挡掉绝大多数偶发的限流错误。

3.3 索引结构:不只是"存进去"那么简单

向量存进数据库,不等于就能高效检索。索引结构决定了检索是"扫全表"还是"走索引"。常见的索引类型有扁平索引、倒排文件索引、分层可导航小世界图等。扁平索引召回率最高但速度慢,适合小数据集;分层图索引速度快、召回率也不错,是大多数场景的默认选择。

选索引类型时,核心权衡是召回率、速度、内存三者不可兼得。分层图索引通过构建多层图结构,在检索时从粗到细逐层逼近,用少量召回率损失换来了数量级的速度提升。对于十万级以下的 chunk,扁平索引其实也够用;上了百万级,就必须用近似索引了。

还有一个常被忽略的点:元数据过滤。真实业务里,用户的问题往往带着隐含的过滤条件,比如"今年的政策""技术部门的流程"。如果只做纯向量检索,很可能召回往年的、其他部门的文档。正确做法是把元数据(时间、部门、文档类型)和向量一起存,检索时先按元数据过滤,再在子集里做向量搜索。这个"先过滤后检索"的顺序很关键,反过来会先召回一堆无关内容再过滤,浪费算力还降低精度。

4. 实操过程与核心环节实现

4.1 从零搭建一条最小可用管道

理论讲完,动手搭一条能跑的管道。我用 TypeScript 写一个最小实现,包含摄入、切分、向量化、检索、生成五个步骤。为了聚焦管道逻辑,这里用内存数组模拟向量库,实际项目替换成真正的向量数据库即可。

先定义核心类型:

interface Document { id: string; content: string; metadata: Record<string, string | number>; } interface VectorRecord { id: string; vector: number[]; content: string; metadata: Record<string, string | number>; } interface RetrievalResult { content: string; score: number; metadata: Record<string, string | number>; }

摄入阶段把原始文档读进来,切分成 chunk,逐个向量化后存入记录数组:

async function ingest(docs: Document[]): Promise<VectorRecord[]> { const records: VectorRecord[] = []; for (const doc of docs) { const chunks = splitByRecursive(doc.content, 400, 60); const texts = chunks.map((c) => c.content); const vectors = await embedBatch(texts); chunks.forEach((chunk, i) => { records.push({ id: `${doc.id}-${chunk.id}`, vector: vectors[i], content: chunk.content, metadata: { ...doc.metadata, source: doc.id }, }); }); } return records; }

检索阶段计算查询向量和每个记录的余弦相似度,排序取 top-k:

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)); } async function retrieve( query: string, records: VectorRecord[], topK = 10 ): Promise<RetrievalResult[]> { const queryVec = (await embedBatch([query]))[0]; return records .map((r) => ({ content: r.content, score: cosineSimilarity(queryVec, r.vector), metadata: r.metadata, })) .sort((a, b) => b.score - a.score) .slice(0, topK); }

生成阶段把检索结果拼进 prompt:

function buildPrompt(query: string, results: RetrievalResult[]): string { const context = results .map((r, i) => `[片段${i + 1}] ${r.content}`) .join('\n\n'); return `基于以下资料回答问题。如果资料中没有相关信息,请明确说明不知道,不要编造。 资料: ${context} 问题:${query} 回答:`; }

这条管道跑通之后,你就有了一个能基于私有知识回答问题的 Agent 雏形。但"能跑"和"好用"之间隔着大量调优,下面讲的就是这些调优细节。

4.2 参数计算:top-k 和 chunk 大小怎么定

这两个参数是管道里最常被拍脑袋决定的,其实有章可循。

chunk 大小的计算逻辑是这样的:假设你的嵌入模型上下文窗口是 512 token,那么 chunk 的 token 数不能超过这个值,否则会被截断。中文里 1 个 token 大约对应 1.5 到 2 个汉字,所以 512 token 大约是 768 到 1024 个汉字。但这是上限,不是最优值。考虑到检索精度,实际 chunk 应该远小于上限,让每个 chunk 聚焦一个完整的小主题。我的经验公式是:chunk 大小 = 嵌入模型窗口 × 0.4 到 0.6。按 512 窗口算,就是 200 到 300 token,约 300 到 500 汉字。

top-k的计算要看上下文预算。假设生成模型的上下文窗口是 8k token,系统 prompt 和用户问题占 1k,留给检索内容的预算是 7k。如果每个 chunk 平均 300 token,理论上能放 23 个。但放太多噪声大,所以实际召回 k 可以设大一点(比如 20),重排后只取前 3 到 5 个送进生成。这样召回阶段保证不漏,生成阶段保证不噪。

提示:这些数字是起点不是终点。上线后一定要用真实问题做评测,看召回率和答案准确率,再反过来调参数。我一般会准备 50 到 100 条标注好的问答对做回归测试。

4.3 重排:让检索精度再上一个台阶

纯向量检索的短板在关键词精确匹配。比如用户问"错误码 E5021 怎么解决",向量检索可能召回一堆讲"错误处理"的通用文档,却漏掉真正提到 E5021 的那一篇。重排模型就是来解决这个问题的。

重排的思路是:先用向量检索快速召回一个较大的候选集(比如 20 到 50 个),再用一个更精细的交叉编码器模型,把查询和每个候选逐一配对打分,重新排序。交叉编码器比双编码器(就是嵌入模型)精度高,因为它能同时看到查询和文档,做细粒度的语义交互。代价是慢,所以只能用在候选集上,不能全库跑。

TypeScript 里调用重排服务通常是这样的:

async function rerank( query: string, candidates: RetrievalResult[], topN = 5 ): Promise<RetrievalResult[]> { const pairs = candidates.map((c) => ({ query, document: c.content })); const scores = await rerankModel.score(pairs); return candidates .map((c, i) => ({ ...c, score: scores[i] })) .sort((a, b) => b.score - a.score) .slice(0, topN); }

加了重排之后,我实测召回精度能提升 15% 到 30%,尤其是那种查询里带专有名词、编号、代码的场景,提升非常明显。这一步的成本增加有限,但收益很大,强烈建议加上。

4.4 上下文组装:顺序和格式也有讲究

检索到的 chunk 怎么拼进 prompt,不是随便堆一起就行。有几个细节值得注意。

顺序方面,有个反直觉的发现:把最相关的 chunk 放在开头和结尾,中间放次相关的,模型的表现比单纯按相关度降序排列更好。这被称为"中间遗忘"现象——模型对上下文中间部分的信息注意力较弱。所以组装时可以把 top-1 放最前,top-2 放最后,其余按序放中间。

格式方面,给每个 chunk 加上编号和来源标记,不仅方便模型引用,也方便你调试。我习惯用[片段N | 来源: xxx]的格式。如果业务需要答案带出处,可以在 prompt 里明确要求模型标注引用了哪个片段。

截断方面,如果拼起来超过上下文预算,优先保留高分 chunk,从低分的开始丢。但要注意别把同一个文档的多个 chunk 丢得只剩一个,那样可能丢失上下文连贯性。

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

5.1 检索召回不准的排查路径

召回不准是最常见的问题,表现是"答案明明在文档里,但模型说不知道"。排查要按管道顺序逐段查。

先查切分。把召回结果和原始文档对照,看关键信息是不是被切断了。如果关键句正好落在两个 chunk 的交界处,两边都不完整,那就是切分问题。解决办法是加大 overlap,或者改用语义切分。

再查向量化。确认查询和文档用的是同一个模型,确认没有超过模型的上下文窗口被截断。可以手动算一下查询向量和已知相关文档的相似度,如果相似度很低,说明嵌入模型不适合你的领域,考虑换模型或做微调。

最后查检索参数。top-k 是不是太小,元数据过滤是不是把相关文档过滤掉了,相似度阈值是不是设太高。我一般会先把 top-k 调到 50,看相关文档在不在候选集里。在,就是排序问题;不在,就是召回问题。

5.2 模型"幻觉"的抑制手段

即使检索到了正确内容,模型有时还是会编造。抑制幻觉有几个层次的手段。

最直接的是prompt 约束:明确告诉模型"只基于给定资料回答,资料没有就说不知道"。这句话看似简单,但能挡掉相当一部分幻觉。再进一步,可以要求模型在答案里标注每个结论对应的片段编号,这样你能快速核查。

更根本的是提高检索质量。幻觉往往是因为检索到的内容不够相关,模型只能靠自己的先验知识补全。检索准了,模型有据可依,幻觉自然减少。

还有一个技巧是降低生成温度。温度参数控制输出的随机性,温度越低越保守。知识问答场景建议温度设在 0 到 0.3 之间,让模型尽量复述资料而不是自由发挥。

5.3 常见问题速查表

问题现象可能原因排查方向解决手段
答案说不知道但文档里有切分切断关键信息对照召回 chunk 和原文加大 overlap 或语义切分
召回内容沾边但不相关chunk 太大向量被稀释检查 chunk 平均长度缩小 chunk 到 300 字左右
专有名词检索不到纯向量检索漏关键词测试关键词查询加关键词检索做混合召回
答案编造不存在的内容检索质量差或温度高检查召回相关度加 prompt 约束、降温度、加重排
检索速度慢索引类型或数据量问题看检索耗时分布换近似索引、加缓存
中英混合文档效果差嵌入模型多语言能力弱分别测中英文查询换多语言模型

5.4 几个踩过的坑

坑一:忽略文档预处理。PDF 直接解析出来的文本经常带页眉页脚、乱码、断行。这些噪声进了向量库,会污染检索结果。正确做法是摄入阶段做清洗:去页眉页脚、合并断行、统一标点。这一步花的时间,后面会加倍省回来。

坑二:全量重建索引太频繁。早期我每次更新文档都全量重建,文档一多就要跑几个小时。后来改成增量索引:只对新增和修改的文档做切分和向量化,删除的文档按 id 移除。配合文档的哈希值判断是否变化,效率提升巨大。

坑三:没有评测集。凭感觉调参是灾难。我后来固定维护一个评测集,每次改管道都跑一遍,看召回率和答案准确率的变化。没有评测集,你根本不知道改动是变好还是变坏。

坑四:把 RAG 当成万能药。有些问题根本不适合 RAG,比如需要多步推理的、需要实时计算的、需要跨文档聚合的。这些场景要么用工具调用,要么用更复杂的 Agentic RAG。硬套基础 RAG 只会得到似是而非的答案。

6. 从基础 RAG 到 Agentic RAG 的演进方向

基础管道跑通之后,你会发现它有几个天然局限:检索是一次性的,不管问题多复杂都只查一轮;检索策略是固定的,不会根据问题类型调整;不会主动追问或澄清。这些局限催生了Agentic RAG——让 Agent 自己决定什么时候检索、检索什么、检索几轮。

演进的第一步是查询改写。用户的问题往往口语化、有指代,直接拿去检索效果差。可以让模型先把问题改写成更适合检索的形式,比如把"它怎么用"补全成"XX 功能怎么使用"。这一步用一次模型调用就能显著提升召回率。

第二步是多轮检索。复杂问题需要拆解成子问题,逐个检索再综合。比如"对比 A 和 B 两个方案的优缺点",可以先分别检索 A 和 B 的资料,再让模型对比。这需要 Agent 具备任务分解和结果聚合的能力。

第三步是自适应检索。Agent 根据问题类型决定要不要检索、检索哪个知识库。简单常识问题直接答,专业问题才走检索。这样既省成本又提体验。

再往上就是GraphRAG和本体 RAG这类结构化增强方案,把知识图谱和向量检索结合,处理需要关系推理的复杂查询。这些是进阶话题,但基础管道是它们共同的地基。地基打好了,往上加什么都顺手。

我个人在实际项目里的体会是:不要一上来就追求最复杂的方案。先把基础管道跑通、评测集建好、参数调到位,你会发现 80% 的场景基础 RAG 就够了。剩下 20% 的复杂场景,再针对性地引入 Agentic 能力。很多团队一上来就搞多路召回、图谱增强,结果基础切分都没做好,投入大量精力却收效甚微。管道的每一段都值得认真对待,而知识获取这一段,恰恰是最容易被低估、也最能拉开差距的地方。

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

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

立即咨询