告别 AI 幻觉!LangChain RAG 完全实战:Document + MemoryVectorStore 打造轻量级语义知识库
大语言模型(LLM)有个致命弱点:它会"一本正经地胡说八道"。当你问一个它没学过的知识时,它不是诚实地说"我不知道",而是会编造出一个看似合理但完全错误的答案 ------ 这就是幻觉(Hallucination) 。本文将用 LangChain 框架,从
Document文档对象到MemoryVectorStore内存向量存储,手把手教你搭建一个轻量级的 RAG(检索增强生成)系统,让 AI 只基于你提供的知识来回答。
前置知识
- 了解 Embedding(向量化)的基本概念
- 了解 cosine 相似度
- 有 Node.js + npm 基础
如果还不熟悉向量化原理,建议先阅读:
- RAG语义搜索全栈实战:用Node.js和Embedding构建智能搜索引擎 ------ 纯手写版,从底层理解 cosine 相似度计算
- LLM分词与向量化原理实战 ------ Tokenization 与 Embedding 完全指南
一、什么是 RAG?为什么需要它?
1.1 LLM 的幻觉问题
arduino
用户:光光和东东是怎么认识的?
普通 LLM(没学过这个故事):
"光光和东东是在大学社团里认识的,
光光参加了足球社,东东是插画社的社长..."
❌ 完全在编造!这个故事里明明说的是从幼儿园就认识
大语言模型的知识来源于训练数据,但它不知道自己知道什么、不知道什么。当你问一个它没学过的问题时,它会基于概率生成最"合理"的回答 ------ 哪怕这个回答是错的。
1.2 RAG 的解决思路
css
传统 LLM 问答:
用户提问 → LLM 直接回答 → 可能产生幻觉
RAG 增强问答:
用户提问 → 检索相关知识片段 → 把片段塞进 Prompt → LLM 基于片段回答
↓
"请基于以下片段回答问题:
[片段1]...
[片段2]...
问题:光光和东东是怎么认识的?"
RAG = R etrieval(检索)+ A ugmented(增强)+ Generation(生成)
| 阶段 | 做什么 | 作用 |
|---|---|---|
| Retrieval | 把用户问题和知识库文档都做 Embedding,计算相似度,找出最相关的片段 | 找到"应该知道什么" |
| Augmented | 把检索到的片段拼接到 Prompt 中,作为上下文背景 | 给 LLM 戴上"紧箍咒" |
| Generation | LLM 基于增强后的 Prompt 生成回答 | 只基于事实回答 |
二、LangChain RAG 四件套
LangChain 将 RAG 的底层细节(向量化、相似度计算、存储等)封装成了高层的抽象接口,让我们可以"开箱即用"。
scss
┌─────────────────────────────────────────────────────────┐
│ LangChain RAG 链路 │
│ │
│ Document ──→ Embedding ──→ VectorStore ──→ Retriever │
│ │ │ │ │ │
│ pageContent 向量化模型 向量存储 检索接口 │
│ metadata text → [float] [vec] + [doc] invoke() │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 用户提问 → 向量化 → 相似度计算 → 取TopK → │ │
│ │ 拼接Context → 塞进Prompt → LLM生成回答 │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
2.1 Document ------ RAG 的最小单元
javascript
import { Document } from '@langchain/core/documents';
const doc = new Document({
pageContent: '要单独 embedding 的文本内容',
metadata: {
chapter: 1,
character: '光光',
type: '角色介绍',
mood: '活泼'
}
});
| 字段 | 是否参与 Embedding | 作用 |
|---|---|---|
pageContent |
✅ 是 | 实际要做向量化的文本内容 |
metadata |
❌ 否 | 用于过滤、溯源、展示,不参与向量化计算 |
metadata虽然不参与向量化,但在实际应用中极其重要。比如你可以根据metadata.character过滤只返回"光光"相关的文档,或者根据metadata.chapter按章节排序。
2.2 OpenAIEmbeddings ------ 文本向量化
javascript
import { OpenAIEmbeddings } from '@langchain/openai';
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: 'text-embedding-v3', // 或 text-embedding-ada-002 等
configuration: {
baseURL: 'https://api.deepseek.com/v1' // 支持任意兼容 OpenAI 的 API
}
});
OpenAIEmbeddings 把一段文本转换成一组浮点数(向量),使得语义相近的文本在向量空间中距离更近。
2.3 MemoryVectorStore ------ 内存向量存储
javascript
import { MemoryVectorStore } from '@langchain/community/vectorstores/memory';
// 将 documents 全部向量化,存入内存
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
MemoryVectorStore 是最轻量级的向量存储,所有数据保存在内存中,适合:
- 学习阶段:快速上手,无需安装数据库
- 轻量级应用:文档数量少、重启后数据可丢失的场景
- 原型验证:快速验证 RAG 效果
⚠️ 生产环境建议使用 PostgreSQL + pgvector、Milvus、Pinecone 等持久化向量数据库。
2.4 Retriever ------ 检索器(标准接口)
javascript
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke('东东和光光是怎么成为朋友的');
Retriever 是一个抽象接口,它的职责是:输入一个问题,返回最相关的文档列表。内部做了三件事:
- 向量化:把问题转成向量
- 相似度计算:计算问题向量与知识库中所有文档向量的相似度
- 后处理:去重、过滤、rerank,返回 Top K 条
三、完整代码实战
3.1 项目初始化
bash
mkdir langchain-rag && cd langchain-rag
npm init -y
npm install @langchain/openai @langchain/core @langchain/community dotenv
创建 .env 文件:
env
# LLM 配置(支持任意兼容 OpenAI 协议的 API)
OPENAI_API_KEY=sk-your-key
OPENAI_BASE_URL=https://api.deepseek.com/v1
MODEL_NAME=deepseek-chat
# Embedding 模型配置
EMBEDDINGS_MODEL_NAME=text-embedding-v3
如果你使用 DeepSeek API,
text-embedding-v3是官方推荐的 Embedding 模型。也可以使用 OpenAI 的text-embedding-3-small。
3.2 核心代码(index.mjs)
javascript
import 'dotenv/config';
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from '@langchain/community/vectorstores/memory';
import { Document } from '@langchain/core/documents';
// ========== 1. 初始化 LLM 和 Embedding 模型 ==========
const model = new ChatOpenAI({
temperature: 0, // 事实性问答用 0,减少创造性编造
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
}
});
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
}
});
// ========== 2. 构建 Document 知识库 ==========
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. 构建向量存储(自动向量化 + 存入内存)==========
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
// ========== 4. 创建检索器 ==========
const retriever = vectorStore.asRetriever({
k: 3 // 返回最相似的 3 条
});
// ========== 5. 用户提问 ==========
const question = '东东和光光是怎么成为朋友的';
console.log('='.repeat(80));
console.log(`问题:${question}`);
console.log('='.repeat(80));
// ========== 6. 检索相关文档(Retrieval)==========
const docs = await retriever.invoke(question);
// 同时获取带分数的相似度结果,用于展示
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);
console.log('\n[检索到的文档及相似度评分]');
docs.forEach((doc, i) => {
const scoredResult = scoredResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent
);
const score = scoredResult ? scoredResult[1] : null;
// score 是距离(越小越相似),转换为相似度(越大越相似)
const similarity = score != null ? (1 - score).toFixed(4) : 'N/A';
console.log(`\n[文档 ${i + 1}] 相似度: ${similarity} (原始距离: ${score})`);
console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
console.log(`元数据: 章节=${doc.metadata.chapter}, 角色=${doc.metadata.character}, 类型=${doc.metadata.type}`);
});
// ========== 7. 增强 Prompt(Augmented)==========
const context = docs
.map((doc, i) => `[片段${i}]\n${doc.pageContent}`)
.join('\n\n-----\n\n');
const prompt = `你是一个讲友情故事的老师。基于以下故事片段回答问题,用温暖生动的语言。如果故事中没有提到,就说"这个故事里还没有提到这个细节"。
故事片段:
${context}
问题:${question}
老师的回答:`;
// ========== 8. LLM 生成回答(Generation)==========
const response = await model.invoke(prompt);
console.log('\n' + '='.repeat(80));
console.log('AI 回答:');
console.log(response.content);
console.log('='.repeat(80));
3.3 运行效果
bash
node index.mjs
输出示例:
makefile
================================================================================
问题:东东和光光是怎么成为朋友的
================================================================================
[检索到的文档及相似度评分]
[文档 1] 相似度: 0.8234 (原始距离: 0.1766)
内容: 东东是光光最好的朋友,他是一个安静而聪明的男孩...
元数据: 章节=2, 角色=东东, 类型=角色介绍
[文档 2] 相似度: 0.7912 (原始距离: 0.2088)
内容: 从那以后,光光和东东成为了学校里最要好的朋友...
元数据: 章节=6, 角色=光光和东东, 类型=结局
[文档 3] 相似度: 0.7656 (原始距离: 0.2344)
内容: 比赛那天终于到了,光光和东东一起站在球场上...
元数据: 章节=5, 角色=光光和东东, 类型=高潮转折
================================================================================
AI 回答:
东东和光光从幼儿园就认识了,他们是性格截然不同的好朋友...
================================================================================
四、代码逐行解读
4.1 为什么用 temperature: 0?
javascript
const model = new ChatOpenAI({
temperature: 0, // ← 事实性问答推荐用 0
// ...
});
temperature 控制 LLM 输出的随机性:
| temperature | 效果 | 适用场景 |
|---|---|---|
| 0 | 确定性输出,每次都一样 | 事实问答、代码生成 |
| 0.3-0.5 | 轻微随机,略有变化 | 一般对话 |
| 0.7-1.0 | 高随机性,创意丰富 | 写作、头脑风暴 |
RAG 的核心目标是准确回答 ,所以用 temperature: 0 最合适。
4.2 fromDocuments 做了什么?
javascript
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
这行代码背后做了三件事:
less
documents (7个Document)
│
▼ 逐个提取 pageContent
["光光是一个活泼开朗...", "东东是光光最好的朋友...", ...]
│
▼ 调用 embeddings.embedDocuments()
[[0.023, -0.156, 0.891, ...], [0.134, 0.567, -0.234, ...], ...]
│
▼ 存入内存
MemoryVectorStore {
memoryVectors: [
{ content: "光光是...", embedding: [0.023, ...], metadata: {...} },
{ content: "东东是...", embedding: [0.134, ...], metadata: {...} },
...
]
}
4.3 asRetriever vs similaritySearchWithScore
javascript
// 方式一:使用 Retriever(推荐)
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke(question);
// 方式二:直接使用向量存储查询
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);
| 特性 | asRetriever |
similaritySearchWithScore |
|---|---|---|
| 返回格式 | Document[](纯文档数组) |
[Document, score][](带分数的元组数组) |
| 内部处理 | 可能包含去重、过滤、rerank 等后处理 | 仅做向量相似度查询 |
| 适用场景 | 生产环境,作为 RAG 的标准入口 | 调试、需要查看相似度分数 |
| 接口抽象 | 高(封装了完整检索流程) | 低(仅底层查询) |
Retriever 是 LangChain 的标准接口 ,它的好处是你可以随时替换底层实现(比如从 MemoryVectorStore 换成 Chroma 或 Pinecone),而上层代码完全不需要改。
4.4 相似度分数的解读
javascript
const similarity = score != null ? (1 - score).toFixed(4) : 'N/A';
similaritySearchWithScore 返回的 score 是距离值(distance),不是相似度:
css
┌────────────────────────────────────────┐
│ 相似度 vs 距离 对比表 │
├────────────┬─────────────┬─────────────┤
│ 概念 │ 距离 │ 相似度 │
├────────────┼─────────────┼─────────────┤
│ 含义 │ 向量空间距离 │ 语义接近程度 │
├────────────┼─────────────┼─────────────┤
│ 范围 │ [0, 2] 左右 │ [0, 1] │
├────────────┼─────────────┼─────────────┤
│ 规律 │ 越小越相似 │ 越大越相似 │
├────────────┼─────────────┼─────────────┤
│ 转换公式 │ distance │ similarity │
│ │ │ = 1 - distance│
└────────────┴─────────────┴─────────────┘
注意:不同 Embedding 模型和向量存储的距离计算方式可能不同(欧氏距离、cosine 距离等),转换公式不一定是
1 - score。本文示例使用的是 cosine 距离,因此similarity = 1 - distance是合理的近似。
4.5 Augmented Prompt 的构建
javascript
const context = docs
.map((doc, i) => `[片段${i}]\n${doc.pageContent}`)
.join('\n\n-----\n\n');
这行代码把检索到的 3 个文档拼接成一个字符串,作为上下文注入 Prompt:
css
[片段0]
东东是光光最好的朋友,他是一个安静而聪明的男孩...
-----
[片段1]
从那以后,光光和东东成为了学校里最要好的朋友...
-----
[片段2]
比赛那天终于到了,光光和东东一起站在球场上...
Prompt 中的关键指令:
arduino
"基于以下故事片段回答问题...如果故事中没有提到,
就说'这个故事里还没有提到这个细节'"
这个约束指令是 RAG 防幻觉的关键 ------ 它明确告诉 LLM:你只能基于我给你的片段回答,不要自己编造。
五、从内存到生产:MemoryVectorStore 的局限性
5.1 MemoryVectorStore 的适用边界
| 维度 | MemoryVectorStore | 生产级向量数据库 |
|---|---|---|
| 存储位置 | 内存(Node.js 进程) | 磁盘 / 分布式存储 |
| 数据持久化 | ❌ 进程重启丢失 | ✅ 持久化保存 |
| 数据规模 | 数百~数千条文档 | 百万~数十亿条 |
| 并发查询 | 单进程内 | 高并发支持 |
| 启动速度 | 快(直接加载) | 需连接数据库 |
| 适用阶段 | 学习/原型/轻量应用 | 生产环境 |
5.2 生产环境方案
学习阶段 生产阶段
│ │
▼ ▼
MemoryVectorStore PostgreSQL + pgvector
│ │
│ 数据规模增长、需要持久化 │
└──────────────────────────┘
LangChain 的优势在于统一的 Retriever 接口 。当你从 MemoryVectorStore 迁移到 pgvector 时,上层代码完全不需要改:
javascript
// 内存版本(开发阶段)
const vectorStore = await MemoryVectorStore.fromDocuments(docs, embeddings);
// PostgreSQL 版本(生产阶段)------ 代码几乎一样
const vectorStore = await PGVectorStore.fromDocuments(docs, embeddings, {
postgresConnectionOptions: { connectionString: process.env.DATABASE_URL },
});
// 检索器接口完全一致!
const retriever = vectorStore.asRetriever({ k: 3 });
六、知识图谱
scss
LangChain RAG 知识体系
│
├── RAG 核心概念
│ ├── Retrieval(检索)
│ │ ├── 用户问题向量化
│ │ ├── 向量相似度计算
│ │ └── Top K 筛选
│ ├── Augmented(增强)
│ │ └── 检索片段拼接到 Prompt
│ └── Generation(生成)
│ └── LLM 基于上下文回答
│
├── LangChain 核心组件
│ ├── Document
│ │ ├── pageContent(参与 Embedding)
│ │ └── metadata(不参与 Embedding,用于过滤/溯源)
│ ├── OpenAIEmbeddings
│ │ └── text → [float] 向量化
│ ├── MemoryVectorStore
│ │ ├── fromDocuments() 自动向量化存储
│ │ ├── asRetriever() 创建检索器
│ │ └── similaritySearchWithScore() 带分数查询
│ └── Retriever
│ ├── invoke(question) 标准检索入口
│ └── 内部:向量化 → 相似度 → 去重/过滤/rerank
│
├── 关键对比
│ ├── Retriever vs similaritySearchWithScore
│ │ ├── Retriever:标准接口,含后处理
│ │ └── similaritySearchWithScore:底层查询,含分数
│ ├── MemoryVectorStore vs 生产级向量数据库
│ │ ├── MemoryVectorStore:学习/原型/轻量
│ │ └── PGVector/Pinecone/Milvus:生产环境
│ └── temperature 设置
│ ├── 0:事实问答(推荐)
│ └── 0.7+:创意生成
│
└── 注意事项
├── API Key 用环境变量
├── metadata 虽不参与 embedding 但很重要
├── 相似度分数 vs 距离的区别
└── Prompt 中必须加"基于片段回答"的约束指令
总结
本文从 LLM 幻觉问题出发,完整走了一遍 LangChain RAG 的实现链路:
- RAG 本质 :不是让 LLM"学会"新知识,而是在提问时把相关知识片段塞给它,让它基于事实回答
- LangChain 四件套 :
Document(文档)→OpenAIEmbeddings(向量化)→MemoryVectorStore(存储)→Retriever(检索) - MemoryVectorStore 适合学习 :零配置、开箱即用,但数据不持久。生产环境建议迁移到
pgvector等持久化数据库 - 防幻觉的关键 :Prompt 中必须包含"基于以下片段回答"的约束指令,且
temperature设为 0 - Retriever 是标准接口:底层实现可以随意替换,上层代码零改动
RAG 是目前最实用的 LLM 增强技术之一。从个人知识库到企业文档问答,从客服机器人到法律文档检索,RAG 都在发挥着不可替代的作用。掌握 LangChain 的 RAG 实现,是你从"会用 LLM API"进阶到"能构建 AI 应用"的关键一步。
参考资料
本文基于课堂实战学习内容整理,经内容审查与代码纠错后输出。如果觉得有帮助,欢迎点赞收藏!