1. 为什么知识获取管道是 AI Agent 的分水岭
做 AI Agent 开发到第三篇之后,基本都会撞上同一堵墙:模型本身很聪明,但你问它公司内部的报销标准、上周刚更新的接口文档、某个客户的特殊约定,它要么一本正经地胡说,要么干脆说不知道。这不是模型不行,而是它的知识被冻结在训练截止那一天,而且它压根没见过你的私有数据。知识获取管道要解决的,就是把这个断层补上——让 Agent 在回答问题之前,先去"查资料",再"开口说话"。这套机制里最主流、最成熟的一条路线,就是RAG(Retrieval-Augmented Generation,检索增强生成)。
我先把话说在前面:RAG 不是某个库、某个框架,而是一种架构模式。它的核心思想朴素到有点土——先检索,再生成。用户问一个问题,系统先从知识库里捞出最相关的若干段文本,把这些文本塞进提示词,再让大模型基于这些"证据"来回答。这样一来,模型不需要记住你的知识,它只需要会"读"你递给它的材料。这个转变非常关键,它把"知识更新"从"重新训练模型"这种昂贵操作,降级成了"往数据库里加一条记录"这种日常操作。
这篇是"走进 AI Agent"系列的第四篇,主题锁定在知识获取管道的基础层。我会用TypeScript作为主要实现语言来串讲,因为现在大量 Agent 应用跑在 Node 侧,前端工程师转过来做 Agent 开发,TypeScript 几乎是零门槛的入口。整篇内容适合三类人:一是刚接触 RAG、想知道它到底怎么跑起来的新手;二是已经用过 LangChain 之类框架、但对其内部机制一知半解的开发者;三是准备把 RAG 落到真实业务里、需要知道坑在哪里的工程负责人。我不会只给你一段能跑的 demo,而是把每个环节"为什么这么设计"讲透,让你在换框架、换模型、换数据库的时候心里有底。
需要提前说明的是,RAG 的完整链路其实很长,从文档解析、切分、向量化、存储、检索、重排到最终拼装提示词,每一环都有讲究。这一篇聚焦"基础",也就是把主干链路打通,把最核心的几个概念讲清楚。更进阶的 Agentic RAG、GraphRAG、多路召回融合这些,我会在后续篇章里单独展开。先把地基打牢,比一上来就堆花活重要得多。
2. RAG 的整体设计与核心思路拆解
2.1 一句话讲清 RAG 到底在干什么
打个比方。你请了一位博学但记性只停留在去年的顾问,他现在要回答你公司今年的新政策。你不会让他去重新读一遍公司所有文件(那相当于重新训练),而是会在他回答前,先让助理把相关的几页文件找出来,放到他面前,说"你就照着这几页回答"。RAG 里的"检索器"就是那个助理,"生成器"就是那位顾问。
用技术语言描述,RAG 把一次问答拆成两个阶段。离线阶段(索引):把你的知识文档切块、转成向量、存进向量数据库,建好索引。在线阶段(查询):用户提问时,把问题也转成向量,去数据库里找最相似的若干块,把这些块和问题一起交给大模型生成答案。离线阶段是一次性的、可以慢慢跑;在线阶段要求快,通常要在几百毫秒到几秒内完成。
这个拆分带来一个巨大的好处:知识更新和模型解耦。文档变了,你只需要重新索引那部分文档,模型一行都不用动。反过来,模型升级了,你的知识库也完全不受影响。这种解耦是 RAG 能在大规模生产环境里活下来的根本原因。
2.2 为什么是"检索增强"而不是"微调"
很多人第一反应是:我直接拿私有数据微调一个模型不就行了?能行,但代价和局限都很明显。微调是把知识"焊死"进模型权重里,更新一次就要重训一次,成本高、周期长;而且微调擅长的是让模型学会某种"风格"或"格式",对于精确的事实召回,它并不比检索可靠——模型可能记住了大概,但记错了细节,你还很难发现。RAG 则把事实来源外置成可审计的文档,答案错了你能顺着引用去查是哪块资料的问题。
从工程角度看,RAG 的迭代速度是微调没法比的。改一个切分策略、换一个 embedding 模型、调一下 top-k,改完立刻能测效果。微调你要等训练、等评估,一轮下来半天没了。所以在绝大多数"让 Agent 掌握私有知识"的场景里,RAG 是默认首选,微调是补充手段,两者不冲突——常见做法是 RAG 负责事实,微调负责语气和格式。
2.3 主干链路的五个关键环节
把 RAG 拆开,主干上有五个绕不开的环节,我用一张表先给你一个全局印象,后面逐个展开。
| 环节 | 输入 | 输出 | 核心目标 | 常见坑 |
|---|---|---|---|---|
| 文档加载 | 原始文件(PDF/MD/HTML) | 纯文本 | 把各种格式统一成文本 | 表格、公式丢失 |
| 文本切分 | 长文本 | 文本块(chunk) | 块大小适中、语义完整 | 切断句子、块过大 |
| 向量化 | 文本块 | 向量 | 语义可比较 | 模型选错、维度不匹配 |
| 存储与检索 | 向量+原文 | Top-K 相关块 | 快速找到相关内容 | 相似度阈值难定 |
| 生成 | 问题+相关块 | 答案 | 基于证据回答 | 提示词没约束好 |
这五步里,切分和检索是最容易被低估、也最影响效果的两环。新手往往把注意力全放在"用哪个大模型"上,结果发现换了最强的模型,答案还是不准——问题根本不在生成端,而在检索端压根没捞到正确的资料。记住一句话:RAG 的效果上限由检索决定,生成只是把检索到的东西说清楚。
2.4 TypeScript 技术栈的选型考量
为什么用 TypeScript 来讲?因为 Agent 应用越来越往"应用层"走,而应用层大量是 Web 技术栈。用 TypeScript 做 RAG,好处是前后端同构、类型安全、生态成熟。核心依赖大致是这几类:文档解析用pdf-parse、mammoth之类;切分和编排可以自己写,也可以用langchain的 JS 版本;向量化调用各家 embedding API;向量存储可以用内存版(开发)或专业向量库(生产)。
选型上我的建议是:开发阶段尽量少依赖框架,把链路自己手写一遍。原因很实在——RAG 的坑大多藏在细节里,框架帮你封装了细节,也帮你藏起了问题。你自己手写一遍切分、手写一遍余弦相似度,再去看框架的源码,理解会完全不一样。等链路跑通了,再决定要不要上框架提效。这一篇的代码示例会偏向"手写为主、框架为辅"的风格。
3. 核心细节解析与实操要点
3.1 文档加载:别小看"把文件读成文本"这一步
文档加载听起来最没技术含量,实际上是最脏最累的一环。你的知识可能散落在 PDF、Word、Markdown、网页、数据库里,每种格式的解析质量天差地别。PDF 尤其麻烦,扫描件需要 OCR,带表格的 PDF 解析出来经常错位,双栏排版的 PDF 可能把左右两栏的文字交错读出来,读出来就是一堆乱码般的句子。
实操上我的经验是:能拿到结构化源文件,就绝不用 PDF。如果知识源头是 Confluence、飞书文档、Markdown 仓库,优先走它们的 API 或原始格式,PDF 只作为最后兜底。如果非解析 PDF 不可,选一个靠谱的解析库,并且一定要人工抽查解析结果——随机抽十页,看看文字顺序对不对、表格有没有散架。这一步偷懒,后面全盘皆输,因为垃圾进必然垃圾出。
在 TypeScript 里,一个最小化的加载函数大概长这样:
import fs from "fs/promises"; import pdfParse from "pdf-parse"; async function loadDocument(filePath: string): Promise<string> { const ext = filePath.split(".").pop()?.toLowerCase(); if (ext === "pdf") { const buffer = await fs.readFile(filePath); const result = await pdfParse(buffer); return result.text; } // md / txt 直接读 return await fs.readFile(filePath, "utf-8"); }这段代码能跑,但你要清楚它的局限:pdf-parse对复杂版式无能为力。生产环境里,我通常会加一层"清洗"——把连续多个空行压成一个、去掉页眉页脚的重复文本、修掉断行导致的错误换行。这些清洗规则要根据你的文档特点定制,没有通用解。
3.2 文本切分:RAG 里最需要"手感"的一步
切分是 RAG 的灵魂。为什么不能把整篇文档直接丢进去检索?因为 embedding 模型对长文本的表达能力会衰减,一整篇几千字的文档压成一个向量,语义会被"平均"掉,检索时既不够精确,又浪费上下文窗口。所以要切成块。但切多大、怎么切,全是学问。
块太小,一句话被切成两半,检索到的块缺上下文,模型看不懂。块太大,一个块里混了好几个主题,向量表达模糊,检索精度下降,还挤占上下文。业界常见的起点是500 到 1000 个字符一块,块之间留10% 到 20% 的重叠(overlap)。重叠的作用是防止关键信息正好卡在切分边界上被切断——上一块的结尾和下一块的开头有重复内容,保证语义连续。
切分策略上,最朴素的是按固定长度硬切,但会切断句子。好一点的是按语义边界切:优先在段落、句子、标点处断开。再进一步是递归切分——先按大分隔符(如双换行)切,块还是太大就按单换行切,再大就按句号切,层层降级,尽量保持语义完整。下面是一个递归切分的简化实现:
function splitText( text: string, chunkSize = 800, overlap = 120 ): string[] { const separators = ["\n\n", "\n", "。", "!", "?", ". ", " "]; const chunks: string[] = []; function recursiveSplit(segment: string, sepIndex: number): void { if (segment.length <= chunkSize || sepIndex >= separators.length) { chunks.push(segment); 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) { chunks.push(buffer); // 保留 overlap:从上一块尾部截取 buffer = buffer.slice(-overlap) + sep + part; } else { buffer = candidate; } } if (buffer) chunks.push(buffer); } recursiveSplit(text, 0); return chunks; }这段代码不是最优解,但足够让你理解递归切分的思路。实际项目里,我强烈建议针对不同文档类型用不同策略:技术文档按标题层级切(每个小节一块),FAQ 按问答对切,代码按函数切。一刀切的切分策略,效果一定平庸。
注意:overlap 不是越大越好。重叠太多会导致检索时同一内容反复命中,浪费上下文还引入冗余。10% 到 20% 是个稳妥区间,具体要拿你的数据实测。
3.3 向量化:把语义变成可以算距离的数字
切完块,下一步是把每个块转成向量。向量就是一串浮点数,比如 1536 维的数组。它的神奇之处在于:语义相近的文本,向量在空间里的距离也近。"如何申请报销"和"报销流程是什么"字面不同,但向量会很接近;而"如何申请报销"和"今天天气不错"向量就离得远。检索的本质,就是在向量空间里找离问题向量最近的几个块。
向量化靠 embedding 模型完成。选型上有几个硬指标要看:维度(越高表达力越强但存储和计算越贵)、最大输入长度(决定单块能多长)、语言支持(中文场景要选中文表现好的)、成本(按 token 计费,量大时很敏感)。这里有个铁律:索引时用的 embedding 模型,和查询时必须用同一个。用 A 模型索引、用 B 模型查询,向量空间对不上,检索结果就是随机的。这个错误新手极容易犯,换模型时忘了重建索引。
调用 embedding 的代码大致是这样:
async function embed(texts: string[]): Promise<number[][]> { const res = await fetch("https://your-embedding-endpoint/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.EMBED_API_KEY}`, }, body: JSON.stringify({ model: "your-embedding-model", input: texts, }), }); const data = await res.json(); return data.data.map((d: any) => d.embedding); }批量调用时注意两点:一是控制单次请求的文本数量,太多会被限流或超时;二是做好失败重试,网络抖动很常见,索引阶段丢了几条数据,检索时就永远查不到。我一般会加一个带指数退避的重试封装,并且把每条文本的索引状态落库,方便断点续传。
3.4 存储与检索:相似度怎么算、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)); }开发阶段,几百上千个块用内存数组暴力算完全够用。生产环境块数上万甚至百万,就要上专业向量库,它们用近似最近邻(ANN)算法把检索从线性复杂度降到对数级。选向量库时关注:是否支持元数据过滤(比如只搜某个部门的文档)、是否支持混合检索(向量+关键词)、运维成本如何。
Top-K 怎么定是个高频问题。K 太小,可能漏掉正确资料;K 太大,塞进一堆无关内容干扰模型,还费 token。常见起点是K=3 到 5。但更聪明的做法是加相似度阈值:低于某个分数的块直接丢掉,哪怕它排在前 K。因为有时候知识库里压根没有相关内容,硬塞进去只会让模型编。我通常会把阈值和 K 一起调,观察"检索命中率"这个指标——正确资料出现在 Top-K 里的比例。
提示:检索效果差,先别急着换模型。八成问题出在切分和 embedding 上。把检索到的块打印出来人工看一眼,比调任何参数都管用。
4. 实操过程与核心环节实现
4.1 从零搭一个最小可用的 RAG 管道
前面拆了零件,这一节把它们组装成一条能跑的完整链路。我用 TypeScript 写一个内存版的 RAG,不依赖任何向量库,方便你本地跑通、理解全貌。整个流程分索引和查询两段。
先定义数据结构。每个块除了向量,还要保留原文和来源信息,方便后面做引用:
interface Chunk { id: string; text: string; source: string; embedding: number[]; }索引阶段:加载文档、切分、向量化、存进内存数组。
const knowledgeBase: Chunk[] = []; async function indexDocument(filePath: string): Promise<void> { const raw = await loadDocument(filePath); const chunks = splitText(raw, 800, 120); const embeddings = await embed(chunks); chunks.forEach((text, i) => { knowledgeBase.push({ id: `${filePath}-${i}`, text, source: filePath, embedding: embeddings[i], }); }); }查询阶段:把问题向量化,和所有块算相似度,取 Top-K,拼提示词,交给大模型。
async function query(question: string, topK = 4): Promise<string> { const [qVec] = await embed([question]); const scored = knowledgeBase.map((chunk) => ({ chunk, score: cosineSimilarity(qVec, chunk.embedding), })); scored.sort((a, b) => b.score - a.score); const top = scored.slice(0, topK).filter((s) => s.score > 0.3); const context = top .map((s, i) => `[资料${i + 1}] ${s.chunk.text}`) .join("\n\n"); const prompt = `你是一个严谨的助手。请仅根据下面提供的资料回答问题。 如果资料中没有相关信息,请直接说"资料中未提及",不要编造。 资料: ${context} 问题:${question} 回答:`; return await callLLM(prompt); }这段代码不到一百行,但 RAG 的骨架全在里面了。你可以拿一份自己的 Markdown 文档跑一遍,问几个问题,感受一下效果。跑通之后,再逐个环节替换成更专业的实现——切分换成递归切分、内存数组换成向量库、加一层重排,效果会一步步提升。
4.2 提示词里的约束:让模型"只基于资料回答"
RAG 效果好不好,提示词占很大比重。上面那段 prompt 里有几个关键设计,值得单独说。第一,明确角色和任务——"严谨的助手",告诉模型它的定位。第二,强制基于资料——"仅根据下面提供的资料",切断模型自由发挥的冲动。第三,给出兜底话术——"资料中未提及",让模型有台阶下,而不是硬编。第四,给资料编号——方便模型引用,也方便你事后核对。
我踩过的一个坑是:早期提示词写得太宽松,模型经常把资料和自己的先验知识混在一起,答案看着对,其实掺了训练数据里的旧信息。后来加了"仅根据资料"和"未提及就说未提及"这两条,幻觉率明显下降。另一个技巧是要求模型标注引用来源,比如"回答时请注明依据的是哪条资料",这样答案可追溯,用户信任度也高。
注意:提示词里的资料顺序会影响模型注意力。把最相关的资料放在最前面或最后面,效果通常比放中间好。这是"中间遗忘"现象,长上下文模型普遍存在。
4.3 参数计算:块大小、Top-K 与上下文预算
RAG 里几个参数不是拍脑袋定的,背后有预算约束。假设你的大模型上下文窗口是 8K token,你要留出空间给系统提示、用户问题、模型回答。粗略分配:系统提示 200 token,问题 100 token,回答预留 1000 token,那么留给资料的只有约 6700 token。中文大致 1 个 token 对应 1.5 到 2 个字符,6700 token 约等于 1 万到 1.3 万字符。
如果每块 800 字符,Top-K=4,资料总量约 3200 字符,远没到上限,说明你还有空间可以增大 K 或增大块。反过来,如果 K=10、每块 1500 字符,就是 1.5 万字符,超预算了,模型会截断,反而丢信息。所以块大小和 Top-K 要一起算,让"块大小 × K"落在上下文预算内,并留出安全余量。这个计算过程很多人跳过,结果要么浪费窗口,要么被静默截断还找不到原因。
4.4 检索质量的自测方法
链路跑通后,怎么知道检索好不好?最土也最有效的办法是建一个小型评测集:准备 20 到 50 个真实问题,每个问题人工标注"正确答案应该来自哪几个块"。然后跑检索,看正确块有没有出现在 Top-K 里,算出命中率。命中率低,说明切分或 embedding 有问题;命中率高但最终答案差,说明问题在生成端或提示词。
这个评测集不用大,几十条就能暴露大部分问题。我习惯把它做成一个 JSON 文件,每次改完切分策略或换 embedding 模型,跑一遍看命中率变化。没有这个基线,你调参数就是盲人摸象,改了半天不知道是变好还是变坏。这是我从"凭感觉调"转向"数据驱动调"的关键一步,强烈建议你也建一个。
5. 常见问题与排查技巧实录
5.1 检索不到正确内容,问题出在哪
这是最高频的问题。排查顺序我总结成一张表,从上往下查,基本能定位到根因。
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 正确块完全不在 Top-K | 切分把关键信息切碎 | 打印所有块,找目标内容 | 调整切分策略 |
| 正确块排名靠后 | embedding 模型不适合 | 换模型对比命中率 | 换中文强的模型 |
| 检索结果全是无关内容 | 索引和查询模型不一致 | 核对两侧模型名 | 统一模型并重建索引 |
| 相似度普遍偏低 | 阈值设太高 | 看分数分布 | 下调阈值或去掉 |
| 同一内容反复命中 | overlap 过大 | 看重复块比例 | 减小 overlap |
我遇到最多的是第一种:关键信息被切分切碎了。比如一个操作步骤跨了两块,检索只命中前半块,模型看到的是残缺信息。解决办法是增大 overlap,或者改用按语义边界切分。第二种也很常见,用了英文为主的 embedding 模型处理中文,效果自然差,换成中文优化的模型立刻改善。
5.2 答案里有幻觉,明明资料里没有
模型编造内容,通常有三个来源。一是检索没命中,但提示词没约束好,模型就自由发挥了。二是资料里有相似但不完全对应的内容,模型做了"合理推断",推错了。三是问题本身有歧义,模型理解偏了。针对第一种,加"未提及就说未提及"的兜底话术;针对第二种,要求模型严格引用原文,不要推断;针对第三种,可以在检索前先做一次"问题改写",把模糊问题澄清成明确查询。
还有一个隐蔽的坑:资料块之间互相矛盾。知识库里有新旧两版文档,检索同时命中,模型不知道该信哪个。这种情况要在索引阶段做版本管理,旧版本标记为失效,检索时过滤掉。知识库的"卫生"和代码库一样重要,脏数据不清理,再好的模型也救不了。
5.3 性能与成本:什么时候该优化
开发阶段别过早优化。内存暴力检索、同步调用 embedding,几百个块完全够用。等块数上万、查询并发上来,再考虑优化。优化方向有几个:向量检索上 ANN 索引,把 O(n) 降到 O(log n);embedding 结果缓存,相同文本不重复调用;批量索引,减少网络往返;异步并发,索引阶段可以并发处理多个文档。
成本上,embedding 调用和 LLM 调用是两大头。embedding 是一次性成本(除非频繁更新),LLM 是每次查询都产生。控制 LLM 成本的关键是别塞太多资料——Top-K 不是越大越好,塞进去的无关内容既费钱又干扰模型。我见过有人 K 设成 20,结果一半是噪音,成本翻几倍效果还更差。精而准,永远比多而杂强。
5.4 几个我踩过的坑,直接告诉你
第一个坑:换 embedding 模型忘了重建索引。我换了个新模型,查询效果突然变得极差,查了半天才发现索引还是旧模型生成的,向量空间对不上。记住,embedding 模型和索引是绑定的,换模型必须全量重建。
第二个坑:切分时把代码块切断了。技术文档里有代码示例,按固定长度切会把一个函数切成两半,检索到的代码根本没法看。后来我加了规则:遇到代码块标记就整体保留,不参与切分。
第三个坑:忽略了元数据。一开始我只存了文本和向量,后来想按文档类型过滤、想显示来源,发现没存这些信息,只能重建索引。所以索引时一定要把 source、类型、时间、权限等元数据一起存进去,后面做过滤和引用都用得上。
第四个坑:中文标点没处理好。英文按空格切词,中文没有空格,按字符切容易在词中间断开。中文场景要按标点(。!?;)切,或者用专门的中文分词。这个细节不注意,切出来的块读起来很别扭。
提示:RAG 调优是个迭代过程,别指望一次到位。建好评测集,每次只改一个变量,观察指标变化,慢慢就摸到你这套数据的"脾气"了。
6. 从基础 RAG 到 Agentic RAG 的演进方向
基础 RAG 跑通之后,你会发现它有几个天生的局限。第一,它只会检索一次——问题进来,检索一轮,生成,结束。但很多问题需要多步推理,比如"对比 A 和 B 两个方案的差异",得先分别查 A 和 B,再对比。第二,它不会判断该不该检索——有些问题模型自己就能答,硬去检索反而引入噪音。第三,它不会自我纠错——检索到的资料不对,它也不知道回头重查。
这些局限催生了Agentic RAG:把 RAG 从一条固定流水线,变成一个会思考、会决策、会多轮检索的 Agent。它能在检索前判断"这个问题需要查资料吗",能在检索后评估"资料够不够、对不对",不够就换个查询词再查一轮,甚至能调用多个知识源交叉验证。这已经不是"检索增强生成",而是"检索作为 Agent 的一个工具"。
从基础 RAG 往 Agentic RAG 走,技术上要补几块:查询改写(把用户问题转成更好的检索查询)、多路召回(向量检索+关键词检索+图检索融合)、重排(用更精细的模型对候选块重新排序)、自我反思(让 Agent 评估检索质量并决定是否重试)。这些每一块都够写一篇,我会在后续篇章里逐个拆。你现在要做的,是先把基础链路跑扎实,把评测集建起来,把切分和检索的手感练出来。地基不牢,上面盖什么都是空中楼阁。
我个人在实际项目里的体会是:RAG 的收益,八成来自把数据整理干净、把切分做对、把评测建起来这三件"笨功夫",剩下两成才是模型和框架的选择。很多人反过来,花大量时间比较模型和框架,却不肯花半天把文档解析和切分调好,结果一直在低水平上打转。先把笨功夫做足,你会发现后面的一切都顺了。