👋 哈喽,掘金的家人们(JYM)!
还记得上一集我们讲的 RAG(检索增强生成)吗?我们用“光光和东东”的故事,成功治好了大模型的“胡说八道症”。
但是!(敲黑板)细心的同学可能发现了,上次的数据是我们 Hardcode(硬编码) 在代码里的。 而在真实世界里,老板给你的需求通常是:“嘿,把咱们公司的技术文档/这个网站的内容/那堆 PDF 喂给 AI,让它能回答客户问题。”
这就引出了 RAG 系统中两个至关重要的环节:
- Loader(加载器):数据的“搬运工”,负责把各种格式的数据统一变成文本。
- Splitter(切割器):数据的“精细刀工”,负责把长文本切成 AI 易于消化的小块。
今天,我们就以抓取一篇掘金文章并进行问答为例,手把手带你打通 RAG 的数据大动脉!
🧐 第一部分:为什么要“切”?(The Why)
很多初学者问:“直接把网页 HTML 扔给 LLM 不行吗?”
❌ 绝对不行!理由有三:
- Token 贵啊!:一篇长文几万字,你问个“作者是谁”,结果把全文都发过去,这 Token 烧得心疼不?
- 上下文限制:虽然现在 LLM 支持 128k 甚至更长,但输入越长,模型注意力越分散(Lost in the Middle 现象),回答质量反而下降。
- 垃圾信息:网页里全是广告、导航栏、CSS 样式,这些噪音会干扰模型。
所以,我们需要一套**ETL(Extract, Transform, Load)**流程: 网页 -> 纯文本 (Extract) -> 切割成小块 (Transform) -> 存入向量库 (Load)
🛠️ 第二部分:代码实战 —— 打造你的爬虫 RAG
我们要实现的目标是:让 AI 读懂一篇关于“父亲去世对作者人生态度影响”的掘金文章,并回答我们的提问。
2.1 环境准备:搬运工的工具箱
首先,我们需要引入一些强大的工具。
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L2-4
import "cheerio"; // 💡 后端界的 jQuery,使用 CSS 选择器像操作 DOM 一样查找节点
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
cheerio:前端同学对 jQuery 肯定不陌生。Cheerio 就是 Node.js 版的 jQuery,它能让我们用 CSS 选择器(比如.class,#id)去提取 HTML 里的内容。CheerioWebBaseLoader:LangChain 封装好的网页加载器,底层就是用的 cheerio。RecursiveCharacterTextSplitter:今天的主角!递归字符文本分割器,它是目前最智能的切割方案之一。
2.2 实例化模型:大脑就位
这部分和上一篇一样,我们需要准备好 ChatModel(负责说话)和 Embeddings(负责把文字变向量)。
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L9-24
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME, // GPT-3.5 / GPT-4
apiKey: process.env.OPENAI_API_KEY,
temperature: 0 // 严谨模式
});
const embeddings = new OpenAIEmbeddings({
modelName: process.env.EMBEDDING_MODEL_NAME, // text-embedding-3-small
apiKey: process.env.OPENAI_API_KEY,
});
🕷️ 第三部分:Loader —— 只要精华,不要糟粕
我们要抓取的文章链接是 https://juejin.cn/post/7233327509919547452。
打开掘金文章页,F12 审查元素,你会发现文章正文通常包裹在 .main-area 类或者是 article 标签里。
我们要把这些内容“吸”下来。
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L26-31
const cheerioLoader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452?searchId=20260302193603120AE3328025B138C1FB",
{
selector: ".main-area p"
}
);
🔍 深度解析:
selector: ".main-area p":这一行代码价值千金!- 如果我们不加这个 selector,Loader 会把整个网页(包括顶部的“首页”、右边的“广告”、底部的“相关推荐”)全部抓下来。这都是噪音!
- 这里我们指定只抓取
.main-area下面的p(段落)标签。这就像吃小龙虾只吃虾尾肉,壳和头全扔掉。这就是清洗数据的第一步。
执行加载:
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L33-33
const documents = await cheerioLoader.load();
此时,documents 里存放的就是经过清洗的、纯净的文章段落文本。
🔪 第四部分:Splitter —— 递归切割的艺术
拿到了几千字的长文,直接存向量库效果不好。我们需要把它切成小块(Chunk)。
这里使用的是 RecursiveCharacterTextSplitter(递归字符文本分割器)。
4.1 核心参数配置
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L35-39
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 📏 每一块的大小(字符数)
chunkOverlap: 50, // 🔗 重叠部分的大小
separators: ["。", ",", "?","!"], // ✂️ 语义分割符优先级
})
这一段代码是 RAG 优化的核心,我们逐行拆解:
1. chunkSize: 400
这决定了每个切片的最大容量。
- 太小:语义支离破碎(比如一句话还没说完就切断了)。
- 太大:包含太多无关信息,检索精度下降。
- 400~1000 是一个经验上的黄金区间。
2. chunkOverlap: 50(关键!🌟)
为什么要有重叠? 想象一下,如果一句话是:“关键密码是...(切断)...123456”。
- 切片 A: “...关键密码是”
- 切片 B: “123456...” 当你搜“密码”时,找到了 A,但 A 里没密码;找到了 B,但 B 不知道这是密码。信息断层了!
设置 chunkOverlap: 50,意味着切片 A 的结尾 50 个字,会重复出现在切片 B 的开头。
- 切片 A: “...关键密码是 123456”
- 切片 B: “关键密码是 123456...” 这样无论检索到哪个,语义都是连贯的。这就是“重叠”的魔力。
并不是每一次切割都会重叠,它会智能判断是否需要重叠来保证语义的完整。
3. separators: ["。", ",", "?","!"](递归的奥义 🔄)
这个 Splitter 之所以叫“Recursive(递归)”,是因为它非常智能。它的切割逻辑是这样的:
- 第一轮尝试:先试着用第一个分隔符
。(句号)来切。- 如果切出来的一段话 < 400字,完美,保留。
- 如果切出来的一段话 > 400字(比如一个超长段落),怎么办?
- 第二轮尝试:退而求其次,用第二个分隔符
,(逗号)在那个长段落里继续切。- 如果还不行?
- 第三轮尝试:用
?或!继续切。 - 实在不行,就强制按字符切。
总结:它会尽最大努力保持句子的完整性,不会像笨蛋一样在单词中间或者句子的一半硬生生切开。它懂中文语义!
4.2 执行切割
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L41-41
const splitDocuments = await textSplitter.splitDocuments(documents);
现在,splitDocuments 变成了一个包含许多小 Document 对象的数组,每个对象大概 400 字左右,且首尾相连。
💾 第五部分:向量化与存储
接下来的步骤就和之前一样了,我们将切好的“肉块”扔进向量锅里煮。
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L46-49
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings
);
这里,LangChain 会自动调用 OpenAIEmbeddings,把每一个文本块转成 [0.12, -0.45, ...] 这样的向量,并存入内存。
🔍 第六部分:检索与生成 —— 见证真相
现在,我们来问一个必须读过文章才能回答的深刻问题: “父亲去世对作者的人生态度产生了怎样的根本性逆转?”
6.1 检索(Retrieve)
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L52-62
const retriever = await vectorStore.asRetriever({k:2}); // 只找最相关的 2 段
const retrievedDocs = await retriever.invoke(question);
const scoreResults = await vectorStore.similaritySearchWithScore(question);
6.2 可视化检索结果(Debug 专用)
作为开发者,我们不能黑盒操作。我们需要看看 RAG 到底找出了什么。
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L65-76
retrievedDocs.forEach((doc, i) => {
// ... 计算相似度分数 ...
console.log(`文档${i+1}:${doc.pageContent} 相似度评分:${similarity}`);
});
你会发现,由于我们之前切分得当(使用了递归和重叠),检索出的片段通常包含完整的句子,并且正好是文章中描写“父亲去世”和“人生感悟”的那一段。向量匹配的精度直接依赖于 Splitter 的质量。
6.3 增强生成(Augment & Generate)
最后,拼装 Prompt,召唤 LLM。
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\rag-loader\rag-test\loader-and-splitter.mjs #L78-92
const content = retrievedDocs
.map((doc, i) => `文档${i+1}:${doc.pageContent}`)
.join("\n\n--------\n\n");
const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${content}
问题:
${question}
回答:
`
const response = await model.invoke(prompt);
console.log(response.content);
AI 的回答(示例):
"作者在父亲去世后,意识到生命的脆弱和短暂。他从以前的追求功名利禄,转变为更加珍惜当下,重视与家人的陪伴,开始用一种更平和、豁达的心态去面对生活中的得失..."
完美!👏👏👏
📝 总结
今天我们从“手搓数据”进化到了“工业级数据处理”。让我们复盘一下 RAG 数据处理的黄金法则:
- Loader 是过滤器:使用
selector或正则,在源头去掉噪音(广告、导航)。 - Splitter 是关键:
- Recursive:必须用递归分割,尊重语义边界(句号、逗号)。
- Overlap:必须有重叠(如 50-100字),防止上下文在切口处丢失。
- Size:大小适中(400-1000),平衡语义完整性和检索精度。
掌握了 CheerioLoader 和 RecursiveCharacterTextSplitter,你就拥有了处理互联网 90% 文本数据的能力。不管是爬取新闻、分析财报,还是做个人知识库,这套流程都是通用的!
💡 思考题:如果我们要处理 PDF 文件,LangChain 里应该用什么 Loader?如果处理的是 Python 代码文件,Splitter 的分隔符应该换成什么?(提示:代码不是按句号分割的哦~)
记得点赞收藏,代码多敲几遍,RAG 原理自然通!💪