LangChain.js RAG 实战:爬取掘金文章,让 LLM 告别幻觉

从零实现 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 采用"先全切再合并"的策略:

  1. 先用 把所有句子切开
  2. 贪心合并:A句+A句+A句 → 如果不超 400 字就继续加
  3. 加到某句超了 400 就停止,这个 chunk 完成
  4. 从下一句开始新的 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=每次返回几个结果

检索时实际发生的是:

  1. 用户问题 → embedding → 问题向量
  2. 问题向量与库中所有文档向量做余弦相似度计算
  3. 余弦值越接近 1 表示语义越相似,越接近 0 表示不相关
  4. 按相似度排序,返回 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 兼容接口 ,只需要改 baseURLapiKey,代码不用动。


总结

从两个案例可以看到 RAG 的完整链路:

  1. Loader:从各种来源加载文档(网页、PDF、数据库...)
  2. Splitter:将长文档智能切片成 chunks
  3. Embedding:将每个 chunk 转为向量
  4. VectorStore:存储向量,支持相似度检索
  5. Retriever:根据用户问题检索最相关的 chunks
  6. Augmented Prompt:将检索结果拼入 Prompt
  7. Generation:LLM 基于增强后的 Prompt 生成准确回答

掌握了这个流程,你就能让 LLM 读懂任意文档,从"通用聊天机器人"升级为"领域知识专家"。

相关推荐
dong_junshuai11 小时前
每天一个开源项目#99 OpenResearch:2.2K星的本地研究Agent工作台
开源·github·agent
花椒技术12 小时前
把 AI 视频接进直播:提前生成、按次生成、持续生成怎么选?
agent·音视频开发
用户80825986668712 小时前
用 LangGraph 重写自己手写的 Agent:框架替你解决了什么,什么它不管
agent
能不能静下心来看12 小时前
从 RAG 到 Agent:跑通两个 Demo 后,我终于分清了这两个词
agent
ZGi.ai12 小时前
知识库权限变了,怎样避免答错?
agent·权限管理·知识库·工作流·zgi·客服运营
大模型真好玩12 小时前
DeepSeek Harness 入门很简单(四)——DeepSeek Harness接入插件
人工智能·agent·deepseek
掰头战士12 小时前
MCP、Skill、Plugin,都是给agent拓展能力,到底有何区别?
typescript·llm·agent
嘟嘟嘟952714 小时前
AI Agent 的边缘困境
人工智能·架构·agent
HYDtomako14 小时前
Agent checkpoint设计
ai·agent·checkpoint·claude code
梦在远山后14 小时前
从需求到架构:我的企业研发 Agent 整体设计
langchain·agent