从 URL 到 Chunk:网页爬取与语义切割实战,构建 RAG 知识库的第一步

从 URL 到 Chunk:网页爬取与语义切割实战,构建 RAG 知识库的第一步

上一篇文章我们用 LangChain 实现了 RAG 检索增强生成,但那个故事是手写 放进代码里的。现实中,知识库的素材来源五花八门:网页、PDF、Word、视频字幕......本文将带你打通 RAG 的第一步 ------如何把任意网页 URL 抓下来、切成语义完整的 Chunk,最终变成可供检索的 Document。从 axios + cheerio 手写爬虫,到 LangChain 的 CheerioWebBaseLoader + RecursiveCharacterTextSplitter,完整走通知识库构建链路。

前置知识

  • 了解 RAG(检索增强生成)的基本概念
  • 了解 LangChain 的 Document 对象结构
  • 有 Node.js + npm 基础

如果还不熟悉 RAG,建议先阅读:


一、为什么需要网页爬取 + 文档切割?

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,但它做了额外的封装:

  1. 自动处理 HTTP 请求(包括 User-Agent、超时等)
  2. 返回标准 Document 对象pageContent + metadata
  3. 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 知识库构建的第一步:

  1. 网页爬取:axios 发请求 + cheerio CSS 选择器提取正文,是理解原理的最佳方式
  2. LangChain LoaderCheerioWebBaseLoader 一行代码搞定,返回标准 Document,与后续 RAG 链路无缝衔接
  3. 文档切割RecursiveCharacterTextSplitter 通过递归尝试不同分隔符,在"语义完整"和"大小可控"之间取得平衡
  4. 三个核心参数chunkSize(大小)、separators(语义分隔符)、chunkOverlap(重叠冗余)
  5. chunkOverlap 的本质:用存储空间的冗余来换取语义检索的连续性

当你能把任意网页 URL 变成一堆语义完整的 Chunk,再接入上一篇文章的 Embedding + VectorStore + Retriever,一个完整的 RAG 知识库就诞生了。从个人博客知识库到企业文档问答系统,这套流程是通用的基础骨架。


参考资料


相关推荐
zhr_math_king3 小时前
LangGraph-快速入门-第一个例子
python·langchain
BraveWang5 小时前
【LangChain 1.x】07、生产环境模型管理|能力检测、限流、监控与容错
langchain
小折耳猫_5 小时前
智能体评估与测试体系
langchain·智能体·langgraph
lzjava20246 小时前
LangChain Prompt提示词工程
前端·langchain·prompt
拾年2758 小时前
RAG 灵魂三问:你的文档是怎么"喂"给大模型的?
langchain·node.js·llm
陳陈陳8 小时前
LLM的"消化系统":一文讲透RAG文档加载与智能切割
数据库·langchain·llm
CS semi9 小时前
【LangChain篇章】@tool使用(创建+绑定+调用)
langchain
先吃饱再说17 小时前
一篇文章被切成 100 块还怎么保持语义:RAG 分块的底层逻辑
langchain·llm
BraveWang1 天前
【LangChain 1.x】06、Tool Calling|工具的四种定义方式与手动调用全流程
langchain