搭 RAG 的第一个坎:网页检索答非所问?说说文档加载与分块的那些坑

作者:袖梨 2026-07-21

折腾了一晚上,从爬虫怎么取正文,到文档怎么切块,踩了好几个实打实的坑。今天整理出来,给同样刚上手 RAG 的朋友避避。

爬网页取正文,正则真的不如选择器香

最开始我图省事,直接 axios 发请求拿 html 字符串,想着用正则把正文抠出来。写了没两分钟我就放弃了 —— 掘金的页面结构杂得很,侧边栏、评论、推荐栏全混在 html 里,正则写出来又长又脆,改个类名就全崩了。

后来想起以前听人提过 cheerio,说就是服务端的 jQuery。我一个写前端的,用 CSS 选择器拿元素那不是手到擒来?

装完依赖写了个最简版本:

 复制代码import axios from 'axios';
import * as cheerio from 'cheerio';const targetUrl = 'https://juejin.cn/post/7660707431753678854';async function crawlPage() {
    try {
        const { data: html } = await axios.get(targetUrl);
        const $ = cheerio.load(html); // 在内存里造出 DOM 树
        const pageContent = $('.article-content').text();
        console.log(pageContent);
    } catch (err) {
        console.error('爬取失败', err);
    }
}crawlPage();

跑通那一刻真的舒服。说白了 cheerio 干的事很简单:把拿到的 html 字符串在内存里解析成 DOM 树,然后你就可以用熟悉的 $('.类名') 去捞元素,完全不用跟正则较劲。

踩坑提醒 别上来就瞎写选择器,先打开页面 F12 确认一下正文容器的类名。我一开始想当然写了 .content,结果打印出来空的,对着页面核对了半天才发现是 .article-content。别问我为什么知道,试了三次。

这样爬出来的内容干净是干净了,但新的问题马上就来了。

整段塞进去不行吗?为什么非要做文档切割

最开始我的想法很朴素:文章都爬出来了,直接整段转成向量丢库里不就完了?

结果检索的时候就出问题了:一篇文章几千字,向量是把整段话压缩成一个向量,你问一个具体的小问题,它匹配到的是整篇文章的整体语义,答出来自然泛得没边。

这就好比你去图书馆查 “番茄炒蛋放多少盐”,管理员直接给你抱来一整本《中国菜谱大全》,说这本书里有答案。有是有,但你得自己翻半天。

文档切割(Text Splitter)干的事,就是把这本厚书拆成一页一页、一段一段的 “知识卡片”,每张卡片讲一小块内容。检索的时候直接把最相关的那几张卡片拎出来,答案自然就准了。

但怎么切也是门学问,不是咔咔乱切就行。

我踩过的分块坑:不是切得越细越好

LangChain 里最常用的就是 RecursiveCharacterTextSplitter,名字挺长,其实逻辑很接地气:它会按你给的分隔符列表,挨个尝试去切,尽量在语义完整的地方下刀,而不是硬把一句话砍断。

我最开始直接抄了别人的配置,chunkSize 设 1000,也没改分隔符,跑出来的效果一言难尽。中文句子靠句号收尾,默认的分隔符都是英文换行、空格,切出来的东西经常一句话劈两半。

后来改成了这样:

 复制代码const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,    // 每块大概 400 字符
    separators: ['。', '!', '?'], // 优先在句末标点切
    chunkOverlap: 100, // 相邻两块重叠 100 字符
});

这里面每个参数我都踩过坑,挨个说。

chunkSize:多大才合适?

不是越小越精准。切得太碎,每一块都只剩几个词,语义就断了;切得太大,一块里塞了好几个主题,检索还是不准。

我试了 200、400、800 几个档位,对于普通技术文章来说,400 字符大概一两段话,刚好能把一个小知识点讲完整,体验下来是最舒服的。

separators:切在哪里很重要

这个参数是灵魂。你告诉它 “优先在句号、感叹号、问号后面切”,它就会尽量保住一句话的完整性。

千万别用逗号当分隔符,半句话的语义碎得没法用。

chunkOverlap:重叠不是冗余,是补锅

我一开始觉得 overlap 纯属浪费,切分开就分开了,重叠干嘛?

直到我看到有一块的结尾是 “path.resolve 的目标不是简单拼接,而是得到一个”,下一块开头是 “可以定位文件的绝对路径”。单看哪一块都不完整,拼起来才是一句完整的话。

overlap 就是干这个的:万一真的从句子中间切开了,两边各留一截重叠的内容,保证语义不会断层。就像粘东西的时候两边都留点余量,才不会裂开。

注意 overlap 不是越大越好,一般设成 chunkSize 的 1/4 到 1/5 就够了。太大了会出现大量重复片段,反而会干扰检索结果。

跑通的那一刻:看看检索结果准不准

分完块丢进向量库,我搜了一句 “总结一下 fs 模块的作用”,打印出来的结果是这样:

img_6a5ec108c2f6530.webp

能看到排最前面的文档,相似度 0.3892,内容刚好就是讲 fs 模块定义的那段,确实比之前整段塞进去准多了。

这里有个小点我当时琢磨了半天:原始分 0.61 左右,相似度是 1 减原始分。因为向量数据库算的是 “距离”,距离越小,两个向量越像。所以原始分越低,相似度指标越高,别搞反了。

上面的检索和打分结果,就是下面这套完整流程跑出来的。从环境配置、网页加载、文档分块,到向量存储、相似度检索、最后拼接 Prompt 调用大模型,一整套链路都在里面:

 复制代码import "dotenv/config";
import "cheerio";
// 从 url 加载文档
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({
  temperature: 0,
  model: process.env.DASHSCOPE_API_MODEL,
  apiKey: process.env.DASHSCOPE_API_KEY,
  configuration: {
    baseURL: process.env.DASHSCOPE_API_BASE_URL,
  },
});const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.DASHSCOPE_API_KEY,
  model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
  configuration: {
    baseURL: process.env.DASHSCOPE_API_BASE_URL
  },
});// 访问网址 并提取文档内容
// cheerio 可以传递 css 选择器 来提取文档内容 缩小范围
const cheerioLoader = new CheerioWebBaseLoader(
    'https://juejin.cn/post/7660707431753678854',
    {
        selector: '.main-area p'// 文章段落
    }
);// 大的 document 分成小的 document 更加精心地去处理语义
const documents = await cheerioLoader.load();// 递归尝试不同的分隔符,找到最优的切割点,保证每个 chunk 语义尽量完整
const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,
    separators: ['。', '!', '?'],
    chunkOverlap: 100,
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(`文档分割完成,共${splitDocuments.length}个 chunk`);// 创建向量存储
console.log('创建向量存储');
const vectorStore = await MemoryVectorStore.fromDocuments(
    splitDocuments,
    embeddings
);
console.log('向量存储完成');
const retriever = vectorStore.asRetriever({k: 3});const question = "总结一下fs模块的作用";
console.log('='.repeat(80));
console.log(question);
console.log('='.repeat(80));// 检索相关文档
const docs = await retriever.invoke(question);// 带分数的相似度检索
const scoredResults = 
  await vectorStore.similaritySearchWithScore(question, 3);console.log("n [检索到的文档及相似度评分]");
docs.forEach((doc, i) => {
  const scoredResult = scoredResults.find(([scoredDoc]) => 
    scoredDoc.pageContent === doc.pageContent
  )
  const score = scoredResult? scoredResult[1]: null;
  const similarity = score != null ? (1 - score).toFixed(4):
  "N/A"  console.log(`n[文档 ${i + 1}] 相似度指标: ${similarity} (原始分: ${score})`);
  console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
  console.log(`元数据:章节=${doc.metadata.chapter}, 角色=${doc.metadata.character}, 类型=${doc.metadata.type}`);
});// 拼接上下文,组装 Prompt 调用大模型
const context = docs
  .map((doc, i) => `[片段${i}]n ${doc.pageContent}`)
  .join("nn-----nn");const prompt = `
你是一个文章辅助阅读助手,根据文章内容来解答问题。文章内容:
${context}问题:${question}你回答:`;const response = await model.invoke(prompt);
console.log(response.content);

你看,从爬取正文,到切块,再转向量检索,这一整套下来,其实核心就一件事:给 AI 喂干净、准确、语义完整的上下文

核心流程的精简版我也单独拎出来了,对着改改 url 和选择器就能快速上手:

 复制代码import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { OpenAIEmbeddings } from '@langchain/openai';// 初始化向量模型
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.DASHSCOPE_API_KEY,
  model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
  configuration: { baseURL: process.env.DASHSCOPE_API_BASE_URL },
});// 加载网页 + 指定选择器只取正文
const loader = new CheerioWebBaseLoader(
    'https://juejin.cn/post/7660707431753678854',
    { selector: '.main-area p' }
);// 加载 + 分割
const documents = await loader.load();
const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,
    separators: ['。', '!', '?'],
    chunkOverlap: 100,
});
const splitDocs = await textSplitter.splitDocuments(documents);// 存入向量库并检索
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, embeddings);
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke("总结一下fs模块的作用");

多说一句:现在写代码,感觉真的不一样了

折腾完这个小 demo 我最大的感触是,现在做 AI 应用,写代码本身反而不是最难的部分。

API 调用、向量库、分割器,全都是现成的轮子。真正决定效果好不好的,是你能不能选对 loader、能不能把文档切得合理、能不能把上下文组织得清晰。说白了,不是拼谁代码写得溜,是拼谁能把问题拆解清楚,把喂给 AI 的材料打理明白。

以前是把逻辑写给计算机执行,现在是把上下文喂给 AI 处理。说什么 vibecoding 有点玄乎,但 “提出好问题、准备好上下文、搭好稳定的流程”,确实越来越重要了。

最后捋一下这趟踩坑下来最实在的三个结论:

  1. 爬取网页正文,cheerio + CSS 选择器远比重构靠谱,对前端友好到离谱;
  2. 文档切割不是切得越碎越好,保住语义完整是第一位的,优先在句末标点下刀;
  3. chunkOverlap 不是多余的设计,是用来补语义断层的,别省。

当然这也只是最基础的用法,后面还有更复杂的语义分块、重排序这些东西,我也还在啃。

如果你也在刚学 RAG 的路上踩过什么奇葩坑,或者有更好的分块经验,评论区留个言,我也想学学。

相关文章

精彩推荐