不知道你有没有这种体验:问大模型一个近期事件或者企业内部知识,它答得头头是道,结果一查全是编的。这就是大模型著名的 「幻觉」 问题 ------ 它永远不会说 "我不知道",只会一本正经地瞎编。
为什么会有幻觉?本质上是大模型的知识边界受限于训练数据集。训练数据里没有的内容,比如最新的行业资讯、你公司的内部文档、你自己的笔记资料,大模型天然就不知道。
怎么解决这个问题?目前工业界最主流、落地成本最低的方案,就是 RAG(检索增强生成) 。今天我们就从原理到代码,用 LangChain.js 从零搭一个极简版 RAG 系统,彻底搞懂它是怎么让大模型 "只说真话" 的。
本文你将学到:
- 搞懂大模型幻觉的本质与 RAG 的核心原理
- 理解嵌入模型、向量检索、文档分片的作用
- 用 JS 手动实现完整 RAG 流程,吃透底层逻辑
- 掌握 LangChain.js 快速开发 RAG 应用
一、什么是 RAG?一句话讲透本质
RAG 全称是 Retrieval-Augmented Generation,翻译过来叫「检索增强生成」。
它的核心逻辑非常朴素:
用户提问时,先去我们自己的私有知识库里检索出和问题最相关的文档片段,把这些片段作为背景知识塞进 Prompt 里,再交给大模型。大模型看着参考资料回答,自然就不会瞎编了。
拆解成三步就是:
- Retrieval(检索) :根据用户问题,从知识库中召回相关的文本片段
- Augmented(增强) :把召回的文本拼接到 Prompt 中,给大模型补充背景知识
- 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 之所以成为大模型落地的标配方案,核心是它解决了三个痛点:
- 解决幻觉:让大模型基于事实回答,有据可依、可溯源
- 私有数据接入:不用微调模型,就能让大模型用上你的私有数据
- 成本低、迭代快:知识库更新直接改文档就行,不用重新训练模型
今天我们从原理到底层实现,再到 LangChain 工具封装,完整走了一遍 RAG 的开发流程。但这只是入门,真正的生产级 RAG 还涉及 Query 改写、多轮对话记忆、多格式文档解析(PDF/Word/ 表格)等很多细节。
如果你正在做大模型应用开发,RAG 绝对是必须掌握的核心技能。