🔪 RAG 进阶实战:像外科手术一样精准地“加载”与“切割”数据

21 阅读8分钟

👋 哈喽,掘金的家人们(JYM)!

还记得上一集我们讲的 RAG(检索增强生成)吗?我们用“光光和东东”的故事,成功治好了大模型的“胡说八道症”。

但是!(敲黑板)细心的同学可能发现了,上次的数据是我们 Hardcode(硬编码) 在代码里的。 而在真实世界里,老板给你的需求通常是:“嘿,把咱们公司的技术文档/这个网站的内容/那堆 PDF 喂给 AI,让它能回答客户问题。”

这就引出了 RAG 系统中两个至关重要的环节:

  1. Loader(加载器):数据的“搬运工”,负责把各种格式的数据统一变成文本。
  2. Splitter(切割器):数据的“精细刀工”,负责把长文本切成 AI 易于消化的小块。

今天,我们就以抓取一篇掘金文章并进行问答为例,手把手带你打通 RAG 的数据大动脉!


🧐 第一部分:为什么要“切”?(The Why)

很多初学者问:“直接把网页 HTML 扔给 LLM 不行吗?”

绝对不行!理由有三:

  1. Token 贵啊!:一篇长文几万字,你问个“作者是谁”,结果把全文都发过去,这 Token 烧得心疼不?
  2. 上下文限制:虽然现在 LLM 支持 128k 甚至更长,但输入越长,模型注意力越分散(Lost in the Middle 现象),回答质量反而下降。
  3. 垃圾信息:网页里全是广告、导航栏、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(递归)”,是因为它非常智能。它的切割逻辑是这样的:

  1. 第一轮尝试:先试着用第一个分隔符 (句号)来切。
    • 如果切出来的一段话 < 400字,完美,保留。
    • 如果切出来的一段话 > 400字(比如一个超长段落),怎么办?
  2. 第二轮尝试:退而求其次,用第二个分隔符 (逗号)在那个长段落里继续切。
    • 如果还不行?
  3. 第三轮尝试:用 继续切。
  4. 实在不行,就强制按字符切。

总结:它会尽最大努力保持句子的完整性,不会像笨蛋一样在单词中间或者句子的一半硬生生切开。它懂中文语义!

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 数据处理的黄金法则:

  1. Loader 是过滤器:使用 selector 或正则,在源头去掉噪音(广告、导航)。
  2. Splitter 是关键
    • Recursive:必须用递归分割,尊重语义边界(句号、逗号)。
    • Overlap:必须有重叠(如 50-100字),防止上下文在切口处丢失。
    • Size:大小适中(400-1000),平衡语义完整性和检索精度。

掌握了 CheerioLoaderRecursiveCharacterTextSplitter,你就拥有了处理互联网 90% 文本数据的能力。不管是爬取新闻、分析财报,还是做个人知识库,这套流程都是通用的!

💡 思考题:如果我们要处理 PDF 文件,LangChain 里应该用什么 Loader?如果处理的是 Python 代码文件,Splitter 的分隔符应该换成什么?(提示:代码不是按句号分割的哦~)

记得点赞收藏,代码多敲几遍,RAG 原理自然通!💪