文章目录
- 一、知识从哪来?
- 二、Document:知识的最小标准单元
- 三、Loader:知识接入的第一步
- 四、爬虫实战:从零实现网页内容抓取
- [五、LangChain 方案:CheerioWebBaseLoader](#五、LangChain 方案:CheerioWebBaseLoader)
- 六、文本切割:让知识可以被精细检索
-
- [6.1 为什么要切割?](#6.1 为什么要切割?)
- [6.2 三个核心参数详解](#6.2 三个核心参数详解)
- [七、AI 时代程序员的价值](#七、AI 时代程序员的价值)
- [八、完整实战:从 URL 到可检索的知识块](#八、完整实战:从 URL 到可检索的知识块)
- 九、全文总结
- 十、核心知识点复盘
- [十一、常见问题 / 避坑指南](#十一、常见问题 / 避坑指南)
一、知识从哪来?
对于一个 RAG(检索增强生成)知识库系统,知识可以来自各种各样的地方:
- 一篇 CSDN / 掘金 / 知乎上的技术文章(URL)
- 一个 Word 文档(.docx)
- 一个 PDF 文件(.pdf)
- 一段 B 站视频的字幕
- 甚至一条靠谱的 Twitter 推文

这些不同格式的文件,最终都需要变成向量数据库能处理的标准化格式------这就是本文要解决的问题。
我们会从零开始,先手写爬虫理解底层原理,再引入 LangChain 的 Loader + TextSplitter 方案,逐层递进地把整个 Document 切割流程讲透。
二、Document:知识的最小标准单元
LangChain 定义了一个标准的数据结构叫 Document,它是所有知识在 RAG 系统中的统一载体:
js
// Document 对象结构(示意)
{
pageContent: "这篇文章主要介绍了蒸馏技术的原理...", // 文档正文内容
metadata: { // 元数据
source: "https://example.com/article",
title: "蒸馏技术详解",
author: "张三"
}
}
一个 Document 包含两个核心字段:
| 字段 | 含义 | 用途 |
|---|---|---|
| pageContent | 文本的实际内容 | 后续会被向量化(Embedding),用于语义检索 |
| metadata | 元信息(来源、作者、时间等) | 用于追溯原文、按条件过滤("只看张三写的文章") |
关键点 :我们不能直接手动创建 Document 对象 ,而是需要通过 Loader(加载器)将各种格式的文件自动转换。这保证了 Document 的结构统一和数据完整性。
三、Loader:知识接入的第一步
Loader 做的事情很简单:输入是各种文件,输出是标准化 Document 数组。
LangChain 生态提供了 180 多种 Loader,来自两个主要来源:
| 来源 | 包名 | 特点 |
|---|---|---|
| 社区 | @langchain/community |
社区贡献,覆盖面广,我们也可以贡献 loader |
| 官方 | @langchain/core |
官方维护,核心功能稳定 |
面对一个知识库项目,你要做的只有两件事:
- 选择合适的 Loader(根据文件类型选)
- 对输出的 Document 进行分块 (文件太大,需要切成有语义的小块才能做向量检索)

下面我们先从最基础的手写爬虫开始,理解 Loader 底层到底做了什么。
四、爬虫实战:从零实现网页内容抓取
在引入 LangChain 之前,我们先手写一个爬虫,理解底层原理。搞懂了底层,再用 Loader 就会明白它帮你省了多少事。
目标:从指定 URL 爬取网页,用 CSS 选择器提取需要的文本。
技术选型:
| 工具 | 作用 |
|---|---|
| axios | 发送 HTTP 请求,拿到 HTML 字符串 |
| cheerio | 在内存中把 HTML 字符串解析为 DOM 树,支持 CSS 选择器------让前端开发者用熟悉的 jQuery 风格提取内容 |
js
// crawl.mjs ------ 手写爬虫实现
import axios from 'axios'; // 发送 HTTP 请求
import * as cheerio from 'cheerio'; // 服务端 DOM 解析
const targetUrl = 'https://leetcode.cn/problems/spiral-matrix/';
async function crawlPage() {
try {
// 第一步:发送 GET 请求,获取 HTML 字符串
const { data: html } = await axios.get(targetUrl);
console.log(html); // 原始 HTML 字符串(可以在控制台看到完整的网页源码)
// 第二步:cheerio.load() 在内存中把 HTML 字符串虚拟化为一个 DOM 对象
// 这是 cheerio 的核心能力:让 JS 开发者用前端思维操作后端 HTML
const $ = cheerio.load(html);
// 第三步:用 CSS 选择器提取指定区域的内容
// 就像在浏览器里写 $('.main-area p').text() 一样自然
const pageContent = $('.main-area p').text();
console.log(pageContent);
} catch (e) {
console.error('爬取失败:', e.message);
}
}
crawlPage();
代码执行流程解析:
axios.get(url) → 拿到 HTML 字符串(整个网页的源码)
↓
cheerio.load(html) → 在内存中构建 DOM 树(类似浏览器解析 HTML)
↓
$('css selector').text() → CSS 选择器遍历 DOM 树,精准提取文本
cheerio 为什么好用? 因为它让后端开发者用前端思维 解决问题。你不用写复杂的正则表达式去匹配 HTML 标签,直接用熟悉的 CSS 选择器语法(
#content、.article p、h1.title等),就能精确提取页面中的任意内容。这是"会用 jQuery 就会写爬虫"的开发体验。
爬虫手写版的局限
手写爬虫虽然灵活,但也有一些重复性工作:
- 每次都要处理 axios 请求、异常捕获
- 每次都要手动 cheerio.load()
- 输出的只是字符串,不是标准化的 Document 对象
- 没有 metadata(来源 URL、抓取时间等元信息丢失了)
LangChain 的 Loader 就是帮你把这些重复工作封装好了。
五、LangChain 方案:CheerioWebBaseLoader
手写爬虫理解原理后,我们升级到专业方案。LangChain 社区封装了 CheerioWebBaseLoader,一行配置就能完成爬取 + 转换为标准 Document:
js
// index.mjs ------ 使用 LangChain Loader + TextSplitter
import 'dotenv/config';
import { CheerioWebBaseLoader }
from '@langchain/community/document_loaders/web/cheerio';
import { RecursiveCharacterTextSplitter }
from '@langchain/textsplitters';
// 第一步:用 Loader 爬取网页,自动转换为 Document
const cheerioLoader = new CheerioWebBaseLoader(
// 目标 URL
'https://blog.csdn.net/xxx/article/details/162911131',
{
selector: '#content_views p' // CSS 选择器,只提取文章正文段落
}
);
// load() 返回 Document[],每个元素的 pageContent 是段落文本,metadata 自动填充
const documents = await cheerioLoader.load();
console.log('加载到的文档数量:', documents.length);
console.log('第一篇文档内容:', documents[0].pageContent.slice(0, 200));
console.log('来源:', documents[0].metadata.source);
CheerioWebBaseLoader 帮我们做了什么?
它将前面的 axios + cheerio + Document 构造三步合而为一:
URL → axios 请求 HTML → cheerio 解析 + CSS 选择器提取 → 构造标准 Document 对象
我们拿到手的直接就是带 pageContent + metadata 的标准 Document 数组,可以直接进入下一步------切块。
六、文本切割:让知识可以被精细检索
6.1 为什么要切割?
一整篇文章(比如 5000 字的技术博客)直接塞进向量数据库,检索效果会很差。比如你问"什么是蒸馏?",系统可能把整篇文章都返回给你,里面夹杂了大量无关段落。
切割的核心矛盾:
| 块太大 | 语义太宽泛,检索不准,返回一堆无关内容 |
|---|---|
| 块太小 | 语义被切断,信息不完整,关键上下文丢失 |
| 目标 | 每个块都是一个语义完整的最小单元 |
LangChain 提供了 RecursiveCharacterTextSplitter(递归字符切割器)来解决这个问题:
js
// 第二步:将大文档切割成语义完整的小块(Chunk)
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个 chunk 最多 400 个字符
separators: ['。', '!', '?'], // 优先在句号、感叹号、问号处切割
chunkOverlap: 100, // 相邻 chunk 重叠 100 个字符
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log('切割后的文档块数量:', splitDocuments.length);
// 查看前两个 chunk 的内容和重叠区域
splitDocuments.slice(0, 2).forEach((doc, i) => {
console.log(`\n--- Chunk ${i + 1} (${doc.pageContent.length}字) ---`);
console.log(doc.pageContent.slice(-150)); // 看末尾 150 字符
});
6.2 三个核心参数详解
(1)separators(分隔符):语义排第一
js
separators: ['。', '!', '?']
切割的第一优先级是语义 ,而不是大小。分隔符告诉 TextSplitter:"优先在句号 。、感叹号 !、问号 ? 这些自然断句点切分"。
为什么是这三个标点?因为它们标记了一个完整语义的结束。每一句话以它们结尾时,表达了一个完整的、可独立理解的意思。而逗号 , 只表示语气停顿,语义还没讲完,不适合作为切割点。
(2)chunkSize(块大小):硬性上限
js
chunkSize: 400
当一段文本按分隔符切完后,如果仍然超过 400 字符,TextSplitter 会递归尝试更细粒度的分隔符 (比如从 。 降级到 , 再到空格),直到每个块都不超过 400 字符。
这个过程叫"递归",因为:
尝试按 '。' 切割 → 还是太大?
→ 尝试按 '!' 切割 → 还是太大?
→ 尝试按 '?' 切割 → 还是太大?
→ 尝试按 ',' 切割 → 还是太大?
→ 尝试按空格切割 → 还是太大?
→ 硬切到 400 字符
递归的核心价值是逐级找到最优的切割方案:能在句号处切就绝不切逗号,能在逗号处切就绝不切空格,实在不行才硬切。
(3)chunkOverlap(重叠):弥补语义断裂
js
chunkOverlap: 100
这是最容易被忽视但最重要的参数。考虑下面这个被切割的场景:
Chunk 1: ...光光每天训练足球,他非常努力,希望参加比赛。
Chunk 2: 希望参加比赛。比赛那天,东东帮助他获得胜利。
Chunk 1 的最后一句话和 Chunk 2 的第一句话,语义相关性是最强的------它们属于同一个故事上下文。
但因为 chunkSize=400 的限制,它们被硬生生切开了。如果检索时只命中了 Chunk 2,你就丢失了"光光努力训练"的前置信息。
chunkOverlap 的解决方案:相邻两个 chunk 之间保留 100 个字符的重叠内容。
┌─────────────────────────────┐
│ Chunk 1 (400字) │
│ ...他非常努力,希望参加比赛。│
└─────────────────────────────┘
│←── 100字 overlap ──→│
┌─────────────────────────────┐
│ Chunk 2 (400字) │
│ 希望参加比赛。比赛那天... │
└─────────────────────────────┘
这样"希望参加比赛"这句话会同时出现在 Chunk 1 的末尾和 Chunk 2 的开头,确保跨越切割边界的上下文不会丢失。
一句话总结切割策略:语义排第一(separators),大小做约束(chunkSize),重叠来兜底(chunkOverlap)。
七、AI 时代程序员的价值
你可能会有一个疑问:上面这些代码 AI 都能写,那程序员还有什么价值?
价值不再是"coding",而是"驾驭 AI 构建产品"。
在 AI 时代,以下几个方面变得比写代码本身更重要:
| 能力维度 | 含义 |
|---|---|
| 提出好问题 | 向 AI 描述清楚需求,给出精确的上下文(Context),而不是模糊一句"帮我写个功能"。问题质量决定 AI 产出质量 |
| 驾驭 Agent | 设计 Agent 产品架构,串联多个工具和模型,部署(Deploy)稳定运行的 AI 应用 |
| 设计 Loop | Agent 的本质是一个长期运行的自主循环(Loop),设计这个循环让它稳定不出错,是架构师的核心能力 |
| 快速迭代 | 把精力花在"定义什么是正确的事"而非"一行行写代码",用 AI 加速从想法到产品 |
你可以快速成长为一名 AI 架构师------不一定是代码写得最多的那个人,但一定是最懂"怎么让 AI 干好活"的那个人。
八、完整实战:从 URL 到可检索的知识块
把整个流程串联起来,一个完整的 RAG 数据预处理流水线:
js
import 'dotenv/config';
import "cheerio";
// ============================================================
// 第一步:引入 Loader --- 负责从 URL 爬取网页,转为标准 Document
// ============================================================
import {
// CheerioWebBaseLoader:内部封装了 axios + cheerio
// 发送 HTTP 请求拿到 HTML → 用 CSS 选择器提取内容 → 构造 Document 对象
CheerioWebBaseLoader
} from '@langchain/community/document_loaders/web/cheerio';
// ============================================================
// 第二步:引入 TextSplitter --- 负责把大文档切成语义完整的小块
// ============================================================
import {
// RecursiveCharacterTextSplitter:递归字符切割器
// 会逐级尝试不同的分隔符(。→ !→ ?→ ,→ 空格),找到最优切割点
RecursiveCharacterTextSplitter
} from '@langchain/textsplitters'
// ----------------------------------------------------------
// 阶段一:加载 --- 从目标 URL 爬取并提取文章正文
// ----------------------------------------------------------
// selector 使用 CSS 选择器 '#content_views p',精准提取 CSDN 文章正文的每个段落
const cheerioLoader = new CheerioWebBaseLoader(
'https://blog.csdn.net/suger__salt/article/details/162911131?fromshare=blogdetail&sharetype=blogdetail&sharerId=162911131&sharerefer=PC&sharesource=suger__salt&sharefrom=from_link',
{
selector:'#content_views p'
}
);
// load() 发起请求并返回标准 Document 数组,每个元素包含 pageContent + metadata
const documents = await cheerioLoader.load();
// ----------------------------------------------------------
// 阶段二:切割 --- 把大文档递归切成语义完整的小块(Chunk)
// ----------------------------------------------------------
// 切割策略:
// 1. 语义排第一 --- 优先在句号/感叹号/问号处断句,保证每个 chunk 表达完整意思
// 2. 大小做约束 --- 每个 chunk 不超过 chunkSize 个字符
// 3. 重叠来兜底 --- 相邻 chunk 有 overlap 个字符的重复,防止关键上下文被切断
//
// 为什么不用逗号作为分隔符?
// 逗号只表示语气停顿,语义还没讲完,不适合作为切割点
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个知识块最多 400 个字符
separators: ['。', '!', '?'], // 优先在句末标点处切割
chunkOverlap: 100, // 相邻块之间保留 100 个字符重叠
})
// 下面用两段示意文本,直观展示 chunkOverlap 的作用:
//
// Chunk 1: ...光光每天训练足球,他非常努力,希望参加比赛。
// ↑───────────────── 重叠区域 ─────────────────↑
// Chunk 2: 希望参加比赛。比赛那天,东东帮助他获得胜利。
//
// "希望参加比赛。" 同时出现在两个 chunk 中,避免了因切割导致的语义断裂。
const splitDocuments =
await textSplitter.splitDocuments(documents);
// 输出切割后的所有文档块,可以看到每个 chunk 的 pageContent 和 metadata
console.log(splitDocuments);
直观看到上述代码的执行结果:
- 如果不做切割:
- 切割之后(上面代码执行):
完整数据流向图:
┌──────────────┐
各种来源 │ Loader │ 标准化
──────────────────►│ │────────────►
URL / PDF / Word │ 180+ 种加载器│ Document[]
└──────────────┘
│
▼
┌──────────────┐
│ TextSplitter│ 切块
│ │────────────►
│ 递归分块策略 │ Chunk[]
└──────────────┘
│
▼
┌──────────────┐
│ 向量数据库 │ 存储+检索
│ │────────────►
│ Embedding │ 知识库就绪
└──────────────┘
九、全文总结
本文从零到一完成了 RAG 知识库的 Document 切割全流程:
底层原理:手写 axios + cheerio 爬虫,理解 HTML 字符串 → DOM 树 → CSS 选择器提取的完整链路。
工程化方案:使用 LangChain 的 CheerioWebBaseLoader,一行配置完成 URL → 标准 Document 的转换。
核心技巧:RecursiveCharacterTextSplitter 的三大参数------separators 语义优先、chunkSize 硬性约束、chunkOverlap 弥补语义断裂。
AI 时代定位:代码能力不再是唯一竞争力,驾驭 AI 构建产品的"架构思维"才是核心价值。
十、核心知识点复盘
| 知识点 | 关键内容 |
|---|---|
| Document 结构 | { pageContent, metadata },RAG 系统中知识的标准化载体 |
| Loader 作用 | 输入任意格式文件 → 输出标准化 Document 数组 |
| cheeiro | 服务端 DOM 解析库,让后端开发者用前端 CSS 选择器思维操作 HTML |
| CheerioWebBaseLoader | 封装 axios + cheerio + Document 构造,URL → Document 一步完成 |
| RecursiveCharacterTextSplitter | 递归尝试不同分隔符,找到每个 chunk 的最优切割方案 |
| chunkSize | 每个知识块的最大字符数,硬性大小上限 |
| separators | 优先切割点列表,语义排第一------能在句号切就绝不切逗号 |
| chunkOverlap | 相邻 chunk 的重叠字符数,防止跨切割边界的语义断裂 |
| 递归切割 | 逐级尝试更细粒度的分隔符,找到最优切割点 |
十一、常见问题 / 避坑指南
- import 路径报错
ERR_PACKAGE_PATH_NOT_EXPORTED
问题 :引入 CheerioWebBaseLoader 时写成:
js
// ❌ 错误路径(缺少 /web/)
import { CheerioWebBaseLoader }
from '@langchain/community/document_loaders/cheerio';
@langchain/community 的 package.json 中 exports 字段只声明了 ./document_loaders/web/cheerio 路径,直接写 cheerio 会报错。
解决:
js
// ✅ 正确路径(注意多了 /web/)
import { CheerioWebBaseLoader }
from '@langchain/community/document_loaders/web/cheerio';
排错技巧 :安装
@langchain/community后,可以到node_modules/@langchain/community/package.json的exports字段中搜cheerio确认正确的导入路径。
RecursiveCharacterTextSplitter导入报错
问题 :写成了 RecursiveC(不完整)或直接拼错名字。
js
// ❌ 不完整的命名,模块中不存在此导出
import { RecursiveC } from '@langchain/textsplitters';
解决:
js
// ✅ 完整类名
import { RecursiveCharacterTextSplitter }
from '@langchain/textsplitters';
同时确保已安装依赖:
bash
pnpm i @langchain/textsplitters
- 爬虫拿不到内容
常见原因和解决方法:
| 原因 | 排查方法 | 解决方案 |
|---|---|---|
| CSS 选择器不匹配 | F12 打开浏览器开发者工具,检查实际 DOM 结构 | 根据实际 HTML 结构调整选择器 |
| 目标网站有反爬机制 | 观察返回内容是否包含验证码/重定向 | 添加 User-Agent 等请求头伪装浏览器 |
| 页面动态渲染(SPA) | 查看 HTML 源码中是否没有正文内容 | cheerio 只能解析静态 HTML,SPA 页面需要用 Puppeteer/Playwright |
- chunkOverlap 设多大合适?
经验值 :chunkOverlap 一般设为 chunkSize 的 10% ~ 20% 比较合理。
| chunkSize | 推荐 overlap | 说明 |
|---|---|---|
| 400 | 40 ~ 80 | 中文场景,约 2-3 句话 |
| 1000 | 100 ~ 200 | 英文或中英混合场景 |
| 2000 | 200 ~ 400 | 长文档场景 |
太小了纠不住语义断裂,太大了重复太多浪费存储空间和向量计算成本。
.mjs文件执行没有输出也不报错
如果你写的 .mjs 文件运行后什么都没输出(也不报错),逐项检查:
- import 路径是否正确(见避坑 1、2)------路径错误时 Node.js 会在模块解析阶段报错
- 是否有顶层
await------Node.js ≥ 14.8 在.mjs中支持顶层 await,确认版本 - 检查空 catch :有没有
catch(err){}把错误偷偷吞掉?加上console.error(err)试试 package.json中"type": "commonjs"不影响.mjs------.mjs文件强制按 ESM 处理,但项目混用 CJS 和 ESM 时要注意兼容性
本文是 RAG 核心技术系列的第二篇(实战篇),基于 LangChain.js 生态(@langchain/community + @langchain/textsplitters),所有代码均可在 Node.js 18+ 环境中直接运行。

