RAG 实战:从一篇掘金文章出发,拆解检索增强生成的全链路
你有没有遇到过这样的场景:让 AI 总结一篇长文章的核心观点,它要么回答得似是而非,要么干脆说"这篇文章超出了我的知识范围"。这不是模型不够聪明,而是它没有见过这篇文章。
大语言模型的训练数据有截止日期,且无法覆盖互联网上的每一篇文章。当你需要 AI 理解一份它从未见过的文档时,你有两个选择:
- 微调(Fine-tuning):把文章喂给模型重新训练,耗时、烧钱,且每次新增文档都要重来一遍
- RAG(Retrieval-Augmented Generation,检索增强生成):先把文章存进知识库,提问时检索相关内容,塞进提示词让 AI 现场阅读并回答
显然,RAG 是更务实的选择。下面我们通过一段不到 90 行的真实代码,一步步拆解 RAG 的完整链路。
RAG 六步流水线
整个 RAG 流程可以抽象为六个步骤:
加载文档 → 文本切分 → 向量嵌入 → 存入向量库 → 相似检索 → 增强生成
每一步都有其不可替代的作用,缺任何一环,整个链路都会断裂。下面逐一展开。
第一步:文档加载——把外部知识"搬"进来
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7233327509919547452',
{ selector: '.main-area p' }
);
const documents = await cheerioLoader.load();
这里做了两件事:抓取网页和提取正文。
CheerioWebBaseLoader 底层使用了 Cheerio——一个在 Node.js 端运行的类 jQuery 库,让你可以用 CSS 选择器像操作前端 DOM 一样解析 HTML。selector: '.main-area p' 精确锁定了文章正文区域的所有段落标签,滤掉了导航栏、侧边栏、评论区等噪音。
这是 RAG 的第一道关卡:垃圾进,垃圾出。如果加载的内容混杂了大量无关信息,后续的检索质量无从谈起。
LangChain 的 document_loaders 模块提供了数十种加载器:PDF、Markdown、Notion、GitHub、Confluence……不管知识存在哪里,都能"搬"进管道。
第二步:文本切分——把长文"切"成可检索的块
这是整个 RAG 流程中最容易被低估、却最能影响最终效果的环节。
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个文本块的最大字符数
chunkOverlap: 50, // 相邻文本块之间的重叠字符数
separators: ['。', ',', '!', '?'], // 语义分割符
});
const splitDocuments = await textSplitter.splitDocuments(documents);
这里涉及三个关键参数,每个都有它的"为什么":
chunkSize = 400
文本块不能太大,也不能太小。太大,检索精度下降,且容易超出 LLM 的上下文窗口;太小,语义碎片化,一段完整论述被拦腰截断。400 个字符约等于中文的 200 字,刚好容纳一个完整的观点段落。
在实际项目中,chunkSize 的调整往往是 RAG 性能调优的第一步:客服问答场景可能偏短(200-300),论文分析场景可能偏长(800-1000)。
chunkOverlap = 50
这是 RecursiveCharacterTextSplitter 最精妙的设计——相邻块之间不是硬截断,而是有 50 个字符的"重叠带"。
为什么要重叠?想象一句话正好横跨 Chunk 1 和 Chunk 2 的边界:"……父亲的去世让我意识到//(此处是分块边界)人生短暂,应该及时行乐。"如果不重叠,这句完整的因果逻辑就被一分为二,检索时无论命中哪个 Chunk,AI 都看不到完整语境。50 个字符的重叠如同"安全冗余",保证边界处的语义不被割裂。
separators: ['。', ',', '!', '?']
这些中文标点定义了切割的优先级。RecursiveCharacterTextSplitter 的"递归"体现在:它先尝试用句号切,切出来的块如果还超过 400 字,再用逗号切,以此类推。这样保证切割总是发生在最自然的语义边界上,而不是生硬地数到第 400 个字符就一刀下去。
为什么叫"Recursive"?
这是常见的面试考点。它先按最高优先级分隔符(句号)切分 → 检查每个块是否超过 chunkSize → 对超标的块,降级到下一个分隔符(逗号)继续切 → 递归直到所有块都合规。这种"分层降级"的策略,比简单按固定长度截断更尊重文档的语义结构。
第三步:向量嵌入——把文字翻译成机器"认识"的数字
import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
Embedding 模型的工作是:把一段文本映射成一个高维向量(如一串 1536 个浮点数)。语义相近的两段话,它们在向量空间中的距离就相近;语义无关的,距离就远。
这个过程很像给每段文字拍了一张"语义指纹"。你不需要精确匹配关键词,哪怕换了一套说法,只要意思相同,向量距离依然很近——这就是 RAG 比传统关键词搜索高明的地方。
值得注意的是,这里的 Embedding 模型和大语言模型使用了同一个兼容端点(阿里云 DashScope),但它们是两个不同的模型。Embedding 模型专做向量化,轻量、快速、便宜;Chat 模型专做对话推理。两者各司其职,不可混用——这也是面试中的高频考点。
第四步:向量存储——给"语义指纹"建一个可查询的索引
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocuments, embeddings);
MemoryVectorStore.fromDocuments() 一次性完成了两件事:
- 调用 embeddings 模型,将每个文本块转为向量
- 将所有向量存入内存向量数据库
MemoryVectorStore 顾名思义,数据存在内存中,程序关闭即消失。它适合 Demo 和学习场景。生产环境通常会换成 Pinecone(云原生)、Chroma(本地持久化)、Milvus(分布式高性能)等持久化向量数据库。
第五步:相似检索——找到最相关的"知识碎片"
const retriever = await vectorStore.asRetriever({ k: 2 });
const retrievedDocs = await retriever.invoke(question);
const scoreResults = await vectorStore.similaritySearchWithScore(question, 2);
这短短的几行代码,背后发生了什么呢?
- 问题向量化:将用户问题
"父亲的去世对作者的人生态度产生了怎样的根本性逆转?"传入同一个 Embedding 模型,得到问题向量 - 相似度计算:在向量库中计算问题向量与所有文档向量的距离(通常用余弦相似度或欧氏距离)
- Top-K 选取:返回距离最近的
k=2个文本块
代码还通过 similaritySearchWithScore 拿到了每个检索结果的相似度评分:
retrievedDocs.forEach((doc, i) => {
const scoreResult = scoreResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent
);
const score = scoreResult ? scoreResult[1] : null;
const similarity = score ? (1 - score).toFixed(2) : "N/A";
console.log(`\n文档 ${i+1} 相似度:${similarity}`);
});
这里的 score 是向量距离(越小越相似),1 - score 转换为相似度百分比(越高越相关)。这个评分在生产环境中是重要的诊断工具——如果 Top-1 文档的相似度只有 0.3,说明知识库里可能根本没有相关内容,此时强行生成反而会产生幻觉,应该触发"知识库未覆盖"的兜底逻辑。
第六步:增强生成——让 AI "开卷考试"
const content = retrievedDocs
.map((doc, i) => `[片段${i+1}]\n ${doc.pageContent}`)
.join("\n\n----\n\n");
const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${content}
问题:
${question}
回答:`;
const response = await model.invoke(prompt);
这就是 RAG 的"R"(Retrieval)与"G"(Generation)的交接点——把检索到的文档片段拼接成"开卷材料",连同用户问题一起填入提示词模板,交给 LLM 现场作答。
这个提示词模板本身就是一种提示工程:
你是一个文章辅助阅读助手:角色设定,锚定 AI 的行为边界——只基于文章回答,不编造${content}和${question}用明确的标签分隔:结构化输入,减少模型混淆回答:结尾:引导模型直接进入回答模式,减少废话
完整链路回顾
把这六步串起来,一次完整的 RAG 查询是这样的:
用户提问
│
▼
问题向量化 ──────────→ 在向量库中检索 Top-K 相似文档
│ │
│ ▼
│ 返回 K 个最相关文本块
│ │
▼ ▼
拼装提示词模板
│
▼
LLM 阅读 + 回答
│
▼
返回最终答案
整段代码不到 90 行,却完整实现了从网页抓取到智能回答的全链路。这就是 LangChain 这类框架的价值——它把每个环节抽象为标准组件,你可以像搭积木一样组合它们,快速验证想法。
深入切割机制:三大 Splitter 横向对比
前面的示例使用了 RecursiveCharacterTextSplitter,但 LangChain 的切割器家族不止这一位成员。CharacterTextSplitter、TokenTextSplitter 和 RecursiveCharacterTextSplitter 三者各有所长,选错切割器对 RAG 效果的影响不亚于选错 Embedding 模型。下面结合真实代码逐一拆解。
前置知识:Token 不等同于字符
在深入三种切割器之前,必须先理解一个基础概念——Token 不是字符。大语言模型不是逐字阅读文本,而是将文本转化为 Token(词元)序列后再处理。同一个意思,用不同语言表达,Token 数量可以天差地别:
import { getEncodingNameForModel, getEncoding } from 'js-tiktoken';
const enc = getEncoding('cl100k_base');
console.log('apple', enc.encode('apple').length); // → 1 个 Token
console.log('pineapple', enc.encode('pineapple').length); // → 2 个 Token
console.log('苹果', enc.encode('苹果').length); // → 2 个 Token
apple 是一个高频英文单词,编码器给它分配了独立的 Token ID,1 个 Token 就搞定。pineapple 虽然也是单词,但频率没那么高,被拆成了 2 个 Token。苹果 两个汉字,每个汉字各占 1 个 Token。
这个差异的意义在于:LLM 的上下文窗口是按 Token 计算的(如 GPT-4 的 128K 窗口指的是 128K Token),而你写代码时看到的是字符数。一个中文字符约等于 1.5-2 个 Token,一段 1000 字的英文可能只有 700 Token——用字符数估算 Token 数,误差可以高达 50%。三种切割器的本质差异,正是源于"按什么单位来计数"。
Tiktoken 是 OpenAI 开源的 Token 计数库,cl100k_base 是 GPT-4 / GPT-3.5-turbo 使用的编码表。js-tiktoken 是其 JavaScript 移植版。它提供的能力很简单:给定一段文本,告诉你这段文本会被模型当成多少个 Token。但这个简单的能力,是整个切割器体系的基石。
一、CharacterTextSplitter:简单粗暴的字符计数器
CharacterTextSplitter 是最基础的切割器——它只做一件事:按字符数切。用一段日志文件来演示:
import { CharacterTextSplitter } from '@langchain/textsplitters';
import { Document } from '@langchain/core/documents';
const logDocument = new Document({
pageContent: `[2024-01-15 10:00:00] INFO: Application started
[2024-01-15 10:00:05] DEBUG: Loading configuration file
[2024-01-15 10:00:10] INFO: Database connection established
...`
});
const splitter = new CharacterTextSplitter({
chunkSize: 50, // 每个块最多 50 个字符
chunkOverlap: 10, // 块之间重叠 10 个字符
separator: '\n', // 单一分隔符
});
它的切割逻辑非常直白:
if (当前累计字符数 + 下一段字符数 > chunkSize) {
切割,另起一个新块
} else {
继续往当前块里追加
}
优点:快,非常快。没有复杂的递归逻辑,不需要加载外部编码表,适合对速度要求极高的流式场景。
缺点:完全无视语义。如果 chunkSize: 50 正好卡在一句话中间,它会毫不犹豫地切断。对于中文来说伤害稍小——每个汉字本身是一个有意义的单元,切割点即使不理想,信息损失也有限。但对于英文,切断一个单词中间是灾难性的。
适用场景:中文文本、日志文件、代码等字符与语义单元基本一一对应的内容,或者对切割精度要求不高但追求速度的原型验证。
二、TokenTextSplitter:最精准的"窗口对齐器"
TokenTextSplitter 是三种切割器里唯一直接对标 LLM 上下文窗口的。它不问"这段文本有多少字符",只问"这段文本有多少 Token":
import { TokenTextSplitter } from '@langchain/textsplitters';
const logTextSplitter = new TokenTextSplitter({
chunkSize: 50, // 每个块最多 50 个 Token(不是字符!)
chunkOverlap: 10, // 块之间重叠 10 个 Token
encodingName: 'cl100k_base', // 使用 GPT-4 的编码表
});
const splitDocuments = await logTextSplitter.splitDocuments([logDocument]);
const enc = getEncoding('cl100k_base');
splitDocuments.forEach(doc => {
console.log('char length:', doc.pageContent.length); // 字符长度
console.log('token length:', enc.encode(doc.pageContent).length); // Token 长度 ≤ 50
});
注释中的 chunkSize: 50 指的是 50 个 Token,不是 50 个字符。这是最容易混淆的地方。同一个块,字符长度可能是 80,但 Token 长度严格不超过 50。
它的切割流程是:
加载 tiktoken 编码表 → 将文本 Token 化 → 按 Token 累计计数 → 达到 chunkSize 时切割
这是唯一一个切割后可以直接拿去计算"还剩多少上下文空间"的切割器。 当你需要向 GPT-4 的 128K 窗口中塞入尽可能多的检索结果时,TokenTextSplitter 能精确告诉你每个 Chunk 消耗了多少 Token 配额,不会出现"我以为只用了 3000 Token,实际用了 5000"的估算偏差。
优点:Token 级别的精确控制,与 LLM 窗口天然对齐,跨语言一致性好(中英文在 Token 维度上的表现是统一的)。
缺点:依赖外部编码表(js-tiktoken),初始化有开销;切割发生在 Token 边界而非字符边界,可能导致文本在奇怪的位置断开;不同模型的编码表不同(GPT-4 用 cl100k_base,GPT-2 用 gpt2),需要根据实际使用的模型配置。
适用场景:需要严格控制 Token 预算的生产环境、多语言混合文档、与 OpenAI 模型对接的 RAG 管道。
三、RecursiveCharacterTextSplitter:最"聪明"的语义感知器
RecursiveCharacterTextSplitter 是三种切割器里策略最复杂的。它用字符数作为上限,但在切割时有一套"分层降级"的分隔符策略:
const logSplitter = new RecursiveCharacterTextSplitter({
separators: ["\n", "。", ","], // 分隔符优先级:先换行,再句号,再逗号
chunkSize: 200, // 字符上限
chunkOverlap: 20, // 重叠字符数
});
const logChunks = await logSplitter.splitDocuments([logDocument]);
const enc = getEncoding('cl100k_base');
logChunks.forEach(doc => {
console.log('character length:', doc.pageContent.length);
console.log('token length:', enc.encode(doc.pageContent).length);
});
输出会清楚地暴露字符与 Token 之间的"汇率"——同一段中文长文本,character length 可能是 180,但 token length 可能是 280。这也是为什么在生产环境中,很多人会在 RecursiveCharacterTextSplitter 验证通过后,再加一层 Token 数校验。
它的核心算法可以概括为:
function split(text, separators, chunkSize):
// 1. 取当前优先级最高的分隔符
separator = separators[0]
// 2. 用该分隔符切分文本
splits = text.split(separator)
// 3. 合并切分结果,尽量接近但不超出 chunkSize
for each split in splits:
if (currentChunk.length + split.length > chunkSize):
if (currentChunk.length < chunkSize):
保存 currentChunk,另起新块
else:
// 当前块仍然超标 → 递归降级到下一个分隔符
subSplits = split(split, separators[1:], chunkSize)
保存 subSplits
// 4. 根据 chunkOverlap 为相邻块添加重叠内容
applyOverlap(allChunks, chunkOverlap)
关键在第三步的 else 分支:当使用最高优先级分隔符切出来的片段仍然超出 chunkSize 时,不会暴力截断,而是递归降级到下一个分隔符重新尝试。比如:
separators: ["\n", "。", ","]
一个超长段落
→ 先试 \n 切:只切成 1 段(因为段落内没有换行),仍超标
→ 降级到 。 切:切成 3 句,其中第 2 句还超标
→ 降级到 ,切:切成若干短句,全部合规
这种策略保证切割总是优先发生在最高语义层级——段落边界(换行)优于句子边界(句号),句子边界优于从句边界(逗号)。同样是切成 200 字符的块,Recursive 切出来的块比 CharacterTextSplitter 更"完整",比 TokenTextSplitter 更"语义连贯"。
但它有一个固有矛盾:用字符数限制,却试图在语义边界切割。当一段话在最近的一个句号处已经 190 字符,再加一句就变成 250 字符时,切割器面临选择——是在句号处切(190 字符,语义完整但浪费了 10 字符配额),还是继续凑到接近 200 字符再切(可能切断语义)?RecursiveCharacterTextSplitter 选择了前者——优先保证语义完整性,即使单个 chunk 略小于 chunkSize。
三者对比一览
| 维度 | CharacterTextSplitter | TokenTextSplitter | RecursiveCharacterTextSplitter |
|---|---|---|---|
| 计量单位 | 字符数 | Token 数 | 字符数 |
| 切割策略 | 累计字符数到阈值即切 | 累计 Token 数到阈值即切 | 分层递归 + 分隔符优先级降级 |
| 语义感知 | 无 | 无(但 Token 天然有一定语义) | 强——优先在段落/句子/从句边界切割 |
| 精确度 | 低(Token 估算偏差大) | 高(直接对标 LLM 窗口) | 中(字符数估算,但有语义补偿) |
| 依赖 | 无 | js-tiktoken + 编码表 | 无 |
| 速度 | 最快 | 慢(需 Token 化) | 中等 |
| 中文友好度 | 较好 | 较好 | 最好 |
| 适用场景 | 日志、代码、原型 | 生产环境、Token 预算敏感 | 文章、文档、对话等自然语言 |
如何选择?
没有万能切割器,只有合适的选择:
- 不确定用什么?从
RecursiveCharacterTextSplitter开始。 它的语义感知能力在大多数自然语言场景下表现最好,是 LangChain 文档的默认推荐。 - 对接 OpenAI 模型且关注 Token 预算?用
TokenTextSplitter。 当每个请求的 Token 消耗直接等于成本时,精确计数不是可选项,是必选项。 - 处理结构化日志或代码?用
CharacterTextSplitter。 这类内容的行本身就是天然的分割单元,不需要复杂的递归策略。 - 进阶玩法:组合使用。 先用
RecursiveCharacterTextSplitter做语义切割,再对每个 Chunk 用TokenTextSplitter做二次校准——确保既不破坏语义,又不超出 Token 预算。这在高要求的生产 RAG 管道中是最常见的模式。
面试考点总结
1. RAG 的核心流程是什么?每一步的作用分别是什么?
Loading → Splitting → Embedding → Storing → Retrieval → Generation。加载负责获取外部知识,切分保证检索粒度合理,嵌入将文本转为可计算的向量,存储建立索引,检索找出相关内容,生成基于检索结果回答问题。漏掉任何一步都走不通。
2. chunkSize 和 chunkOverlap 分别如何影响 RAG 效果?
chunkSize 决定了检索粒度:太大则检索不精确且可能超出 LLM 上下文窗口,太小则语义碎片化。chunkOverlap 防止边界语义断裂:保留相邻块之间的重叠内容,确保跨块的关键信息不被截断。两者需要根据文档类型和业务场景联合调优。
3. RecursiveCharacterTextSplitter 的"递归"是什么意思?
它按分隔符优先级递归切分:先用高优先级分隔符(如句号)切,对超出 chunkSize 的块降级到下一个分隔符(如逗号)继续切,直到所有块都符合大小限制。这种方式优先在自然语义边界上切割,而非暴力截断。
4. Embedding 模型和 Chat 模型有什么区别?能否互换?
不能互换。Embedding 模型将文本映射为固定维度的向量,输出是数字数组,用于语义相似度计算;Chat 模型接收文本序列、输出文本,用于对话和推理。它们是两种不同的模型架构,职责完全不同。RAG 中两个模型各司其职:Embedding 负责"找",Chat 负责"答"。
5. 向量检索的相似度评分是怎么算出来的?
将用户问题向量化后,在向量空间中计算问题向量与所有文档向量的距离(常用余弦相似度、欧氏距离或内积),距离越小表示语义越接近。引擎返回距离最近的 K 个文档及其分数。1 - score 可近似转换为相似度百分比。如果最高分仍然很低,说明知识库可能没有覆盖该问题——此时应触发兜底策略而非强行回答。
6. MemoryVectorStore 和持久化向量数据库的区别是什么?
MemoryVectorStore 将向量和文档存在内存中,进程结束即丢失,适合原型验证。生产环境使用 Pinecone、Chroma、Milvus 等持久化方案,支持数据落盘、分布式检索、增量更新和权限控制。面试中能说出至少两种生产级向量数据库是加分项。
7. RAG 和微调(Fine-tuning)的适用场景分别是什么?
RAG 适合:知识频繁更新、需要可解释性(能溯源到具体文档)、外部知识注入、低成本快速接入。微调适合:需要模型学习特定风格或行为模式、任务定义稳定、对延迟敏感不能加检索环节。两者并不互斥,成熟的工业方案往往是 RAG + Fine-tuning 的组合。
8. 提示词模板在 RAG 中起什么作用?
提示词模板决定了检索到的文档如何与用户问题结合。它至少承担三个职能:角色设定(限定 AI 的行为边界,如"只基于给定文章回答")、结构化输入(在文档和问题之间插入明确的分隔标记)、防幻觉引导(隐含地告诉模型"不要编造,不知道就说不知道")。模板本身也是提示工程的一部分,需要针对不同场景迭代优化。
9. Token 和字符有什么区别?为什么这个区别对 RAG 很重要?
Token 是 LLM 处理文本的最小语义单元,不是字符。 一个英文单词可能是 1 个 Token,一个汉字通常是 1-2 个 Token。LLM 的上下文窗口(如 GPT-4 的 128K)是按 Token 计算的,而不是字符。如果按字符数估算 Token 消耗,误差可能高达 50%。在 RAG 中,这意味着你以为塞了 10 个 Chunk 给模型,实际可能已经爆了窗口。js-tiktoken 库的作用就是精确计算任意文本的 Token 数,消除这个估算误差。面试中能说出 cl100k_base(GPT-4 编码表名称)是加分项。
10. CharacterTextSplitter、TokenTextSplitter 和 RecursiveCharacterTextSplitter 三者有什么区别?分别适用什么场景?
这是切割器三兄弟,核心区别在于计量单位和切割策略:
- CharacterTextSplitter:按字符数切,简单直接,无外部依赖,速度快。适合日志、代码等结构化文本。缺点是完全无视语义边界。
- TokenTextSplitter:按 Token 数切,依赖
js-tiktoken编码表,切割结果直接对标 LLM 的上下文窗口。适合 Token 预算敏感的生产环境。缺点是需要加载编码表、切割点可能在语法上不自然。 - RecursiveCharacterTextSplitter:按字符数切上限,但用递归分隔符策略优先在语义边界切割(段落 → 句子 → 从句逐级降级)。适合文章、文档等自然语言内容。它用字符数做上限、用语义做约束,是 LangChain 的默认推荐。
三者不是互斥的——生产环境中常见"Recursive 语义切割 + TokenTextSplitter 二次校准"的组合模式。
11. tiktoken 是什么?cl100k_base 又是什么?
tiktoken 是 OpenAI 开源的 Token 计数库(js-tiktoken 是其 JS 移植版),它能精确计算任意文本在特定模型下的 Token 数。cl100k_base 是 GPT-4 和 GPT-3.5-turbo 使用的编码表名称——不同的模型使用不同的编码表(如 GPT-2 用 gpt2),编码表决定了文本如何被切分为 Token。在 RAG 中使用 TokenTextSplitter 时必须指定与目标模型匹配的编码表,否则 Token 计数不准确。
12. 为什么 RecursiveCharacterTextSplitter 用字符数而不用 Token 数做上限?
这是一个设计权衡。如果要精确控制 Token 数,需要在每次切割时实时 Token 化文本——这对长文档来说是巨大的性能开销。用字符数做上限,用递归分隔符做语义补偿,是一种工程上的近似最优解:速度快、无外部依赖、语义完整性好,代价是 Token 估算不够精确。当 Token 精度不可妥协时(如按 Token 计费的 API),改用 TokenTextSplitter 或对 Recursive 的输出做 Token 二次校验。