RAG 进阶:从网页加载到智能文档分割
上一篇我们用手写
Document搭建了 RAG 的最小闭环。但在真实场景中,知识库往往来自网页、PDF、Word 等各种文件,而且文档很长,需要智能切片。本篇将严格基于你的代码,讲解 Loader 加载网页、Splitter 智能分割,以及完整的网页问答链路。
目录
一、从手写知识库到真实数据:为什么需要 Loader?
上一篇我们手动写了 7 个 Document,这在学习时没问题,但在真实业务中不可行。真实知识库可能来自:
- 网页文章
- PDF 报告
- Word 文档
- TXT/Markdown 笔记
- 甚至音频、视频
Loader 的作用就是:载入任何文件,将其转换为 LangChain 的 Document 对象,供后续流程使用。
二、Loader:载入任何文件
LangChain 的 @langchain/community 模块提供了丰富的社区 Loader,可以加载几乎所有常见文件类型。
2.1 网页 Loader:CheerioWebBaseLoader
对于网页,我们使用 CheerioWebBaseLoader。它基于 Cheerio(后端的 jQuery),可以使用 CSS 选择器像操作前端 DOM 一样查找节点。
import 'dotenv/config'
import 'cheerio'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { MemoryVectorStore } from 'langchain/vectorstores/memory'
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
const model = new ChatOpenAI({
modelName: 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
}
})
加载网页:
const cheerioLoader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452?searchId=20260425211436681C8C3AAE8FE3BB1EA3",
{
selector: '.main-area p'
}
)
const documents = await cheerioLoader.load()
💡
selector: '.main-area p'的作用:通过 CSS 选择器精准定位文章正文段落,过滤掉导航栏、评论区、广告等无关内容,避免噪音进入知识库。
三、Splitter:大文档的智能切片艺术
网页加载的原始文档往往很长,直接整篇 Embedding 会稀释语义(向量过于"平均",失去重点)。因此需要 Splitter(文本分割器) 将其切成小段。
3.1 为什么需要分割?
- 大文件切片:原始文档可能有几万字,超出 Embedding 模型的上下文限制
- document 文档碎片:切成小段后每段都有更集中的主题,检索更精准
- 天然语义分割器:中文的
。、,、?等标点,是天然的语义边界
3.2 RecursiveCharacterTextSplitter 详解
LangChain 提供了 RecursiveCharacterTextSplitter,它会递归地按优先级尝试多种分隔符,尽量在语义完整的地方切断:
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每段目标大小
chunkOverlap: 50, // 段与段之间的重叠字符数
separator: ['。', ',', '!', '?'] // 分隔符,优先按中文标点分割
})
const splitDocuments = await textSplitter.splitDocuments(documents)
console.log(splitDocuments)
console.log(`文档分割完成,共${splitDocuments.length}个片段。`)
3.3 chunkOverlap 与分隔符
为什么要设置 chunkOverlap: 50?
想象一句话被切成两半:"父亲的去世对作者的人生态度产生了...根本性的逆转。" 如果刚好在"产生了"后面切断,下一段丢失前半句的语境,语义就不连贯了。
chunkOverlap: 50 让相邻两段共享 50 个字符,保护语义连贯性。
分隔符的优先级
RecursiveCharacterTextSplitter 会按数组顺序尝试分隔符。对于中文内容,优先使用 。、, 等标点,比按固定字数硬切要智能得多。
四、实战:从网页到问答的完整链路
console.log(`正在创建向量`)
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocuments, embeddings)
console.log("向量存储创建完成")
const retriever = await vectorStore.asRetriever({ k: 3 })
const questions = ["父亲的去世对作者的人生态度产生了怎样的根本性逆转?"]
for (const question of questions) {
console.log("=".repeat(80))
console.log(`[问题]:${question}`)
console.log("=".repeat(80))
const retrievedDocs = await retriever.invoke(question)
const scoreResults = await vectorStore.similaritySearchWithScore(question, 2)
console.log(scoreResults)
console.log(`\n [检索到的文档及相关度评分]`)
retrievedDocs.forEach((doc, i) => {
const scoreResult = scoreResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent
)
const score = scoreResult ? scoreResult[1] : null
const similarityScore = score ? score.toFixed(2) : "N/A"
console.log(`[文档${i + 1}]:${doc.pageContent}`)
console.log(`[相关度评分]:${similarityScore}`)
console.log("=".repeat(80))
if (doc.metadata && Object.keys(doc.metadata).length > 0) {
console.log(`[文档元数据]:${JSON.stringify(doc.metadata)}`)
}
})
const content = retrievedDocs
.map((doc, i) => `[片段${i + 1}]:\n${doc.pageContent}`)
.join("\n\n---\n\n")
const prompt = `
你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${content}
问题:
${question}
回答:
`
console.log("\n [AI回答]")
const response = await model.invoke(prompt)
console.log(response)
console.log("=".repeat(80))
}
五、运行效果分析
运行后你会看到:
文档分割完成,共12个片段。
向量存储创建完成
================================================================================
[问题]:父亲的去世对作者的人生态度产生了怎样的根本性逆转?
================================================================================
[文档1] 父亲走的那天,我站在病房外...
[相关度评分] 0.89
[文档2] 曾经我以为人生是一场赛跑...
[相关度评分] 0.85
[AI回答]
根据文章内容,父亲的去世让作者从"将人生视为一场赛跑"的功利心态,转变为"珍惜当下、关注身边人"的生活态度...
如图
关键洞察
表格
| 观察点 | 说明 |
|---|---|
| 语义检索精准 | 问题中没有出现"赛跑""珍惜当下"等原文词汇,但 Embedding 理解了语义关联 |
| 相似度分数可解释 | 通过 similaritySearchWithScore 可以量化每段文档的相关性 |
| 分割质量决定上限 | chunkSize: 400 让每段聚焦一个主题,chunkOverlap: 50 保证上下文连贯 |
| Loader 的 selector 很关键 | .main-area p 精准提取正文,避免导航栏噪音污染知识库 |
六、总结
本篇在上一篇的基础上,解决了从 Demo 到真实场景的两大核心问题:
表格
| 问题 | 解决方案 | 关键工具 |
|---|---|---|
| 数据来源单一 | 网页 Loader 加载真实数据 | CheerioWebBaseLoader |
| 文档过长 | 智能分割保护语义 | RecursiveCharacterTextSplitter |
通过本文,我们完成了从网页到问答的完整链路:
网页 → Cheerio 加载 → 文本分割 → MemoryVectorStore → 检索 → 增强 Prompt → 生成答案
附录
工作流程图
loader-and-splitter.mjs
import 'dotenv/config'
import 'cheerio' // 后端,使用css 选择器,像操作前端一样查找DOM 节点
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'
const model=new ChatOpenAI({
modelName: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
}
})
const cheerioLoader=new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452?searchId=20260425211436681C8C3AAE8FE3BB1EA3",
{
selector:'.main-area p'
}
)
const documents=await cheerioLoader.load();
// console.log(documents);
const textSplitter=new RecursiveCharacterTextSplitter({
chunkSize:400, // 大小
chunkOverlap:50, //重叠 语义的保护 连贯性
separator:['。',',','!','?'] // 分隔符
})
const splitDocuments=await textSplitter.splitDocuments(documents);
console.log(splitDocuments);
console.log(`文档分割完成,共${splitDocuments.length}个片段。`);
console.log(`正在创建向量`);
const vectorStore=await MemoryVectorStore.fromDocuments(splitDocuments,embeddings)
console.log("向量存储创建完成");
const retriever=await vectorStore.asRetriever({k:3});
const questions=["父亲的去世对作者的人生态度产生了怎样的根本性逆转?"]
for(const question of questions){
console.log("=".repeat(80));
console.log(`[问题]:${question}`)
console.log("=".repeat(80));
const retrievedDocs=await retriever.invoke(question);
const scoreResults=await vectorStore.similaritySearchWithScore(question,2);
console.log(scoreResults);
console.log(`\n [检索到的文档及相关度评分]`);
retrievedDocs.forEach((doc,i)=>{
const scoreResult=scoreResults.find(([scoredDoc])=>scoredDoc.pageContent===doc.pageContent)
const score=scoreResult?scoreResult[1]:null;
const similarityScore=score?score.toFixed(2):"N/A";
console.log(`[文档${i+1}]:${doc.pageContent}`);
console.log(`[相关度评分]:${similarityScore}`);
console.log("=".repeat(80));
if(doc.metadata && Object.keys(doc.metadata).length>0){
console.log(`[文档元数据]:${JSON.stringify(doc.metadata)}`)
}
})
const content = retrievedDocs
.map((doc,i)=>`[片段${i+1}]:\n${doc.pageContent}`)
.join("\n\n---\n\n")
const prompt=`
你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容;
${content}
问题:
${question}
回答:
`
console.log("\n [AI回答]")
const response=await model.invoke(prompt)
console.log(response)
console.log("=".repeat(80));
}