RAG 进阶:从网页加载到智能文档分割

0 阅读5分钟

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回答]
根据文章内容,父亲的去世让作者从"将人生视为一场赛跑"的功利心态,转变为"珍惜当下、关注身边人"的生活态度...

如图

image.png

关键洞察

表格

观察点说明
语义检索精准问题中没有出现"赛跑""珍惜当下"等原文词汇,但 Embedding 理解了语义关联
相似度分数可解释通过 similaritySearchWithScore 可以量化每段文档的相关性
分割质量决定上限chunkSize: 400 让每段聚焦一个主题,chunkOverlap: 50 保证上下文连贯
Loader 的 selector 很关键.main-area p 精准提取正文,避免导航栏噪音污染知识库

六、总结

本篇在上一篇的基础上,解决了从 Demo 到真实场景的两大核心问题:

表格

问题解决方案关键工具
数据来源单一网页 Loader 加载真实数据CheerioWebBaseLoader
文档过长智能分割保护语义RecursiveCharacterTextSplitter

通过本文,我们完成了从网页到问答的完整链路:

网页 → Cheerio 加载 → 文本分割 → MemoryVectorStore → 检索 → 增强 Prompt → 生成答案

附录

工作流程图

a76b3480-e069-4699-9f39-00b1d3c08a62.png

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));

}