RAG 入门实战使用 LangChain 加载网页并完成递归文本分块前言1. 项目要完成什么1.1 为什么网页内容需要预处理1.2 项目的完整执行流程2. 项目初始化与基础知识2.1 初始化项目并安装依赖2.2 Document、Loader 与 Chunk2.3 HTTP、HTML、DOM 和 CSS 选择器2.4 Promise、async 和 await3. 网页抓取模块详解3.1 导入 Axios 和 Cheerio3.2 axios.get() 如何获取 HTML3.3 cheerio.load() 如何提取正文4. LangChain 网页 Loader 详解4.1 为什么还要使用 Loader4.2 创建 CheerioWebBaseLoader 实例4.3 load() 内部完成了什么5. 递归文本分块详解5.1 为什么需要切分 Document5.2 chunkSize、separators 和 chunkOverlap5.3 递归切分的底层过程5.4 splitDocuments() 如何生成小文档6. 整合完整项目代码6.1 完整代码及关键注释6.2 从 URL 到 Chunk 的完整执行过程总结前言RAG 是 Retrieval-Augmented Generation 的缩写中文通常叫作检索增强生成。它的核心思想是大模型回答问题之前先从外部知识库中检索相关内容再结合这些内容生成答案。一套 RAG 系统要想准确检索知识第一步不是调用大模型而是把原始资料处理成适合检索的数据。网页、PDF、Word 等资料的格式各不相同内容也可能非常长。如果直接把整篇文章作为一个整体保存检索粒度会太粗如果随意把文章切开又可能破坏句子的完整语义。本项目从一个网页地址出发完成网页请求、正文提取、Document标准化和递归文本分块最终得到一组可以继续用于 Embedding 和向量存储的小文档。代码不长但包含了 RAG 数据预处理阶段最基础、也最重要的一条链路。1. 项目要完成什么1.1 为什么网页内容需要预处理知识库中的数据可能来自很多地方例如技术博客、产品文档、PDF 文件、Word 文件和视频字幕。不同数据源的读取方式不同但向量化和检索阶段希望接收到统一的数据结构。以网页为例服务器返回的不是干净的文章正文而是一整份 HTML。HTML 中除了正文还可能包含导航栏、按钮、推荐文章、评论区和版权信息。如果把这些内容全部送进知识库无关文字也会参与向量化最终影响检索结果。即使已经提取出正文也不能直接把一篇几千字的文章当作一个检索单元。用户的问题通常只对应文章中的某个局部知识点。整篇文章只有一个向量时局部语义会被其他内容稀释检索命中后还会把大量无关内容一起交给大模型。因此网页进入 RAG 知识库之前需要完成两项处理文档加载请求网页、解析 HTML、提取正文并转换为统一的Document。文档分块把大Document切成多个较小的 Chunk让每个 Chunk 保留相对完整的语义。文档分块的目的不是单纯缩短文本而是在文本长度、语义完整性和检索粒度之间找到平衡。1.2 项目的完整执行流程整个项目可以分成网页抓取、文档标准化和文本分块三个阶段目标 URL - 发送 HTTP 请求 - 获得 HTML 字符串 - Cheerio 解析 DOM - CSS 选择器提取文章段落 - Loader 创建 Document[] - RecursiveCharacterTextSplitter 切分正文 - 得到多个小 Document项目中的两个源码文件承担不同任务文件作用学习重点src/crawl.mjs手动完成网页请求和正文提取理解 Loader 封装前的底层过程src/index.mjs使用 LangChain 加载网页并切分文档理解Document、Loader 和 Splittercrawl.mjs最终得到普通正文字符串index.mjs最终得到多个标准Document。后者可以继续交给 Embedding 模型生成向量再存入向量数据库。RAG 阶段输入输出当前项目是否实现数据加载网页 URLDocument[]已实现文本分块大Document多个小Document已实现Embedding文本块数值向量未实现向量存储向量和元数据可检索知识库未实现检索问答用户问题相关上下文和答案未实现2. 项目初始化与基础知识2.1 初始化项目并安装依赖创建项目目录并生成package.jsonmkdirrag-splittercdrag-splitterpnpminit安装项目使用的依赖# HTTP 请求库用于手动抓取网页pnpmi axios# LangChain 文本切分器和社区 Loaderpnpmi langchain/textsplitters langchain/community# HTML 解析工具和环境变量加载工具pnpmi cheerio dotenv依赖作用在哪个文件中使用axios发送 HTTP 请求并取得响应crawl.mjscheerio将 HTML 解析为 DOM并支持 CSS 选择器查询crawl.mjs同时也是网页 Loader 的依赖langchain/community提供社区维护的数据加载器index.mjslangchain/textsplitters提供文本切分器index.mjsdotenv将.env中的配置加载到process.envindex.mjsaxios与CheerioWebBaseLoader都能参与网页加载但职责不同。crawl.mjs使用 Axios 和 Cheerio 手动实现加载过程目的是看清每一步发生了什么index.mjs使用 Loader 将这些步骤封装起来并直接返回标准文档。当前代码没有读取任何环境变量因此import dotenv/config不会影响网页抓取和文本切分。保留它可以为后续接入需要 API Key 的 Embedding 模型做准备。2.2 Document、Loader 与 ChunkLangChain 使用Document表示标准文档。一个基础Document包含pageContent和metadata两部分{pageContent:真正参与切分和向量化的正文,metadata:{source:文档来源地址,title:文档标题}}pageContent保存知识正文后续的文本切分、Embedding 和相似度检索主要处理这个字段。metadata保存辅助信息例如来源 URL、标题、页码和章节。检索到某个文本块后可以通过元数据追踪它来自哪里。Loader是数据加载器。不同数据源可以使用不同 Loader但它们都会尽量输出Document[]。Document[]表示由多个Document组成的数组即使只加载一个网页返回值仍然是数组。Chunk表示切分后的文本块。Chunk 不是一种新的 LangChain 数据类型每个 Chunk 最终仍会被封装成Document并继承原文档的元数据。概念负责什么本项目中的实现Loader读取外部数据并创建标准文档CheerioWebBaseLoaderDocument保存正文和元数据{ pageContent, metadata }Splitter把大文档切成多个小文档RecursiveCharacterTextSplitterChunk适合独立检索的局部文本切分后每个Document的pageContent2.3 HTTP、HTML、DOM 和 CSS 选择器网页抓取首先涉及 HTTP 通信。程序向目标 URL 发送 GET 请求服务器处理请求后返回响应客户端发送 GET 请求 服务器返回状态码、响应头和响应体响应体中的网页内容通常是 HTML 字符串。例如dividcontent_viewsp第一段正文/pp第二段正文/p/divHTML 字符串只是文本程序还不能直接按照标签关系查询内容。Cheerio 会解析 HTML 标签的父子关系在内存中构建 DOM 树。DOM 是 Document Object Model 的缩写中文叫文档对象模型。它使用树状结构表示网页html是根节点body、div、p等元素是不同层级的子节点。DOM 构建完成后可以使用 CSS 选择器查找节点。项目使用的选择器是#content_views p其中#content_views表示查找idcontent_views的元素空格表示继续查找它的后代节点p表示段落标签。因此这个选择器的完整含义是查找 CSDN 文章正文容器中的所有段落。2.4 Promise、async 和 await网络请求需要等待服务器响应属于异步操作。Axios 不会直接返回最终 HTML而是返回一个Promise。Promise 表示一个未来才会完成的任务它有三种状态状态含义pending任务正在等待结果fulfilled任务成功完成rejected任务执行失败使用async声明的函数一定会返回 Promise。await用于等待 Promise 完成成功时取得结果失败时抛出异常。try...catch用来捕获异常避免程序直接终止。asyncfunctionrequestPage(){try{// await 等待网络请求完成constresponseawaitaxios.get(targetUrl);returnresponse.data;}catch(error){// Promise rejected 后会进入 catchconsole.log(error);}}项目文件使用.mjs后缀表示采用 ES Module 模块系统因此可以使用import。index.mjs还使用了顶层await也就是直接在函数外等待异步方法完成。3. 网页抓取模块详解3.1 导入 Axios 和 Cheeriocrawl.mjs首先导入两个依赖importaxiosfromaxios;import*ascheeriofromcheerio;import axios from axios是默认导入把 Axios 模块的默认导出绑定到变量axios。后续通过axios.get()发起请求。import * as cheerio from cheerio是命名空间导入把 Cheerio 模块的所有命名导出集中到cheerio对象中。后续使用的cheerio.load()就来自这个对象。目标网页地址保存在常量中consttargetUrlhttps://blog.csdn.net/meilindehuzi_a/article/details/162811916;使用常量保存 URL可以避免在多个位置重复书写地址也让请求目标更加清晰。3.2 axios.get() 如何获取 HTML网页请求被放在异步函数crawlPage()中asyncfunctioncrawlPage(){try{const{data:html}awaitaxios.get(targetUrl);console.log(html);}catch(e){}}crawlPage();async function crawlPage()定义了一个异步函数。定义函数时不会执行其中的代码最后的crawlPage()才是真正调用函数、启动请求。axios.get(targetUrl)向目标地址发送 HTTP GET 请求并返回PromiseAxiosResponse。请求成功后响应对象通常包含以下字段字段含义data响应体网页请求中通常是 HTML 字符串statusHTTP 状态码例如成功时常见的200headers服务器返回的响应头config该 Axios 请求使用的配置下面两种写法是等价的// 完整写法先接收响应对象再读取 dataconstresponseawaitaxios.get(targetUrl);consthtmlresponse.data;// 项目写法使用对象解构并把 data 重命名为 htmlconst{data:html}awaitaxios.get(targetUrl);{ data: html }使用了对象解构和变量重命名。冒号左侧的data是响应对象中的属性名右侧的html是新变量名。执行结束后html保存服务器返回的网页源码。console.log(html)将 HTML 打印到终端只用于观察请求结果。console.log()不会修改 HTML也不会返回处理后的正文。try...catch包住网络请求是因为网络断开、地址错误或服务器拒绝访问都可能让 Promise 进入rejected状态。项目中的catch暂时为空因此失败时不会显示错误信息但它仍然会捕获异常防止异常继续向外抛出。3.3 cheerio.load() 如何提取正文取得 HTML 字符串后Cheerio 负责解析和查询// 将 HTML 字符串解析成 DOM并返回查询函数const$cheerio.load(html);// 查找正文容器中的所有 p 标签再读取文本constpageContent$(#content_views p).text();console.log(pageContent);cheerio.load(html)接收 HTML 字符串解析其中的标签、属性和父子关系然后返回一个查询函数。项目把这个函数命名为$。$不是 JavaScript 特殊语法只是一个合法的变量名由于 Cheerio 的使用方式接近 jQuery通常使用$表示查询函数。调用$(#content_views p)后Cheerio 会在 DOM 树中寻找所有符合选择器的节点。返回值是一个 Cheerio 节点集合不是字符串。继续调用.text()Cheerio 会读取所有匹配节点及其后代节点的文本并把结果连接成一个字符串。这个字符串被保存到pageContent中。数据在这一阶段经历了以下变化targetUrlURL 字符串 - axios.get()发送请求 - htmlHTML 字符串 - cheerio.load()DOM 查询函数 - $(selector)节点集合 - .text()正文字符串API输入输出作用axios.get(url)URLPromise发送 GET 请求awaitPromisePromise 成功后的结果等待异步任务完成cheerio.load(html)HTML 字符串查询函数$解析 HTML 并构建 DOM$(selector)CSS 选择器Cheerio 节点集合在 DOM 中查找节点.text()无参数字符串读取节点文本console.log(value)任意值undefined将内容打印到终端4. LangChain 网页 Loader 详解4.1 为什么还要使用 Loader手动使用 Axios 和 Cheerio 可以得到正文字符串但 RAG 后续还需要保存文档来源和标题。LangChain 的 Loader 在网页请求和正文提取之上又增加了一层文档标准化。CheerioWebBaseLoader来自langchain/communityimport{CheerioWebBaseLoader}fromlangchain/community/document_loaders/web/cheerio;花括号表示命名导入即只从模块中导入名为CheerioWebBaseLoader的成员。导入路径中的document_loaders/web/cheerio表示使用专门处理网页和 Cheerio 的 Loader。langchain/community主要存放社区维护的数据源集成Loader 就属于这类组件。它与langchain/core的职责不同core提供 LangChain 的核心抽象community提供连接不同外部数据源的具体实现。4.2 创建 CheerioWebBaseLoader 实例Loader 通过构造函数创建constcheerioLoadernewCheerioWebBaseLoader(https://blog.csdn.net/meilindehuzi_a/article/details/162811916,{selector:#content_views p});CheerioWebBaseLoader是一个类可以理解为创建网页加载器的模板。new会调用类的构造函数并返回一个 Loader 实例实例保存在cheerioLoader中。创建实例时只保存 URL 和配置此时还没有发送网络请求。两个参数的含义如下参数当前值作用webPathCSDN 文章 URL指定要加载的网页selector#content_views p指定从网页中提取哪些节点的文本如果不传selector当前版本默认读取body。读取整个body可能把导航、按钮和推荐内容一起放入pageContent所以项目将范围缩小到正文段落。4.3 load() 内部完成了什么真正发送请求的是load()constdocumentsawaitcheerioLoader.load();load()是 Loader 实例的方法返回PromiseDocument[]。await等待它完成后documents得到文档数组。当前版本的load()内部依次完成以下步骤调用scrape()请求构造函数中保存的网页 URL。获得服务器返回的 HTML 字符串。使用 Cheerio 把 HTML 解析成 DOM。执行$(this.selector).text()提取正文。执行$(title).text()读取网页标题。创建Document把正文写入pageContent。把 URL 和网页标题写入metadata。将Document放进数组并返回。把核心逻辑简化后可以理解为asyncload(){// 请求网页并获得 Cheerio 查询函数const$awaitthis.scrape();// 从 title 标签中读取网页标题consttitle$(title).text();// 使用构造函数中保存的 selector 提取正文constpageContent$(this.selector).text();// 统一封装成 Document 数组return[newDocument({pageContent,metadata:{source:this.webPath,title}})];}假设加载成功documents[0]的结构类似{pageContent:从 CSDN 正文区域提取出的文本……,metadata:{source:https://blog.csdn.net/meilindehuzi_a/article/details/162811916,title:网页 title 标签中的标题}}这一步建立了统一的数据格式。以后即使换成 PDF Loader下游切分器仍然只需要读取pageContent和metadata不需要了解原始文件如何解析。5. 递归文本分块详解5.1 为什么需要切分 Documentload()得到的是完整网页正文。如果文章包含几千个字符直接把它作为一个向量会带来两个问题一个向量需要同时表达文章中的多个主题局部知识不够突出。检索命中后会返回整篇文章占用大模型上下文并混入无关内容。文档切分会把大正文变成多个较小的语义单元。用户提问后检索器可以只返回最相关的几个 Chunk从而提高检索精度。项目使用RecursiveCharacterTextSplitterimport{RecursiveCharacterTextSplitter}fromlangchain/textsplitters;它不是简单地每隔固定字符切一刀而是优先寻找自然语言中的分隔符在尽量保留句子语义的前提下控制文本块大小。5.2 chunkSize、separators 和 chunkOverlap切分器通过构造函数配置consttextSplitternewRecursiveCharacterTextSplitter({chunkSize:400,separators:[。,,],chunkOverlap:100});创建实例时同样不会立即切分文本只会把参数保存到textSplitter中。参数当前值作用chunkSize400控制每个 Chunk 希望接近的最大长度separators。、、指定递归切分时使用的中文语义边界chunkOverlap100让相邻 Chunk 保留一部分重复上下文默认长度函数使用 JavaScript 的text.length所以chunkSize: 400表示 400 个字符串长度单位并不等于 400 个大模型 Token。大部分常用汉字的长度是 1而某些 Emoji 和扩展字符可能占两个 UTF-16 代码单元。separators数组有顺序。切分器优先尝试句号如果当前超长片段仍需继续处理再尝试后面的感叹号和问号。选择句号、感叹号和问号是因为它们通常表示一句话结束逗号只表示句子内部停顿过早按逗号切分更容易破坏语义。chunkOverlap用于缓解边界信息丢失。假设一个定义在 Chunk A 的结尾而它的解释位于 Chunk B 的开头如果两个块完全没有重叠单独检索到其中一个块时上下文可能不完整。加入重叠后Chunk B 会保留 Chunk A 末尾的一部分内容。chunkOverlap必须小于chunkSize。如果重叠长度大于或等于块大小切分器无法正常向前推进构造函数会抛出错误。5.3 递归切分的底层过程递归切分并不是反复把文本平均分成两半而是按照分隔符优先级逐层尝试。基本过程如下从separators第一项开始寻找当前文本中存在的分隔符。使用找到的分隔符把文本拆成多个片段。长度小于chunkSize的片段暂时保存。如果某个片段仍然过长就使用数组中后面的分隔符继续处理。将相邻短片段重新合并使 Chunk 尽量接近chunkSize。组织下一个 Chunk 时保留上一块末尾的部分片段作为重叠上下文。假设文本由四句话组成句子 A。句子 B。句子 C。句子 D。切分器先按句号得到多个句子片段然后依次合并。只要加入下一句后不超过chunkSize就继续放进当前 Chunk如果加入后过长就先输出当前 Chunk再组织下一块。chunkOverlap: 100也不是简单复制上一块最后 100 个字符。切分器以已经切出的片段为单位保留上下文因此实际重叠长度可能小于 100但会尽量接近设置值。当前配置还有一个需要准确理解的边界自定义separators会替换默认分隔符数组而当前数组中没有空字符串。如果出现一个超过 400、内部又没有。的长片段切分器没有更细的分隔符可用这个片段可能超过chunkSize。因此当前代码中的400是目标大小不是任何情况下都不会突破的绝对上限。5.4 splitDocuments() 如何生成小文档真正执行切分的方法是constsplitDocumentsawaittextSplitter.splitDocuments(documents);splitDocuments()接收 Loader 返回的Document[]返回值仍然是PromiseDocument[]。区别在于输入通常是少量大文档输出通常是多个小文档。内部执行过程可以分成四步读取每个输入文档的pageContent。保存每个输入文档原来的metadata。调用递归切分逻辑将pageContent拆成多个 Chunk 字符串。为每个 Chunk 创建新的Document并复制原文档的元数据。可以将它简化理解为asyncfunctionsplitDocuments(documents){constresult[];for(constdocumentofdocuments){// 根据切分器参数处理正文constchunksawaitthis.splitText(document.pageContent);for(constchunkofchunks){// 每个 Chunk 重新封装为 Document并继承来源信息result.push(newDocument({pageContent:chunk,metadata:document.metadata}));}}returnresult;}实际实现还会向metadata.loc.lines中补充文本对应的行号范围。核心原则不变只切分pageContent同时保留metadata。阶段pageContentmetadata文档数量load()之后完整网页正文URL 和标题通常为 1splitDocuments()之后每个 Chunk 的局部正文继承 URL 和标题通常为多个6. 整合完整项目代码6.1 完整代码及关键注释经过前面的模块分析网页加载和文本切分可以串联为下面的完整程序// 加载 .env 中的环境变量当前项目暂时没有读取具体变量importdotenv/config;// 加载 Cheerio 依赖网页 Loader 内部会使用它解析 HTMLimportcheerio;// 网页 Loader负责请求网页并返回 Document[]import{CheerioWebBaseLoader}fromlangchain/community/document_loaders/web/cheerio;// 递归字符切分器负责把大 Document 切成小 Documentimport{RecursiveCharacterTextSplitter}fromlangchain/textsplitters;// 创建网页 Loader保存目标 URL 和正文 CSS 选择器constcheerioLoadernewCheerioWebBaseLoader(https://blog.csdn.net/meilindehuzi_a/article/details/162811916,{selector:#content_views p});// 真正请求网页HTML - DOM - pageContent metadata - Document[]constdocumentsawaitcheerioLoader.load();// 创建切分器并设置目标大小、中文语义边界和重叠长度consttextSplitternewRecursiveCharacterTextSplitter({chunkSize:400,separators:[。,,],chunkOverlap:100});// 切分每个 Document 的 pageContent并保留原来的 metadataconstsplitDocumentsawaittextSplitter.splitDocuments(documents);// 在终端查看最终得到的多个小 Documentconsole.log(splitDocuments);6.2 从 URL 到 Chunk 的完整执行过程执行node src/index.mjs后程序会按照以下顺序运行顺序执行内容数据变化1加载依赖获得 Loader 类和 Splitter 类2创建CheerioWebBaseLoader保存 URL 与 CSS 选择器3调用load()URL - HTML - DOM -Document[]4创建RecursiveCharacterTextSplitter保存三个分块参数5调用splitDocuments()大Document- 多个小Document6调用console.log()将最终结果打印到终端从职责上看load()解决的是“网页如何变成标准文档”splitDocuments()解决的是“标准大文档如何变成适合检索的小文档”。两个方法前后衔接但不能混为一谈。最终打印出的每个元素都包含自己的pageContent和继承来的metadata[{pageContent:第一个文本块……,metadata:{source:https://blog.csdn.net/meilindehuzi_a/article/details/162811916,title:网页标题}},{pageContent:与上一块存在部分重叠的第二个文本块……,metadata:{source:https://blog.csdn.net/meilindehuzi_a/article/details/162811916,title:网页标题}}]这些小Document就是后续 Embedding 的输入。Embedding 模型会把每个pageContent转换成向量向量数据库同时保存向量和metadata。用户提问时系统再从这些文本块中寻找最相关的内容。总结这个项目完成了 RAG 数据入库前最基础的网页预处理流程。axios.get()负责发送 HTTP 请求cheerio.load()将 HTML 解析为 DOMCSS 选择器和.text()负责提取正文CheerioWebBaseLoader将这些步骤封装起来通过load()输出带有正文和来源信息的Document[]。得到完整文档后RecursiveCharacterTextSplitter使用chunkSize控制目标大小使用separators寻找中文语义边界使用chunkOverlap保留相邻块之间的上下文。splitDocuments()最终把一个大文档转换为多个仍然带有元数据的小文档为后续 Embedding、向量存储和知识检索准备好数据。