文档切割与RAG管线:从网页爬取到语义检索的完整闭环

摘要: 从网页爬取到向量检索,拆解RAG知识库构建链路:cheerio提取网页内容、RecursiveCharacterTextSplitter语义切割、向量存储与相似度检索,理解文档分块背后的设计哲学。


知识库的第一个难题:知识从哪里来

RAG(Retrieval-Augmented Generation)系统的核心前提是"先有知识库,才能增强生成"。这个知识库里存放的不是数据库里的结构化字段,而是散落在各种格式、各种来源中的非结构化文本------一篇掘金文章、一份PDF报告、一段B站视频的字幕、一条推特长文。

LangChain 为这个场景设计了一个统一的抽象层:Document。每个 Document 对象只包含两个核心字段:

  • pageContent:文档的文本内容,这是后续向量化和检索的主体
  • metadata:文档的元信息,比如来源 URL、章节名、作者、创建时间等

不管原始文件是什么格式,最终都要被"装载"成这个标准结构。而完成这个装载工作的,就是 Document Loader

LangChain 社区提供了超过 180 种 Loader,覆盖了你能想到的几乎所有数据源:PDF、Word、Markdown、CSV、Notion、YouTube 字幕、GitHub 仓库、飞书文档......输入是文件,输出是 Document[]

图片插入位置:Loader 生态示意图------展示不同数据源(PDF、URL、视频、数据库)通过各自 Loader 统一转化为 Document 对象的过程


爬虫先行:从 URL 到 HTML 再到结构化文本

在众多 Loader 中,基于 URL 的网页加载器是最常用的一类。它的底层逻辑很朴素:向目标 URL 发送 HTTP 请求,拿到 HTML 字符串,然后从中提取出我们需要的文本内容。

用 axios 发送请求非常简单:

javascript 复制代码
import axios from 'axios';

const targetUrl = 'https://juejin.cn/post/7662617384500690970';
const { data: html } = await axios.get(targetUrl);
// html 是一个完整的 HTML 文档字符串

但问题在于,拿到 HTML 字符串之后怎么办?整个页面充斥着导航栏、侧边栏、评论区、广告------只有文章正文才是我们真正需要的。用正则表达式去匹配特定标签?这在 DOM 结构稍微复杂一点的页面上几乎是一场噩梦。

cheerio 另辟蹊径,它不依赖浏览器环境,而是在 Node.js 内存中把 HTML 字符串解析成一棵完整的 DOM 树,然后让你用熟悉的 CSS 选择器语法去定位和提取内容。对前端开发者来说,这几乎零学习成本。

javascript 复制代码
import * as cheerio from 'cheerio';

async function crawlPage() {
    const { data: html } = await axios.get(targetUrl);
    const $ = cheerio.load(html);           // 内存中构建 DOM 树
    const pageContent = $('.main-area p').text(); // CSS 选择器提取段落
    console.log(pageContent);
}

cheerio.load(html) 在进程内存中构建了一个虚拟的 DOM 树结构。这棵树跟你浏览器里的 document 对象在逻辑上是同构的------有父子关系、有属性节点、有文本节点。$('.main-area p') 本质上是一次树的遍历,cheerio 沿着 DOM 树逐层查找,匹配 .main-area 下的所有 <p> 标签,然后调用 .text() 把它们的文本内容拼接出来。

这种"前端思维"的爬虫方式,让开发者不用去折腾正则表达式的边界情况,也不用担心 HTML 标签嵌套带来的解析异常。你只需要打开浏览器的开发者工具,找到目标内容的 CSS 选择器,复制粘贴到代码里,爬虫就写好了。


文本切割的核心矛盾:大小与语义的权衡

文档加载完成后,我们面对的是一个大块的完整文本。直接把它塞进向量数据库行不行?

从技术上说,当然可以。但检索效果会非常糟糕。原因在于:向量相似度计算是对整个文本块做语义编码,如果文本块太大,里面包含了多个不同主题的段落,它的语义向量就会变成一个"大杂烩"------什么都有,但什么都模糊。你问"MCP 的接入方式有哪些",它可能匹配到一篇包含 MCP 相关内容但同时也混入了大量无关信息的文章,相似度评分完全不可靠。

切割(Splitting) 是 RAG 管线中最容易被低估的环节。它的目标看似简单------把大文档切成小 chunk------但切割策略直接决定了检索的精度和召回率。

LangChain 提供的 RecursiveCharacterTextSplitter 是解决这个问题的核心工具。它的设计思路体现了一种务实的工程哲学:

javascript 复制代码
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,                          // 每个 chunk 的目标大小
    separators: ['。', '!', '?'],          // 语义分隔符的优先级列表
    chunkOverlap: 100,                       // 相邻 chunk 之间的重叠字符数
});

这三个参数构成了一套完整的语义保全策略,每个参数背后都有明确的工程考量。

chunkSize:太小失语义,太大失精度

400 个字符是一个经验值,不是绝对的。它的选择取决于你的具体场景:技术文档通常 300-500 字符比较合适,因为技术概念往往在几个句子内就能表达完整;法律文书可能需要 500-800 字符,因为条款的表述往往环环相扣;而对话记录或推文则可能只需要 100-200 字符。

chunkSize 太小,一个完整的段落被切得七零八落,每个 chunk 都携带不完整的语义,检索时很难匹配到准确结果。chunkSize 太大,回到一开始的问题------语义被稀释,检索精度下降。

图片插入位置:chunkSize 对比示意图------展示同一个段落在不同 chunkSize 下的切割结果,直观体现语义完整性随 chunkSize 变化的规律

separators:语义边界优先于字符数量

这是 RecursiveCharacterTextSplitter 最精妙的设计。它不会简单地按字符数硬切,而是递归地尝试不同优先级的分隔符

你传入的 separators: ['。', '!', '?'] 定义了一个优先级顺序:先尝试按句号切分,如果某个句子加上后超过 chunkSize,就退回到逗号,如果逗号也不行,继续往下退化。这个递归过程会一直持续,直到找到一种能让每个 chunk 都尽量接近 chunkSize 且不破坏语义边界的切割方式。

为什么不把逗号也放进分隔符列表?因为以逗号切分意味着在一个完整句子的中间断开------"MCP 协议支持两种传输方式"和",分别是 stdio 和 HTTP"被分到两个不同的 chunk 里,每个 chunk 都只携带了半句话的语义,检索时就很难被准确命中。句号、感叹号、问号是自然语言的句级边界,以它们作为分隔符,能最大程度保证每个 chunk 都是一个完整的语义单元。

chunkOverlap:用冗余弥补语义断裂

再好的分隔符策略也无法覆盖所有场景。没有标点的古文、菜单列表、纯数据表格------这些文本根本没有可供切割的语义边界。当 RecursiveCharacterTextSplitter 退无可退时,它只能按 chunkSize 硬切,这必然导致某个句子的后半部分被切到下一个 chunk 里。

chunkOverlap 就是为这种"语义遗憾"设计的补偿机制。相邻的两个 chunk 之间保留 100 个字符的重叠区域,确保被硬切分割的句子能在两个 chunk 中都保留一部分上下文。这样,即使用户的查询只匹配到某个 chunk,重叠区域也能让相邻 chunk 的相关信息被连带检索到。

100 个字符约等于 chunkSize 的 25%,这是一个相对保守的冗余比例。如果 overlap 太小,补偿效果不足;如果太大,向量数据库中会存储大量重复内容,浪费存储空间和计算资源。

javascript 复制代码
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(`文档分割完成,共 ${splitDocuments.length} 个 chunk`);

分割完成后,每个 chunk 都是一个独立的 Document 对象,拥有自己的 pageContent 和从原始文档继承的 metadata。它们现在是大小适中、语义完整的独立单元,可以进入下一步------向量化。


向量化:把文字变成数学

分割好的 chunk 还是人类可读的文本。要让机器能"理解"它们之间的语义关系,需要把它们转化为向量------一串固定长度的浮点数。

javascript 复制代码
import { OpenAIEmbeddings } from '@langchain/openai';

const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.OPENAI_API_KEY,
    model: process.env.EMBEDDINGS_MODEL_NAME,
    configuration: {
        baseURL: process.env.OPENAI_BASE_URL
    },
});

Embedding 模型的工作方式可以这样理解:它把一段文本输入到一个训练好的神经网络中,网络输出一个高维向量(通常是 1536 维或更多)。这个向量的神奇之处在于------语义相近的文本,在向量空间中距离也近。"MCP 的接入方式"和"MCP 支持 stdio 和 HTTP 两种传输协议"这两句话,经过 Embedding 后得到的向量,在数学空间中的余弦距离会非常小,而"今天天气不错"的向量则离它们很远。

javascript 复制代码
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';

const vectorStore = await MemoryVectorStore.fromDocuments(
    splitDocuments,
    embeddings
);

MemoryVectorStore 会把所有 chunk 逐个送入 Embedding 模型,生成对应的向量,然后存储在一个内存结构中。它的 fromDocuments 方法内部做了两件事:调用 embeddings.embedDocuments() 批量生成向量,然后将 Document 和向量一一对应地存入向量存储。

图片插入位置:向量空间示意图------展示语义相近的文本在高维向量空间中聚集,而语义无关的文本分散在远处


检索与增强:RAG 的最后一公里

向量库构建完成后,就可以回答用户的问题了。整个流程分为三步:

第一步:用户问题向量化。 把用户的问题用同一个 Embedding 模型转换为向量。

第二步:相似度检索。 在向量空间中计算问题向量与所有 chunk 向量的距离,返回距离最近的 K 个 chunk。

javascript 复制代码
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke("MCP的接入方式有哪些");

asRetriever 返回的是一个标准的 LangChain Retriever 接口,封装了"问题向量化 → 相似度计算 → 返回 Top-K Document"的逻辑。invoke 是 LangChain 中统一的执行入口,不管是 LLM、Retriever 还是 Chain,都通过 invoke 来触发。

如果你想要更细粒度的控制------比如查看每个检索结果的相似度评分------可以用 similaritySearchWithScore

javascript 复制代码
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

scoredResults.forEach(([doc, score], i) => {
    const similarity = (1 - score).toFixed(4);
    console.log(`[文档 ${i + 1}] 相似度: ${similarity}`);
    console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
});

这里的 score 是向量间的距离值(越小越相似),1 - score 将其转换为相似度(越大越相似)。这个评分在工程上非常有用:你可以设置一个阈值,低于某个相似度的检索结果直接丢弃,避免不相关的内容污染 LLM 的上下文。

第三步:增强生成。 把检索到的文档片段拼接到 prompt 中,作为 LLM 回答问题的上下文:

javascript 复制代码
const context = docs
    .map((doc, i) => `[片段${i}]\n ${doc.pageContent}`)
    .join("\n\n-----\n\n");

const prompt = `
你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${context}
问题:${question}
你的回答:`;

const response = await model.invoke(prompt);

这一步的本质是给 LLM 提供了"开卷考试"的条件------它不需要依赖训练数据中可能不存在或已过时的知识,而是直接从你提供的文档中寻找答案。LLM 的强项是语言理解和生成,不是记忆海量事实;RAG 把"记忆"这件事外包给了向量数据库,让 LLM 专注于它最擅长的事。

图片插入位置:RAG 完整流程图------从用户提问 → 问题向量化 → 向量检索 → 上下文拼接 → LLM 生成回答的端到端流程


从切割到检索:一个完整的 RAG 管线

把以上所有环节串联起来,就是一个完整的 RAG 知识库构建与检索管线:

javascript 复制代码
import "dotenv/config";
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';

// 1. 初始化模型
const model = new ChatOpenAI({
    temperature: 0,
    model: process.env.MODEL_NAME,
    apiKey: process.env.OPENAI_API_KEY,
    configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.OPENAI_API_KEY,
    model: process.env.EMBEDDINGS_MODEL_NAME,
    configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

// 2. 加载文档:URL → HTML → 指定选择器 → Document
const cheerioLoader = new CheerioWebBaseLoader(
    'https://juejin.cn/post/7662617384500690970',
    { selector: '.main-area p' }
);
const documents = await cheerioLoader.load();

// 3. 切割文档:大 Document → 语义完整的 chunk
const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,
    separators: ['。', '!', '?'],
    chunkOverlap: 100,
});
const splitDocuments = await textSplitter.splitDocuments(documents);

// 4. 构建向量库:chunk → Embedding → 向量存储
const vectorStore = await MemoryVectorStore.fromDocuments(
    splitDocuments, embeddings
);

// 5. 检索 + 增强生成
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke("MCP的接入方式有哪些");

const context = docs.map((d, i) => `[片段${i}]\n ${d.pageContent}`).join("\n\n");
const prompt = `根据以下内容回答问题:\n${context}\n问题:MCP的接入方式有哪些\n回答:`;
const response = await model.invoke(prompt);

这条管线只有约 50 行代码,但它完成了从"一个 URL"到"基于文章内容的智能问答"的完整链路。每一步都承担着明确的职责,每一环的配置都直接影响最终的检索质量。


切割策略背后的工程权衡

回顾整个文档处理流程,切割是其中最值得品味的一环。它的设计不是简单的"按大小切块",而是一套在语义完整性和计算效率之间寻找平衡点的策略:

  • separators 决定了语义的最小单元。句号、感叹号、问号是自然语言中句级的边界,以它们作为分隔符,每个 chunk 至少是一个完整的句子,不会出现断句的尴尬。
  • chunkSize 决定了检索的粒度。400 字符约等于 3-5 个中文句子,足够承载一个完整的技术概念,又不至于因为内容太多而稀释语义向量的精度。
  • chunkOverlap 是语义断裂的保险丝。它用 25% 的冗余换来了语义连续性,让那些被硬切的句子不至于完全丢失上下文。

这三个参数不是孤立的,它们相互制约。增大 chunkSize 意味着每个 chunk 的语义更完整,但检索精度下降;增大 overlap 意味着语义连续性更好,但存储成本上升;调整 separators 的优先级会影响切割的"自然度"------一个不好的分隔符列表会让每个 chunk 读起来都像被剪刀随机剪过。

这种设计哲学在 AI 时代有一个更广泛的隐喻:当 AI 接管了代码编写,人类的竞争力不再是"写出正确的代码",而是"设计出正确的分割策略" 。你不再需要自己写 CSS 选择器的解析逻辑(cheerio 帮你做了),不再需要自己实现文本切割算法(RecursiveCharacterTextSplitter 帮你做了),不再需要自己写向量相似度计算(MemoryVectorStore 帮你做了)。但你需要理解:为什么选择器要选 .main-area p 而不是 body?为什么 chunkSize 是 400 而不是 4000?为什么 separators 里不放逗号?这些问题没有标准答案,只有对场景的深刻理解才能给出合理的判断。

从 vibe coding 到 prompt engineering,从 context 提供到 Agent 驾驭与部署,程序员的角色正在从"代码执行者"转变为"架构设计者"。你不再是一个字一个字地敲代码,而是在更高的抽象层次上思考:数据从哪里来、如何切割、如何存储、如何检索、如何生成。驾驭 AI 的能力,本质上是对问题本质的洞察力。


文档切割看似是一个简单的预处理步骤,但它贯穿了 RAG 系统的整个生命周期:加载(Loader)决定了你能拿到什么数据,切割(Splitter)决定了数据以什么粒度被检索,向量化(Embedding)决定了检索的语义准确性。这三个环节环环相扣,任何一个环节的疏忽都会在最终的问答质量上被放大。当你下次调试一个 RAG 系统发现检索效果不佳时,不妨先回头看看你的切割参数------答案很可能就藏在那 400 个字符和 100 个字符的重叠之间。

相关推荐
浮生望1 小时前
知识蒸馏:大模型教小模型,远不止背答案那么简单
llm
寒水馨1 天前
Windows下载、安装ollama-v0.32.1(附安装包OllamaSetup.exe)
windows·llm·大语言模型·llama·本地部署·ollama·模型运行
leeyi1 天前
流式传输引擎:Eino StreamReader 源码拆解(第61篇-E47)
llm·aigc·agent
AINative软件工程1 天前
LLM 应用的熔断降级工程实践:Circuit Breaker 不只是重试的升级版
后端·llm·ai编程
wangruofeng2 天前
opencodex 解锁 Codex 任意模型,一个本地代理打通 Claude/Kimi/GLM/DeepSeek
llm·github·openai
wangruofeng2 天前
姚顺雨长谈:在 Anthropic 和 Gemini 训练模型,英雄主义已经过时
llm·aigc
赵康2 天前
AI 写代码之后,Code Review 会议怎么开
ai·llm·skill
莫逸风2 天前
【AgentScope 2.0】 0. 学习指南
java·llm·agent·agentscope