首页 > 教程攻略 > ai教程 >LangChain.js RAG 实战:爬取掘金文章,让 LLM 告别幻觉

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

来源:互联网 时间:2026-07-14 07:23:17

从零实现 RAG:爬取掘金文章,让 LLM 帮你读懂任何网页

如果你一直在关注大模型落地,应该对“RAG”这个词不陌生。它被公认为解决 LLM 幻觉问题最实用的方案之一。但到底怎么用?代码怎么写?今天这篇文章就从一个最基础的案例开始,一步步带你跑通完整的 RAG 链路——从手工知识库,到自动爬取网页并问答。

什么是 RAG?

RAG(Retrieval-Augmented Generation,检索增强生成)的核心思路其实很简单:LLM 的训练数据是有限的,你问它训练集之外的问题(比如一篇昨天刚发布的文章),它很可能“认认真真地胡说八道”——这就是幻觉。RAG 的做法是:先检索相关知识,再让 LLM 基于这些知识回答,确保输出的内容有据可查。

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

流程可以概括为:

用户提问 → 向量检索 → 找到相关文档片段 → 拼入 Prompt → LLM 生成回答

下面通过两个递进的实战案例,带你完整走通这条链路。先从最简单的场景开始。

案例一:手工知识库 RAG

我们手动编写一个关于“光光和东东”的友情故事,让 LLM 基于这个故事回答问题。这个案例虽然简单,但能让你看清 RAG 的核心构件。

核心代码结构

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 代表知识库中的一个片段(chunk),包含两个关键字段:

字段作用
pageContent存储实际文本,会被 embedding 成向量参与语义搜索
metadata附加标签(章节、角色、类型等),不参与向量计算,可用于过滤检索

在 RAG 全流程中,Document 是贯穿始终的统一数据格式:

原始文本切片 → Document[] → Embedding 向量化 → 存入向量库 → 检索返回 Document[] → 提取内容拼入 Prompt

案例二:自动爬虫 RAG(loader-and-splitter.mjs)

手工编写知识库毕竟不现实。真正的 RAG 需要自动从各种来源加载文档、自动切片、自动向量化。这个案例实现的是:爬取一篇掘金文章 → 自动切片 → 向量化 → LLM 问答。

完整流程图

掘金文章 URL
  → CheerioWebBaseLoader(爬取+解析)
  → RecursiveCharacterTextSplitter(智能切片)
  → OpenAIEmbeddings(向量化)
  → MemoryVectorStore(存入内存向量库)
  → retriever(检索)
  → LLM(生成回答)

三个核心 API 详解

1. CheerioWebBaseLoader — 网页加载器

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 只取文章正文区的

标签,自动过滤掉导航栏、评论区等噪音。

2. RecursiveCharacterTextSplitter — 递归文本切片器

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 采用“先全切再合并”的策略:先用 把所有句子切开,然后贪心合并,几个句子加起来不超过 400 字就继续加,直到某句超了 400 就停止,这个 chunk 完成。最终一个 chunk 里可能包含 2-4 个完整句子,大小接近但不超 400 字。

3. MemoryVectorStore — 内存向量数据库

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:

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 等国内模型。关键在于:

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