搭建 RAG 的第一个坎:网页检索答非所问?说说文档加载与分块的那些坑
折腾了一晚上,从爬虫怎么取正文,到文档怎么切块,踩了好几个实打实的坑。今天整理出来,给同样刚上手 RAG 的朋友避避。
爬网页取正文,正则真的不如选择器香
最开始我图省事,直接 axios 发请求拿 html 字符串,想着用正则把正文抠出来。写了没两分钟我就放弃了 —— 掘金的页面结构杂得很,侧边栏、评论、推荐栏全混在 html 里,正则写出来又长又脆,改个类名就全崩了。
后来想起以前听人提过 cheerio,说就是服务端的 jQuery。我一个写前端的,用 CSS 选择器拿元素那不是手到擒来?
装完依赖写了个最简版本:
复制代码import axios from 'axios';
import * as cheerio from 'cheerio';const targetUrl = 'https://juejin.cn/post/7660707431753678854';async function crawlPage() {
try {
const { data: html } = await axios.get(targetUrl);
const $ = cheerio.load(html); // 在内存里造出 DOM 树
const pageContent = $('.article-content').text();
console.log(pageContent);
} catch (err) {
console.error('爬取失败', err);
}
}crawlPage();
跑通那一刻真的舒服。说白了 cheerio 干的事很简单:把拿到的 html 字符串在内存里解析成 DOM 树,然后你就可以用熟悉的 $('.类名') 去捞元素,完全不用跟正则较劲。
踩坑提醒 别上来就瞎写选择器,先打开页面 F12 确认一下正文容器的类名。我一开始想当然写了 .content,结果打印出来空的,对着页面核对了半天才发现是 .article-content。别问我为什么知道,试了三次。
这样爬出来的内容干净是干净了,但新的问题马上就来了。
整段塞进去不行吗?为什么非要做文档切割
最开始我的想法很朴素:文章都爬出来了,直接整段转成向量丢库里不就完了?
结果检索的时候就出问题了:一篇文章几千字,向量是把整段话压缩成一个向量,你问一个具体的小问题,它匹配到的是整篇文章的整体语义,答出来自然泛得没边。
这就好比你去图书馆查 “番茄炒蛋放多少盐”,管理员直接给你抱来一整本《中国菜谱大全》,说这本书里有答案。有是有,但你得自己翻半天。
文档切割(Text Splitter)干的事,就是把这本厚书拆成一页一页、一段一段的 “知识卡片”,每张卡片讲一小块内容。检索的时候直接把最相关的那几张卡片拎出来,答案自然就准了。
但怎么切也是门学问,不是咔咔乱切就行。
我踩过的分块坑:不是切得越细越好
LangChain 里最常用的就是 RecursiveCharacterTextSplitter,名字挺长,其实逻辑很接地气:它会按你给的分隔符列表,挨个尝试去切,尽量在语义完整的地方下刀,而不是硬把一句话砍断。
我最开始直接抄了别人的配置,chunkSize 设 1000,也没改分隔符,跑出来的效果一言难尽。中文句子靠句号收尾,默认的分隔符都是英文换行、空格,切出来的东西经常一句话劈两半。
后来改成了这样:
复制代码const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每块大概 400 字符
separators: ['。', '!', '?'], // 优先在句末标点切
chunkOverlap: 100, // 相邻两块重叠 100 字符
});
这里面每个参数我都踩过坑,挨个说。
chunkSize:多大才合适?
不是越小越精准。切得太碎,每一块都只剩几个词,语义就断了;切得太大,一块里塞了好几个主题,检索还是不准。
我试了 200、400、800 几个档位,对于普通技术文章来说,400 字符大概一两段话,刚好能把一个小知识点讲完整,体验下来是最舒服的。
separators:切在哪里很重要
这个参数是灵魂。你告诉它 “优先在句号、感叹号、问号后面切”,它就会尽量保住一句话的完整性。
千万别用逗号当分隔符,半句话的语义碎得没法用。
chunkOverlap:重叠不是冗余,是补锅
我一开始觉得 overlap 纯属浪费,切分开就分开了,重叠干嘛?
直到我看到有一块的结尾是 “path.resolve 的目标不是简单拼接,而是得到一个”,下一块开头是 “可以定位文件的绝对路径”。单看哪一块都不完整,拼起来才是一句完整的话。
overlap 就是干这个的:万一真的从句子中间切开了,两边各留一截重叠的内容,保证语义不会断层。就像粘东西的时候两边都留点余量,才不会裂开。
注意 overlap 不是越大越好,一般设成 chunkSize 的 1/4 到 1/5 就够了。太大了会出现大量重复片段,反而会干扰检索结果。
跑通的那一刻:看看检索结果准不准
分完块丢进向量库,我搜了一句 “总结一下 fs 模块的作用”,打印出来的结果是这样:

能看到排最前面的文档,相似度 0.3892,内容刚好就是讲 fs 模块定义的那段,确实比之前整段塞进去准多了。
这里有个小点我当时琢磨了半天:原始分 0.61 左右,相似度是 1 减原始分。因为向量数据库算的是 “距离”,距离越小,两个向量越像。所以原始分越低,相似度指标越高,别搞反了。
上面的检索和打分结果,就是下面这套完整流程跑出来的。从环境配置、网页加载、文档分块,到向量存储、相似度检索、最后拼接 Prompt 调用大模型,一整套链路都在里面:
复制代码import "dotenv/config";
import "cheerio";
// 从 url 加载文档
import {
CheerioWebBaseLoader
} from "@langchain/community/document_loaders/web/cheerio";
import {
RecursiveCharacterTextSplitter
} from '@langchain/textsplitters';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import {
ChatOpenAI,
OpenAIEmbeddings
} from '@langchain/openai';const model = new ChatOpenAI({
temperature: 0,
model: process.env.DASHSCOPE_API_MODEL,
apiKey: process.env.DASHSCOPE_API_KEY,
configuration: {
baseURL: process.env.DASHSCOPE_API_BASE_URL,
},
});const embeddings = new OpenAIEmbeddings({
apiKey: process.env.DASHSCOPE_API_KEY,
model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
configuration: {
baseURL: process.env.DASHSCOPE_API_BASE_URL
},
});// 访问网址 并提取文档内容
// cheerio 可以传递 css 选择器 来提取文档内容 缩小范围
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7660707431753678854',
{
selector: '.main-area p'// 文章段落
}
);// 大的 document 分成小的 document 更加精心地去处理语义
const documents = await cheerioLoader.load();// 递归尝试不同的分隔符,找到最优的切割点,保证每个 chunk 语义尽量完整
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
separators: ['。', '!', '?'],
chunkOverlap: 100,
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(`文档分割完成,共${splitDocuments.length}个 chunk`);// 创建向量存储
console.log('创建向量存储');
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings
);
console.log('向量存储完成');
const retriever = vectorStore.asRetriever({k: 3});const question = "总结一下fs模块的作用";
console.log('='.repeat(80));
console.log(question);
console.log('='.repeat(80));// 检索相关文档
const docs = await retriever.invoke(question);// 带分数的相似度检索
const scoredResults =
await vectorStore.similaritySearchWithScore(question, 3);console.log("n [检索到的文档及相似度评分]");
docs.forEach((doc, i) => {
const scoredResult = scoredResults.find(([scoredDoc]) =>
scoredDoc.pageContent === doc.pageContent
)
const score = scoredResult? scoredResult[1]: null;
const similarity = score != null ? (1 - score).toFixed(4):
"N/A" console.log(`n[文档 ${i + 1}] 相似度指标: ${similarity} (原始分: ${score})`);
console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
console.log(`元数据:章节=${doc.metadata.chapter}, 角色=${doc.metadata.character}, 类型=${doc.metadata.type}`);
});// 拼接上下文,组装 Prompt 调用大模型
const context = docs
.map((doc, i) => `[片段${i}]n ${doc.pageContent}`)
.join("nn-----nn");const prompt = `
你是一个文章辅助阅读助手,根据文章内容来解答问题。文章内容:
${context}问题:${question}你回答:`;const response = await model.invoke(prompt);
console.log(response.content);
你看,从爬取正文,到切块,再转向量检索,这一整套下来,其实核心就一件事:给 AI 喂干净、准确、语义完整的上下文。
核心流程的精简版我也单独拎出来了,对着改改 url 和选择器就能快速上手:
复制代码import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { OpenAIEmbeddings } from '@langchain/openai';// 初始化向量模型
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.DASHSCOPE_API_KEY,
model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
configuration: { baseURL: process.env.DASHSCOPE_API_BASE_URL },
});// 加载网页 + 指定选择器只取正文
const loader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7660707431753678854',
{ selector: '.main-area p' }
);// 加载 + 分割
const documents = await loader.load();
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
separators: ['。', '!', '?'],
chunkOverlap: 100,
});
const splitDocs = await textSplitter.splitDocuments(documents);// 存入向量库并检索
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, embeddings);
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke("总结一下fs模块的作用");
多说一句:现在写代码,感觉真的不一样了
折腾完这个小 demo 我最大的感触是,现在做 AI 应用,写代码本身反而不是最难的部分。
API 调用、向量库、分割器,全都是现成的轮子。真正决定效果好不好的,是你能不能选对 loader、能不能把文档切得合理、能不能把上下文组织得清晰。说白了,不是拼谁代码写得溜,是拼谁能把问题拆解清楚,把喂给 AI 的材料打理明白。
以前是把逻辑写给计算机执行,现在是把上下文喂给 AI 处理。说什么 vibecoding 有点玄乎,但 “提出好问题、准备好上下文、搭好稳定的流程”,确实越来越重要了。
最后捋一下这趟踩坑下来最实在的三个结论:
- 爬取网页正文,cheerio + CSS 选择器远比重构靠谱,对前端友好到离谱;
- 文档切割不是切得越碎越好,保住语义完整是第一位的,优先在句末标点下刀;
- chunkOverlap 不是多余的设计,是用来补语义断层的,别省。
当然这也只是最基础的用法,后面还有更复杂的语义分块、重排序这些东西,我也还在啃。
如果你也在刚学 RAG 的路上踩过什么奇葩坑,或者有更好的分块经验,评论区留个言,我也想学学。
-
07.21
《空战争锋》闪电式F2 闪电
-
07.21
《空战争锋》米格-21F哨兵
-
07.21
空战争锋V2.9版本飞机属性大全
-
07.21
空战争锋 苏-75绝杀
-
07.21
空战争锋 歼-35 海王星
-
07.21
歼-20S 双截龙 《空战争锋》
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏