大模型总 "胡说八道"?用 LangChain.js 从零实现 RAG 语义检索系统

不知道你有没有这种体验:问大模型一个近期事件或者企业内部知识,它答得头头是道,结果一查全是编的。这就是大模型著名的 「幻觉」 问题 ------ 它永远不会说 "我不知道",只会一本正经地瞎编。

为什么会有幻觉?本质上是大模型的知识边界受限于训练数据集。训练数据里没有的内容,比如最新的行业资讯、你公司的内部文档、你自己的笔记资料,大模型天然就不知道。

怎么解决这个问题?目前工业界最主流、落地成本最低的方案,就是 RAG(检索增强生成) 。今天我们就从原理到代码,用 LangChain.js 从零搭一个极简版 RAG 系统,彻底搞懂它是怎么让大模型 "只说真话" 的。

本文你将学到:

  • 搞懂大模型幻觉的本质与 RAG 的核心原理
  • 理解嵌入模型、向量检索、文档分片的作用
  • 用 JS 手动实现完整 RAG 流程,吃透底层逻辑
  • 掌握 LangChain.js 快速开发 RAG 应用

一、什么是 RAG?一句话讲透本质

RAG 全称是 Retrieval-Augmented Generation,翻译过来叫「检索增强生成」。

它的核心逻辑非常朴素:

用户提问时,先去我们自己的私有知识库里检索出和问题最相关的文档片段,把这些片段作为背景知识塞进 Prompt 里,再交给大模型。大模型看着参考资料回答,自然就不会瞎编了。

拆解成三步就是:

  1. Retrieval(检索) :根据用户问题,从知识库中召回相关的文本片段
  2. Augmented(增强) :把召回的文本拼接到 Prompt 中,给大模型补充背景知识
  3. Generation(生成) :大模型基于参考资料,生成准确的回答

简单说就是:先查资料,再答题。这和我们人做开卷考试的逻辑一模一样。

二、RAG 的核心:为什么向量检索比关键词搜索好用?

很多人会问:我用关键词搜索不行吗?为什么非要搞什么向量、嵌入?

举个例子就懂了:

  • 知识库原文:「光光特别擅长踢足球」
  • 用户提问:「光光擅长什么运动?」

如果是纯关键词搜索,搜 "运动" 两个字,原文里根本没有对应字眼,就搜不到;但向量检索能理解 "踢足球" 属于 "运动" 的语义范畴,能精准命中。

这就是语义检索的优势:它匹配的是意思,而不是字面文字。

2.1 嵌入模型:把文字变成可计算的向量

要实现语义检索,核心工具就是嵌入模型(Embedding Model)

它的作用是把一段文本、一张图片、一份 PDF 转换成一个高维的数学向量。语义越相近的内容,它们的向量在空间里的距离就越近。

比如:

  • "踢足球" 和 "打篮球" 的向量距离很近(都属于运动)
  • "踢足球" 和 "读书画画" 的向量距离就很远

有了向量之后,我们只需要把用户的问题也转成向量,再去知识库的向量库里找距离最近的几个片段,就能拿到最相关的参考资料。

2.2 文档分片:给知识库 "切块"

另一个关键问题是:一整本书、一份几十页的文档,不能直接全部转成向量塞进去。

  • 一是太长了,嵌入模型有输入长度限制;
  • 二是全塞进去会混入大量无关信息,反而降低检索准确率。

所以我们需要把长文档切分成一个个有完整语义的小片段(Chunk),可以按段落切、按页数切、按固定长度切,保证每个片段都有独立完整的含义。

2.3 向量数据库:海量向量的 "搜索引擎"

如果你的知识库只有几条、几十条文档,自己写个循环算相似度也够用。但如果是几十万、上百万条文档,就需要专门的向量数据库来存储向量,并提供毫秒级的相似度检索能力。常见的有 Chroma、Pinecone、Milvus、Weaviate 等。

三、实战!用 LangChain.js 手写极简 RAG

讲完原理,我们直接上代码,用 Node.js + LangChain.js 实现一个可运行的 RAG 小 demo。

我们用一个「光光和东东的友情故事」作为私有知识库,看看大模型能不能基于这份知识库准确回答问题。

3.1 环境准备

先安装依赖:

bash

运行

bash 复制代码
npm install @langchain/core @langchain/openai dotenv

在项目根目录创建 .env 文件,配置模型参数(本文用兼容 OpenAI 接口的大模型 + OpenAI 嵌入模型演示):

env

ini 复制代码
# 对话大模型配置
MODEL_NAME=deepseek-chat
DEEPSEEK_API_KEY=你的APIKey
OPENAI_URL=https://api.deepseek.com/v1

# 嵌入模型配置
EMBEDDINGS_MODEL_NAME=text-embedding-3-small
OpenAI_API_KEY=你的OpenAIKey

3.2 初始化模型与构建知识库

首先导入依赖,初始化大模型、嵌入模型,并构建我们的私有知识库。

这里用 LangChain 的 Document 类来封装知识片段,每个片段包含正文内容 pageContent 和元数据 metadata(用于溯源、过滤,不参与向量化计算)。

javascript

运行

go 复制代码
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { OpenAIEmbeddings } from '@langchain/openai';
import { Document } from '@langchain/core/documents';

// 1. 初始化对话大模型
const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.DEEPSEEK_API_KEY,
  temperature: 0, // 设为0,让回答更严谨、减少编造
  configuration: {
    baseURL: process.env.OPENAI_URL
  }
});

// 2. 初始化嵌入模型
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OpenAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_URL
  }
});

// 3. 构建私有知识库(7个文档片段)
const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容。光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球,每次在球场上奔跑时,就像一道阳光一样充满活力。`,
    metadata: { 
      chapter: 1, 
      character: "光光", 
      type: "角色介绍", 
      mood: "活泼"
    },
  }),
  new Document({
    pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画,他的画总是充满了想象力。虽然性格不同,但东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。`,
    metadata: { 
      chapter: 2, 
      character: "东东", 
      type: "角色介绍", 
      mood: "温馨"
    },
  }),
  new Document({
    pageContent: `有一天,学校要举办一场足球比赛,光光非常兴奋,他邀请东东一起参加。但是东东从来没有踢过足球,他担心自己会拖累光光。光光看出了东东的担忧,他拍着东东的肩膀说:"没关系,我们一起练习,我相信你一定能行的!"`,
    metadata: {
      chapter: 3,
      character: "光光和东东",
      type: "友情情节",
      mood: "鼓励",
    },
  }),
  new Document({
    pageContent: `接下来的日子里,光光每天放学后都会教东东踢足球。光光耐心地教东东如何控球、传球和射门,而东东虽然一开始总是踢不好,但他从不放弃。东东也用自己的方式回报光光,他画了一幅画送给光光,画上是两个小男孩在球场上一起踢球的场景。`,
    metadata: {
      chapter: 4,
      character: "光光和东东",
      type: "友情情节",
      mood: "互助",
    },
  }),
  new Document({
    pageContent: `比赛那天终于到了,光光和东东一起站在球场上。虽然东东的技术还不够熟练,但他非常努力,而且他用自己的观察力帮助光光找到了对手的弱点。在关键时刻,东东传出了一个漂亮的球,光光接球后射门得分!他们赢得了比赛,更重要的是,他们的友谊变得更加深厚了。`,
    metadata: {
      chapter: 5,
      character: "光光和东东",
      type: "高潮转折",
      mood: "激动",
    },
  }),
  new Document({
    pageContent: `从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动,东东教光光画画,他们互相学习,共同成长。每当有人问起他们的友谊,他们总是笑着说:"真正的朋友就是互相帮助,一起变得更好的人!"`,
    metadata: {
      chapter: 6,
      character: "光光和东东",
      type: "结局",
      mood: "欢乐",
    },
  }),
  new Document({
    pageContent: `多年后,光光成为了一名职业足球运动员,而东东成为了一名优秀的插画师。虽然他们走上了不同的道路,但他们的友谊从未改变。东东为光光设计了球衣上的图案,光光在每场比赛后都会给东东打电话分享喜悦。他们证明了,真正的友情可以跨越时间和距离,永远闪闪发光。`,
    metadata: {
      chapter: 7,
      character: "光光和东东",
      type: "尾声",
      mood: "温馨",
    },
  }),
];

3.3 手动实现 RAG 全流程(吃透底层)

我们先不用封装好的工具,手动走一遍「问题向量化 → 相似度计算 → 召回 Top 片段 → 拼接 Prompt → 生成回答」的完整流程。

第一步:批量生成文档向量

javascript

运行

ini 复制代码
// 给所有知识库文档生成向量
const docEmbeddings = await embeddings.embedDocuments(
  documents.map(doc => doc.pageContent)
);

第二步:实现余弦相似度计算

向量相似度最常用的计算方式是余弦相似度,值越接近 1 表示语义越相关。

javascript

运行

ini 复制代码
// 余弦相似度计算
function cosineSimilarity(vecA, vecB) {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;
  for (let i = 0; i < vecA.length; i++) {
    dotProduct += vecA[i] * vecB[i];
    normA += vecA[i] ** 2;
    normB += vecB[i] ** 2;
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

第三步:实现检索函数

javascript

运行

javascript 复制代码
// 检索最相关的 Top K 文档
async function retrieveRelevantDocs(question, topK = 3) {
  // 1. 将用户问题转为向量
  const queryEmbedding = await embeddings.embedQuery(question);
  
  // 2. 计算与所有文档的相似度
  const similarities = documents.map((doc, index) => ({
    doc,
    score: cosineSimilarity(queryEmbedding, docEmbeddings[index])
  }));
  
  // 3. 按相似度降序排序,取 Top K
  return similarities
    .sort((a, b) => b.score - a.score)
    .slice(0, topK)
    .map(item => item.doc);
}

第四步:拼接 Prompt 并调用大模型

javascript

运行

javascript 复制代码
async function ragAnswer(question) {
  // 1. 检索相关文档
  const relevantDocs = await retrieveRelevantDocs(question);
  
  // 2. 拼接参考资料到 Prompt,明确要求大模型基于资料回答
  const context = relevantDocs.map(doc => doc.pageContent).join("\n\n");
  
  const prompt = `
请你基于以下参考资料回答用户的问题。
如果参考资料中没有相关信息,请直接回答"我不知道",绝对不要编造内容。

【参考资料】
${context}

【用户问题】
${question}

【回答】
`;

  // 3. 调用大模型生成回答
  const response = await model.invoke(prompt);
  return response.content;
}

测试效果

javascript

运行

javascript 复制代码
// 测试1:语义匹配问题
const answer1 = await ragAnswer("光光最擅长的运动是什么?");
console.log("回答1:", answer1);
// 输出:光光最擅长的运动是踢足球。

// 测试2:知识库没有的问题
const answer2 = await ragAnswer("光光的数学成绩怎么样?");
console.log("回答2:", answer2);
// 输出:我不知道

可以看到,加入 RAG 之后,大模型既能基于知识库准确回答问题,遇到不会的内容也会老实说不知道,不会编造。

3.4 用 LangChain 封装工具快速开发

手动实现是为了理解原理,实际开发中我们可以直接用 LangChain 封装好的向量存储和检索链,大幅提升效率。

javascript

运行

javascript 复制代码
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { RetrievalQAChain } from "langchain/chains";

// 1. 将文档存入内存向量数据库(自动完成向量化)
const vectorStore = await MemoryVectorStore.fromDocuments(
  documents,
  embeddings
);

// 2. 创建检索器,设置召回 Top 3 相关片段
const retriever = vectorStore.asRetriever(3);

// 3. 一键构建 RAG 问答链
const qaChain = RetrievalQAChain.fromLLM(model, retriever, {
  returnSourceDocuments: true, // 返回参考来源,便于溯源
});

// 测试调用
const result = await qaChain.call({ 
  query: "东东和光光最后分别从事了什么职业?" 
});

console.log("回答:", result.text);
console.log("参考章节:", result.sourceDocuments.map(d => d.metadata.chapter));

四、RAG 的进阶优化方向

一个极简的 RAG 跑起来很容易,但要在生产环境用好,还有很多核心问题要解决:

4.1 文档切分策略

切分是 RAG 效果的第一道门槛。切太粗,无关信息多;切太细,上下文丢失。常见的切分方式:

  • 固定长度切分:简单粗暴,适合结构简单的文本
  • 语义切分:按段落、章节、语义边界切,效果更好
  • 递归字符切分:LangChain 内置的 RecursiveCharacterTextSplitter,工业界最常用

4.2 混合检索 + 重排序

纯向量检索也有短板,比如专有名词、精确数值容易召回不准。工业界常用方案是混合检索:向量检索 + 关键词检索(BM25)双路召回,再用重排序模型(Rerank)对结果重新打分,兼顾语义和精确匹配。

4.3 向量数据库选型

数据量小、做 demo 用内存存储就行;真正上线要选专业向量数据库:

  • 轻量本地 / 小项目:Chroma、FAISS
  • 云端托管:Pinecone、Weaviate
  • 企业级自建:Milvus、Qdrant

五、总结

RAG 之所以成为大模型落地的标配方案,核心是它解决了三个痛点:

  1. 解决幻觉:让大模型基于事实回答,有据可依、可溯源
  2. 私有数据接入:不用微调模型,就能让大模型用上你的私有数据
  3. 成本低、迭代快:知识库更新直接改文档就行,不用重新训练模型

今天我们从原理到底层实现,再到 LangChain 工具封装,完整走了一遍 RAG 的开发流程。但这只是入门,真正的生产级 RAG 还涉及 Query 改写、多轮对话记忆、多格式文档解析(PDF/Word/ 表格)等很多细节。

如果你正在做大模型应用开发,RAG 绝对是必须掌握的核心技能。

相关推荐
sugar__salt18 小时前
Vue.js 前置知识:ES6+ 核心特性完全指南
前端·javascript·vue.js·vue·es6
人间凡尔赛20 小时前
世界模型入门实战:从RTFM到理解物理世界 | 2026深度技术解析
javascript·人工智能·深度学习·机器学习
大家的林语冰20 小时前
🫡 见证历史,TypeScript 7 重写成功,VS Code 原地起飞,GitHub 第一语言联手 Go 破而后立!
前端·javascript·typescript
我叫张小白。20 小时前
LangChain 结构化输出(Structured Output)技术文档
java·数据库·langchain
lhxcc_fly21 小时前
LangGraph 项目部署知识点总结
ai·langchain·项目部署·langgraph
早点睡啊Y21 小时前
深入学LangChain 官方文档(九)Memory 记忆系统首讲
langchain
Hilaku21 小时前
为什么很多人觉得前端很简单?
前端·javascript·程序员
李明卫杭州21 小时前
Vue2 数组响应式为什么总"不更新"?深入源码对比 Vue3 的 Proxy 改造
前端·javascript·vue.js
艾伦野鸽ggg1 天前
JavaScript 浏览器本地存储
开发语言·javascript·ecmascript