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 读懂任意文档,从"通用聊天机器人"升级为"领域知识专家"。

相关推荐
带刺的坐椅1 小时前
SolonCode v2026.7.13 发布:多工作区协同、推理水平可控、子代理任务分组、工程体验再进化
llm·agent·codex·solon-ai·claudecode·opencode
AI程序员2 小时前
会写代码的 AI 很多,能算 Coding Agent 的没几个
人工智能·agent
zxfeng~4 小时前
伴眸-居家养老AI 眼镜智能体 技术方案
人工智能·agent·ai编程·智能眼镜
uncle_ll4 小时前
LangChain 实战指南:回调系统、自定义组件与有状态对话全落地
langchain·llm·embedding·agent·大模型开发·rag
mCell5 小时前
别给烂代码写注释——重写它
agent·代码规范·源码阅读
发光发热吧5 小时前
模型负责聪明,工程负责让聪明可靠
agent
CoovallyAIHub5 小时前
Claude Code 被曝安全后门:Coco 给出了 AI 协作的另一种答案
agent
新知图书6 小时前
记忆能力的定义与内涵
人工智能·agent·ai agent·智能体
昵称好难啊7 小时前
1. Loop Engineering详解
llm·agent