Document 切割:RAG 知识库数据预处理实战

文章目录

一、知识从哪来?

对于一个 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 官方维护,核心功能稳定

面对一个知识库项目,你要做的只有两件事:

  1. 选择合适的 Loader(根据文件类型选)
  2. 对输出的 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 ph1.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 的重叠字符数,防止跨切割边界的语义断裂
递归切割 逐级尝试更细粒度的分隔符,找到最优切割点

十一、常见问题 / 避坑指南

  1. import 路径报错 ERR_PACKAGE_PATH_NOT_EXPORTED

问题 :引入 CheerioWebBaseLoader 时写成:

js 复制代码
// ❌ 错误路径(缺少 /web/)
import { CheerioWebBaseLoader }
  from '@langchain/community/document_loaders/cheerio';

@langchain/communitypackage.jsonexports 字段只声明了 ./document_loaders/web/cheerio 路径,直接写 cheerio 会报错。

解决

js 复制代码
// ✅ 正确路径(注意多了 /web/)
import { CheerioWebBaseLoader }
  from '@langchain/community/document_loaders/web/cheerio';

排错技巧 :安装 @langchain/community 后,可以到 node_modules/@langchain/community/package.jsonexports 字段中搜 cheerio 确认正确的导入路径。

  1. RecursiveCharacterTextSplitter 导入报错

问题 :写成了 RecursiveC(不完整)或直接拼错名字。

js 复制代码
// ❌ 不完整的命名,模块中不存在此导出
import { RecursiveC } from '@langchain/textsplitters';

解决

js 复制代码
// ✅ 完整类名
import { RecursiveCharacterTextSplitter }
  from '@langchain/textsplitters';

同时确保已安装依赖:

bash 复制代码
pnpm i @langchain/textsplitters
  1. 爬虫拿不到内容

常见原因和解决方法

原因 排查方法 解决方案
CSS 选择器不匹配 F12 打开浏览器开发者工具,检查实际 DOM 结构 根据实际 HTML 结构调整选择器
目标网站有反爬机制 观察返回内容是否包含验证码/重定向 添加 User-Agent 等请求头伪装浏览器
页面动态渲染(SPA) 查看 HTML 源码中是否没有正文内容 cheerio 只能解析静态 HTML,SPA 页面需要用 Puppeteer/Playwright
  1. chunkOverlap 设多大合适?

经验值chunkOverlap 一般设为 chunkSize10% ~ 20% 比较合理。

chunkSize 推荐 overlap 说明
400 40 ~ 80 中文场景,约 2-3 句话
1000 100 ~ 200 英文或中英混合场景
2000 200 ~ 400 长文档场景

太小了纠不住语义断裂,太大了重复太多浪费存储空间和向量计算成本。

  1. .mjs 文件执行没有输出也不报错

如果你写的 .mjs 文件运行后什么都没输出(也不报错),逐项检查:

  1. import 路径是否正确(见避坑 1、2)------路径错误时 Node.js 会在模块解析阶段报错
  2. 是否有顶层 await ------Node.js ≥ 14.8 在 .mjs 中支持顶层 await,确认版本
  3. 检查空 catch :有没有 catch(err){} 把错误偷偷吞掉?加上 console.error(err) 试试
  4. package.json"type": "commonjs" 不影响 .mjs ------.mjs 文件强制按 ESM 处理,但项目混用 CJS 和 ESM 时要注意兼容性

本文是 RAG 核心技术系列的第二篇(实战篇),基于 LangChain.js 生态(@langchain/community + @langchain/textsplitters),所有代码均可在 Node.js 18+ 环境中直接运行。

相关推荐
小粉粉hhh1 小时前
React(三)——Redux
前端·javascript·react.js
今天AI了吗2 小时前
Hermes Agent 搭建全流程:从本机试跑到可持续运行的个人 AI Agent
java·人工智能·python·学习·embedding
多加点辣也没关系2 小时前
JavaScript|第24章:事件循环与并发模型
开发语言·javascript·ecmascript
BIM云平台开发3 小时前
【App.vue里跟踪页面跳转和用户ID】
开发语言·前端·javascript
小二·4 小时前
多模态RAG实战:用LangChain+Milvus打造支持图文音视频的企业级知识库
langchain·音视频·milvus
云空4 小时前
《Three.js 完整版3D魔方:带贴纸+中心固定+自动求解复原》
前端·javascript·3d·three.js
@atweiwei4 小时前
Langchainrust:中LLM-as-a-Judge,用 Rust 实现一套 LLM 评估系统
开发语言·后端·ai·rust·llm·rag
gis开发之家5 小时前
《Vue3 从入门到大神33篇》Vue3 源码详解(三):响应式核心思想——从 Object.defineProperty 到 Proxy
前端·javascript·vue.js·vue3·vue3源码
云空5 小时前
《Three.js 可操控3D魔方(键盘转动+打乱+复原)》
javascript·学习·游戏·3d·three.js