从零实现 RAG:爬取掘金文章,让 LLM 帮你读懂任何网页
什么是 RAG?
RAG(Retrieval-Augmented Generation,检索增强生成)是解决 LLM "幻觉"问题的核心方案。
简单来说:LLM 的训练数据是有限的,当你问它训练集之外的问题(比如一篇昨天刚发布的文章),它就会"认认真真地胡说八道"------这就是幻觉。
RAG 的思路很直接:先检索相关知识,再让 LLM 基于这些知识回答。
用户提问 → 向量检索 → 找到相关文档片段 → 拼入 Prompt → LLM 生成回答
本文通过两个递进的实战案例,带你从手工知识库到全自动爬虫 RAG,完整走通这条链路。
案例一:手工知识库 RAG
我们先从最简单的场景入手------手动编写一个关于"光光和东东"的友情故事,让 LLM 基于这个故事回答问题。
核心代码结构
javascript
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
// 1. 手动构造知识库(7个Document)
const documents = [
new Document({
pageContent: `光光是一个活泼开朗的小男孩...`,
metadata: { chapter: 1, character: "光光", type: "角色介绍", mood: "活泼" },
}),
// ... 共7段故事
];
// 2. 向量化并存入内存
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
// 3. 构建检索器
const retriever = vectorStore.asRetriever({ k: 3 });
// 4. 提问 → 检索 → 增强Prompt → 生成回答
const retrievedDocs = await retriever.invoke("光光和东东各自擅长什么?");
const context = retrievedDocs.map((doc, i) => `[片段${i+1}]\n${doc.pageContent}`).join("\n\n----\n\n");
const prompt = `基于以下故事片段回答问题:\n${context}\n\n问题:${question}\n回答:`;
const response = await model.invoke(prompt);
Document:知识的"标准载体"
Document 是 LangChain 中的核心数据结构,每个 Document 代表知识库中的一个片段(chunk):
| 字段 | 作用 |
|---|---|
pageContent |
存储实际文本,会被 embedding 成向量参与语义搜索 |
metadata |
附加标签(章节、角色、类型等),不参与向量计算,可用于过滤检索 |
在 RAG 全流程中,Document 是贯穿始终的统一数据格式:
css
原始文本切片 → Document[] → Embedding 向量化 → 存入向量库 → 检索返回 Document[] → 提取内容拼入 Prompt
案例二:自动爬虫 RAG(loader-and-splitter.mjs)
手工编写知识库毕竟不现实。真正的 RAG 需要自动从各种来源加载文档、自动切片、自动向量化。
这个案例实现的是:爬取一篇掘金文章 → 自动切片 → 向量化 → LLM 问答。
完整流程图
markdown
掘金文章 URL
→ CheerioWebBaseLoader(爬取+解析)
→ RecursiveCharacterTextSplitter(智能切片)
→ OpenAIEmbeddings(向量化)
→ MemoryVectorStore(存入内存向量库)
→ retriever(检索)
→ LLM(生成回答)
三个核心 API 详解
1. CheerioWebBaseLoader --- 网页加载器
javascript
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
const loader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452",
{ selector: '.main-area p' } // CSS选择器,只提取正文段落
);
const documents = await loader.load();
底层原理 :使用 cheerio(服务端的 jQuery)解析 HTML。你可以像操作前端 DOM 一样用 CSS 选择器精确定位要提取的内容。
关键配置 :selector 决定了抓取哪些 DOM 节点。这里 .main-area p 只取文章正文区 <p> 标签,自动过滤掉导航栏、评论区等噪音。
2. RecursiveCharacterTextSplitter --- 递归文本切片器
javascript
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个chunk最大字符数
chunkOverlap: 50, // 相邻chunk重叠字符数
separators: ['。', ',', '!', '?'] // 分隔符优先级
});
const chunks = await splitter.splitDocuments(documents);
三个参数的作用:
chunkSize:每个文本块的最大字符数。这是上限,不是固定值。chunkOverlap:相邻块之间重叠的字符数。比如 chunk1 末尾 50 字和 chunk2 开头 50 字相同,防止关键信息在切割边界处被"腰斩"。separators:按优先级排序的分隔符。优先在句号处切,其次是逗号,再次是感叹号、问号,保证切分尽量发生在自然语义边界。
分隔符和 chunkSize 会冲突吗?
不会。RecursiveCharacterTextSplitter 采用"先全切再合并"的策略:
- 先用
。把所有句子切开 - 贪心合并:A句+A句+A句 → 如果不超 400 字就继续加
- 加到某句超了 400 就停止,这个 chunk 完成
- 从下一句开始新的 chunk
所以最终一个 chunk 里可能包含 2-4 个完整句子,大小接近但不超 400 字。
3. MemoryVectorStore --- 内存向量数据库
javascript
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocuments, embeddings);
const retriever = vectorStore.asRetriever({ k: 2 });
MemoryVectorStore 把所有向量数据存在内存中,适合学习和原型验证。
两个核心方法:
| 方法 | 作用 |
|---|---|
fromDocuments(docs, embeddings) |
将文档列表 embedding 后存入内存 |
asRetriever({ k: 2 }) |
将向量库包装成检索器,k=每次返回几个结果 |
检索时实际发生的是:
- 用户问题 → embedding → 问题向量
- 问题向量与库中所有文档向量做余弦相似度计算
- 余弦值越接近 1 表示语义越相似,越接近 0 表示不相关
- 按相似度排序,返回 Top K 个 Document
LangChain 常见 Splitter 行为对比
不同的 Splitter 切割策略差异很大,选对工具很重要:
| Splitter | 策略 | 特点 |
|---|---|---|
RecursiveCharacterTextSplitter |
按分隔符优先级递归切割,贪心合并凑近 chunkSize | 保持句子完整性,最常用 |
CharacterTextSplitter |
按固定字符数 + 滑动窗口切割 | 简单粗暴,可能截断句子 |
TokenTextSplitter |
按 token 数切割 | 精确控制 token 消耗,但不关心语义边界 |
MarkdownHeaderTextSplitter |
按 Markdown 标题层级切割 | 适合结构化文档 |
HTMLHeaderTextSplitter |
按 HTML 标题标签切割 | 适合网页文档 |
关键区别 :只有 RecursiveCharacterTextSplitter 是"先全部按分隔符切开,再贪心合并凑满 chunkSize",其他都是一刀一刀按固定窗口切。所以 Recursive 的 chunk 质量通常最高。
构建 Prompt 与 LLM 调用
检索到相关文档后,将其拼入 Prompt:
javascript
const context = retrievedDocs
.map((doc, i) => `[片段${i+1}]\n${doc.pageContent}`)
.join("\n\n----\n\n");
const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${context}
问题:
${question}
回答:`;
const response = await model.invoke(prompt);
这就是 RAG 中 Augmented(增强) 的核心:给 LLM 的 Prompt 不只是用户问题,还追加了检索到的相关上下文。
关于国内模型的兼容性
代码中使用的是 @langchain/openai,但实际调用的可能是通义千问(Qwen)或 DeepSeek 等国内模型。关键在于:
javascript
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY, // 国内模型的 API Key
configuration: {
baseURL: process.env.OPENAI_BASE_URL // 国内模型的 API 地址
},
});
国内主流大模型(阿里 Qwen、DeepSeek、智谱 GLM、月之暗面 Kimi、零一万物 Yi 等)都提供 OpenAI 兼容接口 ,只需要改 baseURL 和 apiKey,代码不用动。
总结
从两个案例可以看到 RAG 的完整链路:
- Loader:从各种来源加载文档(网页、PDF、数据库...)
- Splitter:将长文档智能切片成 chunks
- Embedding:将每个 chunk 转为向量
- VectorStore:存储向量,支持相似度检索
- Retriever:根据用户问题检索最相关的 chunks
- Augmented Prompt:将检索结果拼入 Prompt
- Generation:LLM 基于增强后的 Prompt 生成准确回答
掌握了这个流程,你就能让 LLM 读懂任意文档,从"通用聊天机器人"升级为"领域知识专家"。