从 URL 到 Chunk:网页爬取与语义切割实战,构建 RAG 知识库的第一步
上一篇文章我们用 LangChain 实现了 RAG 检索增强生成,但那个故事是手写 放进代码里的。现实中,知识库的素材来源五花八门:网页、PDF、Word、视频字幕......本文将带你打通 RAG 的第一步 ------如何把任意网页 URL 抓下来、切成语义完整的 Chunk,最终变成可供检索的 Document。从 axios + cheerio 手写爬虫,到 LangChain 的
CheerioWebBaseLoader+RecursiveCharacterTextSplitter,完整走通知识库构建链路。
前置知识
- 了解 RAG(检索增强生成)的基本概念
- 了解 LangChain 的
Document对象结构 - 有 Node.js + npm 基础
如果还不熟悉 RAG,建议先阅读:
- 告别AI幻觉!LangChain RAG 完全实战 ------ LangChain RAG 框架实战
- RAG语义搜索全栈实战 ------ 纯手写版,理解底层 cosine 相似度
一、为什么需要网页爬取 + 文档切割?
1.1 RAG 知识库构建的完整链路
vbnet
用户想构建一个"AI 技术博客知识库"
│
▼
┌────────────────────────────────────────────────────────────┐
│ Step 1: 数据采集 │
│ 网页 URL → HTTP 请求 → HTML 字符串 → 提取纯文本 │
│ (axios + cheerio / CheerioWebBaseLoader) │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Step 2: 文档切割 │
│ 长文本 → 按语义分隔符切割 → 大小适中的 Chunk │
│ (RecursiveCharacterTextSplitter) │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Step 3: 向量化存储 │
│ Chunk → Embedding → VectorStore → Retriever │
│ (OpenAIEmbeddings + MemoryVectorStore) │
└────────────────────────────────────────────────────────────┘
│
▼
用户提问 → 检索相关 Chunk → LLM 基于 Chunk 回答
本文聚焦 Step 1 + Step 2,即"从 URL 到 Chunk"的过程。
1.2 为什么不能直接给 LLM 整个网页?
| 问题 | 说明 |
|---|---|
| HTML 噪音 | 网页包含导航栏、广告、评论区等无关内容,需要提取核心正文 |
| 文本过长 | 一篇长文可能上万字,超出 Embedding 模型的 token 限制 |
| 语义断裂 | 粗暴切割会导致句子被截断,"前半句在 Chunk A,后半句在 Chunk B" |
| 检索粒度 | 用户问的是一个小知识点,但整篇文章作为一个向量,无法精准匹配 |
因此,我们需要:先爬取 → 再提取正文 → 再切割 → 最后向量化。
二、手写爬虫:axios + cheerio 抓取网页
2.1 核心思路
css
URL → axios 发请求 → 拿到 HTML 字符串 → cheerio 解析 → CSS 选择器提取正文
| 工具 | 作用 | 类比前端 |
|---|---|---|
| axios | 发送 HTTP 请求,获取网页 HTML | fetch() |
| cheerio | 在 Node.js 中解析 HTML,支持 CSS 选择器 | 浏览器中的 document.querySelector() |
2.2 完整代码(crawl.mjs)
javascript
import axios from 'axios';
import * as cheerio from 'cheerio';
const targetUrl = 'https://juejin.cn/post/7660707431753678854';
async function crawlPage() {
try {
// 1. 发送 HTTP 请求,获取 HTML
const { data: html } = await axios.get(targetUrl, {
headers: {
// 模拟浏览器请求,防止被反爬
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
// 2. 用 cheerio 解析 HTML,构建内存中的 DOM 树
const $ = cheerio.load(html);
// 3. 用 CSS 选择器提取文章正文段落
// .main-area p 表示:class="main-area" 的元素内的所有 <p> 标签
const pageContent = $('.main-area p').text();
console.log('提取到的正文内容:');
console.log(pageContent.substring(0, 200) + '...');
return pageContent;
} catch (error) {
console.error('爬取失败:', error.message);
throw error;
}
}
crawlPage();
2.3 cheerio 的工作流程
css
HTML 字符串(如 <html><body><div class="main-area"><p>第一段</p><p>第二段</p></div></body></html>)
│
▼ cheerio.load(html)
内存中的 DOM 树结构(类似浏览器中的 document 对象)
│
▼ $('.main-area p').text()
提取所有 class="main-area" 内的 <p> 标签文本
│
▼
"第一段 第二段 ..."
cheerio 的优势:不需要启动真实的浏览器(不像 Puppeteer/Playwright 那样重),直接在内存中解析 HTML,速度快、资源占用低,非常适合纯文本提取场景。
三、LangChain 内置方案:CheerioWebBaseLoader
手写爬虫虽然直观,但 LangChain 已经为我们封装好了标准的网页加载器,一行代码就能拿到 Document 对象。
3.1 什么是 Loader?
css
┌─────────────────────────────────────────────┐
│ Loader 的本质 │
│ │
│ 输入:文件 / URL / 数据库 / API │
│ │ │
│ ▼ │
│ Loader(加载器) │
│ │ │
│ ▼ │
│ 输出:[Document, Document, ...] │
│ 每个 Document 包含 pageContent │
│ 和 metadata(来源 URL 等) │
└─────────────────────────────────────────────┘
LangChain 社区提供了 180 多种 Loader,覆盖各种数据源:
| Loader | 来源 | 包 |
|---|---|---|
CheerioWebBaseLoader |
网页 URL | @langchain/community |
PDFLoader |
PDF 文件 | @langchain/community |
CSVLoader |
CSV 文件 | @langchain/community |
TextLoader |
纯文本文件 | @langchain/core |
YoutubeLoader |
YouTube 字幕 | @langchain/community |
3.2 CheerioWebBaseLoader 实战
javascript
import 'dotenv/config';
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
// 创建 Loader,指定 URL 和 CSS 选择器
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7660707431753678854',
{
selector: '.main-area p' // 只提取文章正文段落
}
);
// 加载文档,返回 Document 数组
const documents = await cheerioLoader.load();
console.log(`共加载 ${documents.length} 个 Document`);
console.log('第一个 Document 的 pageContent:');
console.log(documents[0].pageContent.substring(0, 200) + '...');
console.log('metadata:', documents[0].metadata);
CheerioWebBaseLoader 底层原理就是 axios + cheerio,但它做了额外的封装:
- 自动处理 HTTP 请求(包括 User-Agent、超时等)
- 返回标准 Document 对象 (
pageContent+metadata) - metadata 自动填充来源信息 (如
source: URL)
3.3 CSS 选择器:精准提取的关键
javascript
// 只提取文章正文(排除导航、广告、评论区)
selector: '.main-area p'
// 提取标题
selector: 'h1.article-title'
// 提取代码块
selector: 'pre code'
// 提取整个文章内容区域
selector: 'article'
不同网站的 DOM 结构不同,需要根据目标网页的实际 HTML 结构来选择合适的选择器。可以通过浏览器 DevTools(F12 → 元素面板)查看目标内容的 class 或 id。
四、文档切割:RecursiveCharacterTextSplitter 详解
4.1 为什么要切割?
假设我们爬取了一篇文章,内容如下:
yaml
全文共 5000 字(约 3000 token)
Embedding 模型的 token 限制:通常为 512 ~ 8192 token
如果直接把 5000 字作为一个 Document 去 Embedding:
❌ 超出 token 限制,模型会截断
❌ 用户问一个小知识点,整篇文章作为一个向量,无法精准匹配
正确的做法:切成多个小 Chunk,每个 Chunk 400 字左右
✅ 不超出 token 限制
✅ 检索粒度更细,匹配更精准
4.2 语义完整性 vs 大小控制
切割文档时,面临一个核心矛盾:
| 目标 | 要求 |
|---|---|
| 语义完整 | 不要在句子中间切断,"前半句在 Chunk A,后半句在 Chunk B" |
| 大小可控 | 每个 Chunk 不能超过设定的 chunkSize |
LangChain 的 RecursiveCharacterTextSplitter 通过递归尝试不同分隔符来解决这个矛盾:
swift
尝试用 "\n\n"(段落)分隔 → 每个段落小于 chunkSize?
├── 是 → 段落作为一个 Chunk
└── 否 → 尝试用 "。"(句号)分隔
├── 是 → 句子作为一个 Chunk
└── 否 → 尝试用 ","(逗号)分隔
├── 是 → 短语作为一个 Chunk
└── 否 → 强制按字符数硬切
4.3 关键参数解析
javascript
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个 Chunk 的目标大小(字符数)
separators: ["。", "!", "?"], // 语义分隔符,按优先级尝试
chunkOverlap: 100, // 相邻 Chunk 之间的重叠字符数
});
const splitDocuments = await textSplitter.splitDocuments(documents);
| 参数 | 含义 | 建议值 |
|---|---|---|
chunkSize |
每个 Chunk 的目标字符数 | 200~1000(根据 Embedding 模型的 token 限制调整) |
separators |
语义分隔符数组,按优先级尝试 | 中文:["\n\n", "。", "!", "?", "\n", ","] |
chunkOverlap |
相邻 Chunk 的重叠字符数 | chunkSize 的 20%~30% |
4.4 separators 的选择策略
swift
分隔符优先级(从上到下,先尝试大的语义单元):
1. "\n\n" → 段落分隔(最大语义单元)
2. "。" → 句号(句子结束,强语义边界)✅ 推荐
3. "!" → 感叹号(句子结束,强语义边界)✅ 推荐
4. "?" → 问号(句子结束,强语义边界)✅ 推荐
5. "\n" → 换行
6. "," → 逗号(弱语义边界)❌ 不推荐优先使用
7. "" → 空字符串(强制按字符切)
为什么逗号不适合优先使用? 逗号表示句子内部的停顿,语义边界很弱。如果按逗号切割,可能会把"虽然...,但是..."这种因果关系的两个分句拆开,导致语义断裂。
4.5 chunkOverlap:用冗余换语义连续性
arduino
原文:
"光光每天放学后都会教东东踢足球。光光耐心地教东东如何控球、传球和射门。"
不重叠切割(chunkSize=20):
Chunk 1: "光光每天放学后都会教东东踢足球。"
Chunk 2: "光光耐心地教东东如何控球、传球和射门。"
→ 两个问题 Chunk 都以"光光"开头,但可以接受
不重叠切割(更长的文本,切断在句子中间):
Chunk 1: "...光光看出了东东的担忧,他拍着东东的肩膀说:"
Chunk 2: ""没关系,我们一起练习,我相信你一定能行的!""
→ ❌ Chunk 1 以冒号结尾,Chunk 2 是对话内容,语义被切断
重叠切割(chunkOverlap):
Chunk 1: "...光光看出了东东的担忧,他拍着东东的肩膀说:"没关系,我们一起"
Chunk 2: "他拍着东东的肩膀说:"没关系,我们一起练习,我相信你一定能行的!""
→ ✅ Chunk 2 包含了 Chunk 1 结尾的上下文,语义连续
chunkOverlap 的本质是用存储空间的冗余来换取语义检索的完整性。
五、完整代码:URL → Chunk 一条龙
5.1 项目初始化
bash
mkdir web-rag-loader && cd web-rag-loader
npm init -y
npm install @langchain/community @langchain/textsplitters @langchain/openai dotenv cheerio axios
5.2 核心代码(index.mjs)
javascript
import 'dotenv/config';
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from '@langchain/community/vectorstores/memory';
// ========== 配置 ==========
const TARGET_URL = 'https://juejin.cn/post/7660707431753678854';
// ========== Step 1: 加载网页(Loader)==========
console.log('📡 正在爬取网页...');
const cheerioLoader = new CheerioWebBaseLoader(TARGET_URL, {
selector: '.main-area p'
});
const documents = await cheerioLoader.load();
console.log(`✅ 加载完成,共 ${documents.length} 个原始 Document`);
// ========== Step 2: 切割文档(Text Splitter)==========
console.log('\n✂️ 正在切割文档...');
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
separators: ["\n\n", "。", "!", "?", "\n"],
chunkOverlap: 100,
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(`✅ 切割完成,共 ${splitDocuments.length} 个 Chunk`);
// 展示前 3 个 Chunk
console.log('\n📋 前 3 个 Chunk 预览:');
splitDocuments.slice(0, 3).forEach((doc, i) => {
console.log(`\n--- Chunk ${i + 1} ---`);
console.log(`内容: ${doc.pageContent.substring(0, 80)}...`);
console.log(`来源: ${doc.metadata.source}`);
});
// ========== Step 3: 向量化存储(Embedding + VectorStore)==========
console.log('\n🔢 正在向量化...');
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME || 'text-embedding-v3',
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
}
});
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings
);
console.log('✅ 向量化完成,已存入内存');
// ========== Step 4: 检索测试 ==========
console.log('\n🔍 检索测试:');
const retriever = vectorStore.asRetriever({ k: 3 });
const question = '光光和东东是怎么认识的';
const relevantDocs = await retriever.invoke(question);
console.log(`\n问题:${question}`);
console.log(`\n检索到 ${relevantDocs.length} 个相关 Chunk:\n`);
relevantDocs.forEach((doc, i) => {
console.log(`[${i + 1}] ${doc.pageContent.substring(0, 100)}...`);
});
5.3 运行效果
bash
node index.mjs
erlang
📡 正在爬取网页...
✅ 加载完成,共 1 个原始 Document
✂️ 正在切割文档...
✅ 切割完成,共 12 个 Chunk
📋 前 3 个 Chunk 预览:
--- Chunk 1 ---
内容: 光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容...
来源: https://juejin.cn/post/7660707431753678854
--- Chunk 2 ---
内容: 东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画...
来源: https://juejin.cn/post/7660707431753678854
--- Chunk 3 ---
内容: 有一天,学校要举办一场足球比赛,光光非常兴奋,他邀请东东一起参加...
来源: https://juejin.cn/post/7660707431753678854
🔢 正在向量化...
✅ 向量化完成,已存入内存
🔍 检索测试:
问题:光光和东东是怎么认识的
检索到 3 个相关 Chunk:
[1] 东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画...
[2] 从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动...
[3] 比赛那天终于到了,光光和东东一起站在球场上。虽然东东的技术...
六、Loader + Splitter 组合速查表
scss
┌─────────────────────────────────────────────────────────────┐
│ 不同场景下的 Loader + Splitter 组合 │
├──────────────────┬──────────────────┬───────────────────────┤
│ 数据来源 │ Loader │ Splitter │
├──────────────────┼──────────────────┼───────────────────────┤
│ 网页 URL │ CheerioWebBase │ RecursiveCharacter │
│ │ Loader │ TextSplitter │
├──────────────────┼──────────────────┼───────────────────────┤
│ PDF 文件 │ PDFLoader │ RecursiveCharacter │
│ │ │ TextSplitter │
├──────────────────┼──────────────────┼───────────────────────┤
│ Markdown 文件 │ TextLoader │ MarkdownTextSplitter │
│ │ │ (按标题层级切割) │
├──────────────────┼──────────────────┼───────────────────────┤
│ 代码文件 │ TextLoader │ CodeTextSplitter │
│ │ │ (按函数/类切割) │
├──────────────────┼──────────────────┼───────────────────────┤
│ CSV 表格 │ CSVLoader │ 通常不需要切割 │
│ │ (每行一个Document) │ (每行已是一个单元) │
└──────────────────┴──────────────────┴───────────────────────┘
七、从手写到框架:什么时候用什么?
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 学习理解原理 | axios + cheerio 手写 | 理解底层 HTTP 请求和 HTML 解析 |
| 快速原型 | CheerioWebBaseLoader | 一行代码拿到 Document,与 LangChain 生态无缝衔接 |
| 生产环境 | CheerioWebBaseLoader + 错误重试 + 代理池 | 需要处理反爬、超时、IP 封禁等 |
| JavaScript 渲染的网页 | Puppeteer / Playwright | cheerio 只能解析静态 HTML,无法执行 JS |
八、知识图谱
scss
从 URL 到 Chunk 知识体系
│
├── 数据采集(Loader)
│ ├── 手写方案
│ │ ├── axios → HTTP 请求获取 HTML
│ │ ├── cheerio.load(html) → 内存 DOM 树
│ │ └── $(selector).text() → CSS 选择器提取
│ │
│ └── LangChain 方案
│ ├── CheerioWebBaseLoader
│ ├── 配置 selector 精准提取
│ └── 返回标准 Document 对象
│
├── 文档切割(Text Splitter)
│ ├── 为什么要切割
│ │ ├── 避免超出 Embedding token 限制
│ │ └── 提高检索粒度
│ │
│ └── RecursiveCharacterTextSplitter
│ ├── chunkSize:目标大小
│ ├── separators:语义分隔符(按优先级尝试)
│ │ └── 中文推荐:["\n\n", "。", "!", "?", "\n"]
│ └── chunkOverlap:重叠字符数(用冗余换语义连续)
│
├── 完整链路
│ URL → Loader → Document → TextSplitter → [Chunk]
│ │
│ ▼
│ Embedding → VectorStore → Retriever
│
└── 注意事项
├── 手写爬虫要加 User-Agent 和错误处理
├── CheerioWebBaseLoader 不需要单独 import cheerio
├── separators 不要用逗号作为优先分隔符
├── chunkOverlap 建议为 chunkSize 的 20%~30%
└── JS 渲染的网页需要用 Puppeteer/Playwright
总结
本文从"手写爬虫"到"LangChain 框架",完整走通了 RAG 知识库构建的第一步:
- 网页爬取:axios 发请求 + cheerio CSS 选择器提取正文,是理解原理的最佳方式
- LangChain Loader :
CheerioWebBaseLoader一行代码搞定,返回标准 Document,与后续 RAG 链路无缝衔接 - 文档切割 :
RecursiveCharacterTextSplitter通过递归尝试不同分隔符,在"语义完整"和"大小可控"之间取得平衡 - 三个核心参数 :
chunkSize(大小)、separators(语义分隔符)、chunkOverlap(重叠冗余) - chunkOverlap 的本质:用存储空间的冗余来换取语义检索的连续性
当你能把任意网页 URL 变成一堆语义完整的 Chunk,再接入上一篇文章的 Embedding + VectorStore + Retriever,一个完整的 RAG 知识库就诞生了。从个人博客知识库到企业文档问答系统,这套流程是通用的基础骨架。