前两篇文章已经拆清了 RAG 的基本流程,以及 Document、Chunk、metadata 和向量数据库分别解决什么问题。
不过,前面的示例还有一个明显的理想化条件:参与检索的 Document 是我们手动创建的。
真实知识库面对的通常不是几段已经整理好的字符串,而是网页、PDF、Word、Markdown 等原始资料。
如果知识来源是一篇网页,系统至少要完成下面这条链路:
text
网页 URL
→ 下载 HTML
→ 提取正文
→ 转换成 Document
→ 切分成 Chunk
→ 生成 Embedding
→ 写入向量数据库
→ 根据问题检索相关片段
→ 把片段交给大模型回答
本文用一篇真实网页作为知识来源,完整实现网页加载、文档切分、向量化、相似度检索和模型回答。
准备依赖
示例使用 LangChain.js、Cheerio、通义千问兼容接口和内存向量库:
bash
pnpm add \
@langchain/community \
@langchain/textsplitters \
@langchain/classic \
@langchain/openai \
cheerio \
dotenv
第一步:用 Loader 把网页变成 Document
不同类型的知识来源,需要不同的加载方式。
text
网页 → Web Loader
PDF → PDF Loader
Word → Docx Loader
CSV → CSV Loader
Loader 的职责不是生成向量,而是把不同格式的原始内容统一转换成 LangChain Document。
本文使用 CheerioWebBaseLoader 加载网页:
js
import { CheerioWebBaseLoader } from
'@langchain/community/document_loaders/web/cheerio';
const sourceUrl = 'https://juejin.cn/post/7660707431753678854';
const cheerioLoader = new CheerioWebBaseLoader(
sourceUrl,
{
selector: '.article-viewer > :not(style)',
},
);
const documents = await cheerioLoader.load();
加载完成后得到的是一个 Document 数组。
其中:
text
pageContent:通过 CSS 选择器提取的网页正文
metadata.source:原始网页地址
metadata.title:网页标题
此时还没有生成 Embedding,也没有执行检索。Loader 只完成了"原始网页到标准文档"的转换。
用 CSS 选择器提取完整正文
网页正文通常不只包含段落,还包含:
- 标题
- 列表
- 表格
- 代码块
因此,CSS 选择器需要覆盖正文容器中的多种内容元素。当前网页使用下面的选择器,读取正文容器的直接子元素,并排除样式标签:
js
selector: '.article-viewer > :not(style)'
这样可以把正文、标题、列表、表格和代码统一保存到 pageContent,为后续切分和检索提供完整的原始内容。
这里的 CSS 选择器依赖目标网站当前的 DOM 结构。如果网站改版,选择器也要同步调整。正式项目还需要检查抓取结果、处理空内容,并遵守目标网站的使用规则。
第二步:递归切分长文档
网页正文加载完成后,得到的仍然是一整篇长文档。
直接把整篇文章转换成一个向量,会混合 path、同步文件读取、回调、Promise 和 async/await 等多个主题。用户只问一个局部问题时,检索粒度会过于粗糙。
因此,需要使用 RecursiveCharacterTextSplitter 切分:
js
import { RecursiveCharacterTextSplitter }
from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 80,
separators: [
'\n\n', '\n',
'。', '!', '?', ';', ',',
' ', ''
],
});
const splitDocuments =
await textSplitter.splitDocuments(documents);
在当前网页上,这一步把 1 个原始 Document 切成了 30 个 Chunk。
具体数量会随着网页内容和切分配置变化,不是固定结果。
分隔符的递归切分策略
RecursiveCharacterTextSplitter 会按照分隔符顺序逐级尝试:
text
先尝试段落
→ 段落仍然太长,再尝试换行
→ 仍然太长,再尝试句号
→ 再尝试感叹号、问号、分号、逗号
→ 最后按空格或字符兜底
这样做的目标是优先保留较大的自然语义边界,同时尽量让每个 Chunk 接近 chunkSize。
用空字符串完成字符级兜底
分隔符数组最后保留空字符串 '',用于字符级切分。即使遇到代码、菜单、古文或没有标点的长文本,切分器仍然能够把内容控制在接近 chunkSize 的范围内。
chunkOverlap 解决什么问题?
假设一句重要信息正好位于两个 Chunk 的边界:
text
Chunk 1:......Node.js 的主线程不适合执行耗时的同步 I/O
Chunk 2:因此 Web 服务中应优先采用异步文件读取......
两个片段单独出现时,都可能缺少完整因果关系。
chunkOverlap: 80 会让相邻 Chunk 保留一部分重叠内容,降低边界切断语义的概率。
但重叠也不是越大越好。Overlap 过大会制造大量重复向量,增加存储、Embedding 和检索成本。
chunkSize 与 chunkOverlap 没有适用于所有项目的固定答案,需要结合文档结构、Embedding 模型和实际检索结果评估。
第三步:分批生成 Embedding
示例通过 OpenAI 兼容接口调用 DashScope:
js
import { OpenAIEmbeddings } from '@langchain/openai';
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.DASHSCOPE_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
batchSize: 10,
configuration: {
baseURL: process.env.DASHSCOPE_BASE_URL,
},
});
这里的 batchSize: 10 控制一次接口请求提交多少条文本。当前示例生成了 30 个 Chunk,LangChain 会按每批 10 条自动完成多次请求。
使用 OpenAI 兼容接口时,模型名称、批量大小、向量维度和请求限制仍然要按照实际服务商的接口要求配置。
第四步:写入内存向量数据库
完成切分后,可以直接从 Document 创建内存向量库:
js
import { MemoryVectorStore }
from '@langchain/classic/vectorstores/memory';
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings,
);
fromDocuments() 内部会完成两件事:
text
读取每个 Document.pageContent
→ 调用 Embedding Model 生成向量
→ 保存向量、pageContent 和 metadata
MemoryVectorStore 适合教学、测试和小型 Demo。它的数据只保存在当前进程内存中,程序重启后就会消失,不适合作为正式知识库的持久化方案。
第五步:执行带分数的相似度检索
用户问题仍然是一段普通字符串:
js
const question = 'fs 模块有哪些常用 API?';
使用 similaritySearchWithScore() 可以直接传入问题字符串,并返回最相关的文档及其分数:
js
const scoredResults =
await vectorStore.similaritySearchWithScore(
question,
3,
);
这个方法会先调用 Embedding Model 生成问题向量,再与向量库中的文档向量进行比较。
读取相似度分数
当前使用的 MemoryVectorStore 默认计算余弦相似度,数值越大表示越相关。
运行结果类似:
text
[文档 1] 余弦相似度:0.6843
[文档 2] 余弦相似度:0.6454
[文档 3] 余弦相似度:0.6131
不同向量数据库返回的可能是相似度,也可能是距离。当前 MemoryVectorStore 返回余弦相似度,数值越大越相关;切换其他向量数据库时,应按照对应实现解释分数。
第六步:把检索结果加入 Prompt
向量检索返回的是相关 Document,还没有调用生成模型。
先把 Top 3 片段组成上下文:
js
const docs = scoredResults.map(([doc]) => doc);
const context = docs
.map(
(doc, index) =>
`[片段 ${index + 1}]\n${doc.pageContent}`,
)
.join('\n\n-----\n\n');
再将上下文和问题一起交给模型:
js
const prompt = `
你是一个文章阅读助手。请只根据给定文章片段回答问题;
如果片段没有提供答案,请明确说明信息不足。
文章片段:
${context}
问题:${question}
回答:
`;
const response = await model.invoke(prompt);
console.log(response.content);
修正网页选择器后,检索片段中包含了文章里的代码和列表,模型能够根据上下文识别 readFileSync、readFile 和 node:fs/promises 等内容。
完整的数据流是:
text
Juejin 网页
→ CheerioWebBaseLoader
→ 原始 Document
→ RecursiveCharacterTextSplitter
→ 30 个 Chunk
→ OpenAIEmbeddings
→ MemoryVectorStore
→ similaritySearchWithScore
→ Top 3 Document
→ Prompt
→ Chat Model 回答
这个 Demo 距离生产环境还有什么差距?
现在的示例已经跑通 RAG 链路,但还不是生产级知识库。
至少还要继续处理:
- 网页改版后选择器失效
- 抓取失败、空正文和重复内容
- 文档更新后的增量同步
- 持久化向量数据库
- 相似度阈值与无答案判断
- metadata 过滤和来源展示
- Chunk 参数评估
- 检索结果重排(Rerank)
- Prompt 注入与不可信网页内容
特别是网页内容不能默认视为可信指令。RAG 系统应把检索结果当作参考资料,而不是允许网页文本覆盖系统规则。
总结
把网页接入 RAG,不是"请求一下 URL"就结束了。
Loader 负责把网页正文转换成标准 Document,Splitter 按语义边界生成 Chunk,Embedding Model 将文本转换成向量,Vector Store 保存并检索相关内容,最后再由生成模型根据检索片段回答问题。
完整流程可以概括为:
text
URL
→ CheerioWebBaseLoader
→ Document
→ RecursiveCharacterTextSplitter
→ Embedding
→ MemoryVectorStore
→ Top K 检索
→ Prompt
→ 模型回答
网页内容经过这条数据管道后,才真正从一份外部资料变成了可以参与语义检索的 RAG 知识。