为什么你的 AI 知识库总是“断章取义”?

在人工智能大行其道的今天,很多程序员都在调侃自己变成了"提示词工程师(Prompt Engineer)"。甚至有一个新词悄然走红------Vibe Coding(氛围感写代码)

简单来说,就是代码让 AI 去写,我们负责"指点江山":提出好问题(Prompt)、提供丰富且准确的上下文(Context)、驾驭(Harness)并部署 Agent(智能体)产品,从而设计出能长时间稳定运行的业务闭环。

但在高呼"AI 代替程序员"之前,我们必须面对一个非常现实的骨感问题:AI 并不是万能的。 如果你直接把一整个 10MB 的 PDF 扔给大模型,或者把一整个乱七八糟的网页直接灌给它,它不仅可能因为"上下文窗口(Context Window)"限制而拒绝回答,更可能在密密麻麻的无关信息中迷失自我,给你胡言乱语。

这就是为什么在构建 AI 知识库(即 RAG,检索增强生成系统)时,数据清洗与文档切割(Document Chunking) 成为了重中之重。今天,我们就从零开始,聊聊如何利用 Node.js、Cheerio 以及现代 AI 开发框架 LangChain,把杂乱的网页变成 AI 能够轻松吞咽的"标准营养餐"。

一、 核心概念:什么是 AI 眼中的 "Document"?

在动手写代码前,我们先来理清一个最基础的概念。

平时我们接触的文件格式五花八门:Word 格式的 .docx、便携式的 .pdf、网页的 .html,甚至是哔哩哔哩的视频字幕、Twitter(X)上的动态。

对于大模型和向量数据库(Vector Database)来说,它们没办法直接去读取并理解这些格式各异的原始文件。因此,我们需要一个标准的数据桥梁 。在目前主流的 AI 框架 LangChain 中,这个标准格式被称为 Document(文档对象)

一个标准的 Document 对象,结构其实非常简单,主要由两个核心部分组成:

  1. pageContent(文本内容) :存放具体的纯文本信息,去除了所有的 HTML 标签、多余样式和乱码。
  2. metadata(元数据) :存放这个文本的辅助信息。比如:它是从哪个网址爬下来的?作者是谁?创建时间是什么时候?这些信息能帮我们在后期检索时快速过滤和定位。

为了得到这个标准的 Document 格式,我们的数据通常要经历两个阶段的蜕变:

scss 复制代码
[原始文件/网页/视频]  ---> (Loader 加载与解析) ---> [标准 Document] ---> (Splitter 优雅切割) ---> [精准的 Text Chunks (分块)]

下面,我们就一步步来实现这个数据加工厂。

二、 第一阶段:用前端思维野蛮生长 ------ 网页爬虫

要喂给 AI 数据,我们得先有数据。最常见的数据来源就是网页。

我们先不用 LangChain,用最底层的工具来看看一个网页是如何被拿下来并解析的。这里我们要用到两个非常著名的 Node.js 库:

  • Axios:负责向目标网址发送 HTTP 请求,把整个网页的 HTML 代码原封不动地拉下来。
  • Cheerio :这是一个神奇的库。它能在 Node.js(后端环境)中,把拿到的 HTML 字符串解析成一个虚拟的 DOM 树。这样,我们就可以像写前端 CSS 选择器一样,非常优雅地提取网页里我们想要的特定内容,而不需要去写极其痛苦且容易出错的正则表达式。

1. 基础依赖准备

在你的项目根目录下,首先初始化并安装依赖:

Bash

csharp 复制代码
npm init -y
npm install axios cheerio dotenv

2. 爬虫代码实现

我们新建一个 crawl.mjs 文件(使用 ESM 模块化规范),尝试爬取掘金上的一篇技术文章,并提取出它的正文段落。

JavaScript

javascript 复制代码
// crawl.mjs
import axios from 'axios';
import * as cheerio from 'cheerio';

// 我们要爬取的目标网页地址
const targetUrl = 'https://juejin.cn/post/7660707431753678854';

async function crawlPage() {
  try {
    // 1. 使用 axios 发送 GET 请求,获取网页的原始 HTML 字符串
    const { data: html } = await axios.get(targetUrl);
    
    // 2. 将 HTML 字符串加载到 cheerio 中,生成一个虚拟的 DOM 对象(用著名的 $ 符号接收)
    const $ = cheerio.load(html);
    
    // 3. 使用 CSS 选择器定位我们需要的正文内容
    // 比如:掘金文章的正文通常包裹在 class 为 main-area 的容器内的 p 标签中
    const pageContent = $('.main-area p').text();
    
    // 输出提取出来的干净文本
    console.log("--- 提取出的正文内容 ---");
    console.log(pageContent);
  } catch (error) {
    console.error("爬取失败:", error);
  }
}

crawlPage();

原理解析

当我们在浏览器里看网页时,浏览器会把 HTML 渲染成一颗直观的 DOM 树。而 cheerio.load(html) 做的事情,就是在服务器的运行内存里,强行把那一大坨乱糟糟的 HTML 字符串也理成一颗树。

随后,$('.main-area p') 就会像雷达一样在这颗树上搜索,把所有符合条件的 <p> 标签里的文字全部抓出来,自动拼接成一段干净、没有 HTML 杂质的纯文本。

三、 第二阶段:工业化提效 ------ LangChain 标准化加载

上面的野蛮爬虫虽然好用,但在实际的 AI 项目中,我们会遇到几百种不同的文件格式。如果每种格式都要我们自己去写解析、提取、打包成 Document 对象的逻辑,那程序员就真的要累死了。

为此,LangChain 官方和社区提供了超过 180 种 Loader(加载器) 。针对网页,社区维护了专门的 @langchain/community/document_loaders/web/cheerio 模块。

下面,我们引入真正的 LangChain 依赖:

Bash

bash 复制代码
npm install @langchain/core @langchain/community @langchain/textsplitters

让我们看看如何用 LangChain 提供的 CheerioWebBaseLoader 快速实现"一步到位"的标准化加载:

JavaScript

javascript 复制代码
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';

// 初始化加载器,并传入我们关心的 CSS 选择器过滤条件
const cheerioLoader = new CheerioWebBaseLoader(
  'https://juejin.cn/post/7660707431753678854',
  {
    selector: '.main-area p' // 仅提取文章段落,过滤掉导航栏和侧边栏
  }
);

// 一键加载,直接返回标准 Document 数组
const documents = await cheerioLoader.load();
console.log(documents);

运行上述代码,你会在控制台看到如下输出:

JavaScript

bash 复制代码
[
  Document {
    pageContent: '在数字化转型的浪潮中......(此处为具体文章内容)',
    metadata: { source: 'https://juejin.cn/post/7660707431753678854' }
  }
]

瞧!我们不仅拿到了清洗好的文本,LangChain 还自动帮我们在 metadata 中记录了数据的来源网址。这就是标准化的力量。

四、 第三阶段:重头戏 ------ 语义不流失的"优雅切割"

现在,我们手里已经拿到了一个包含整篇文章的超大 Document 对象。

如果直接把这个大家伙喂给大模型做知识检索,会发生什么?

  1. 语义稀释 :大模型就像一个记性不太好的人,你一次性给它讲 5000 字,它可能只记住了开头和结尾,中间最核心的细节反而被它忽略了(学术界称之为 Lost in the Middle 现象)。
  2. 检索精度差:当我们向 AI 提问时,系统会计算问题与文档的相似度。如果文档太大,相似度计算就会变得非常模糊,无法精准定位到具体的某句话。

所以,我们必须把大文档切成一个个小的 Chunk(文本块)

1. 为什么不能"硬切"?

有人可能会说:这简单啊,用 JS 的 substring 或者 slice 方法,每 400 个字符切一刀不就行了?

绝对不行!

假设有这样一段话:

"小明今天非常高兴,因为他拿到了心仪已久的 Offer。但是,如果他不能在规定时间内提交体检报告,这个 Offer 就会被取消。"

如果我们在"但是"后面强行切一刀:

  • Chunk 1: "小明今天非常高兴,因为他拿到了心仪已久的 Offer。但是,"
  • Chunk 2: "如果他不能在规定时间内提交体检报告,这个 Offer 就会被取消。"

当用户提问:"小明拿到 Offer 后有什么隐患吗?"

系统检索时可能会匹配到 Chunk 2 。然而,Chunk 2 里根本没有提到"小明"和"Offer"之间的上下文关联。这就是语义被切断导致的悲剧。

2. 完美的解决方案:RecursiveCharacterTextSplitter 递归切片器

为了解决这个问题,LangChain 提供了非常聪明的 RecursiveCharacterTextSplitter(递归字符文本分割器)。

它的工作逻辑可以用一句话概括:尝试用不同的分隔符去切,直到切出来的块大小刚好小于我们设定的上限,同时用"重叠区域"来当安全网。

它有三个极为关键的参数:

参数名 释义 作用
chunkSize 每个块的最大字符数 控制每个 Chunk 的大小(例如 400 字符),防止块过大导致语义稀释。
separators 分隔符优先级列表 告诉切片器,优先在哪些字符处断句(如 ["\n\n", "\n", "。", "!", "?"])。我们会优先保证一整个段落、一整个句子不被切碎。
chunkOverlap 相邻块的重叠字数 重中之重! 让相邻的两个 Chunk 之间保留一部分重叠的内容(例如 100 字符),用来传递上下文,防止语义硬生生断开。

我们用一张简单的图示来理解 chunkOverlap 的妙处:

css 复制代码
[--- Chunk 1: 包含句子 A, 句子 B, 句子 C ---]
                        [--- Chunk 2: 包含句子 C, 句子 D, 句子 E ---]

如上所示,句子 C 同时存在于 Chunk 1 和 Chunk 2 中。有了这部分重叠,即使句子在边缘被切开,两边的块依然能够保留彼此的上下文线索。

五、 终极实战:完整代码整合

现在,我们将加载器与递归切割器结合在一起,写出一段完整且优雅的数据处理流。

JavaScript

javascript 复制代码
// index.mjs
import "dotenv/config";
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

async function processWebData() {
  // 1. 初始化 Web 加载器
  const cheerioLoader = new CheerioWebBaseLoader(
    'https://juejin.cn/post/7660707431753678854',
    {
      selector: '.main-area p' // 精准锁定文章段落,剔除噪声
    }
  );

  console.log("正在爬取并解析网页中...");
  const documents = await cheerioLoader.load();
  
  // 2. 初始化递归文本分割器
  const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,         // 我们希望每个 Chunk 的大小在 400 字符左右
    separators: ["。", "!", "?"], // 优先按照句末标点符号切割,保证句子语义的完整
    chunkOverlap: 100,      // 相邻 Chunk 之间保留 100 字符的重叠,作为语义过渡的安全网
  });

  console.log("正在对文档进行优雅切片...");
  // 3. 执行切割
  const splitDocuments = await textSplitter.splitDocuments(documents);

  // 4. 打印切割后的成果
  console.log(`\n 切割完成!原始文档共 1 个,切割后生成了 ${splitDocuments.length} 个小 Chunk。`);
  
  // 打印前两个 Chunk 瞧瞧
  splitDocuments.slice(0, 2).forEach((chunk, index) => {
    console.log(`\n--- Chunk [${index + 1}] ---`);
    console.log(`【内容大小】:${chunk.pageContent.length} 字`);
    console.log(`【内容预览】:\n${chunk.pageContent}`);
    console.log(`【元数据】:`, chunk.metadata);
  });
}

processWebData();

运行效果分析

当你运行这段代码,你会发现:

  1. 所有的 Chunk 都非常规整地保持在 400 字以内。
  2. 切割的位置绝不会出现在半个句子的中间,而是非常精准地落在了 或者 的地方。
  3. 如果你仔细对比 Chunk 1 的结尾和 Chunk 2 的开头,会发现它们重叠了一小段文字。这一小段文字,就是确保 AI 在后续进行向量检索(Vector Search)时不会"断章取义"的终极秘诀。

六、 结语

回到我们开头提到的话题。在 AI 时代,写出几行简单的 for 循环或 if-else 代码已经不再是程序员唯一的护城河,因为 AI 可以比我们写得更快、更规范。

但如何去构建一个庞大且高可用的 AI 系统?如何针对特定的业务场景,对乱七八糟的原始数据进行精准的清洗、标准化、以及充满艺术感的"语义切割"?这些需要对数据流动有着深刻理解的架构设计,才是我们作为AI 架构师的核心价值所在。

下次在给你的 AI 知识库投喂数据时,别忘了用上今天学到的 Loader 和 Splitter。优雅地喂养 AI,它才能聪明地回馈你!

相关推荐
东风破_1 小时前
注册了 MCP Resource,模型为什么仍然看不到?
人工智能
正律有为1 小时前
跨境手机壳、印花服饰避坑:游戏手柄无缝涂鸦插画版权自查实操步骤
人工智能
无己心1 小时前
中国医美行业GEO白皮书(2026)
人工智能
_codeOH1 小时前
MCP 协议深度解读:AI 应用的"USB-C"时刻
人工智能·ai编程
河南花仙子科技1 小时前
小游戏开发中常见的BUG类型及成因
人工智能·科技·游戏·bug
禅与计算机程序设计艺术1 小时前
【推荐一款比腾讯WorkBuddy、阿里悟空更好用能力更强的办公自进化 Agent 】开源 DeepThink AI Agent
人工智能
hxhy1 小时前
第6篇:多 Agent 协作 —— 为什么需要多个智能体
人工智能
沫儿笙1 小时前
弧焊机器人氩气省气装置
人工智能·机器人
Luhui Dev1 小时前
用 AI 生成一道几何题配图:三角形四心作图案例
人工智能·数学·agent·luhuidev