告别 AI 幻觉!LangChain RAG 完全实战:Document + MemoryVectorStore 打造轻量级语义知识库

告别 AI 幻觉!LangChain RAG 完全实战:Document + MemoryVectorStore 打造轻量级语义知识库

大语言模型(LLM)有个致命弱点:它会"一本正经地胡说八道"。当你问一个它没学过的知识时,它不是诚实地说"我不知道",而是会编造出一个看似合理但完全错误的答案 ------ 这就是幻觉(Hallucination) 。本文将用 LangChain 框架,从 Document 文档对象到 MemoryVectorStore 内存向量存储,手把手教你搭建一个轻量级的 RAG(检索增强生成)系统,让 AI 只基于你提供的知识来回答。

前置知识

  • 了解 Embedding(向量化)的基本概念
  • 了解 cosine 相似度
  • 有 Node.js + npm 基础

如果还不熟悉向量化原理,建议先阅读:


一、什么是 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 是一个抽象接口,它的职责是:输入一个问题,返回最相关的文档列表。内部做了三件事:

  1. 向量化:把问题转成向量
  2. 相似度计算:计算问题向量与知识库中所有文档向量的相似度
  3. 后处理:去重、过滤、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 换成 ChromaPinecone),而上层代码完全不需要改。

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 的实现链路:

  1. RAG 本质 :不是让 LLM"学会"新知识,而是在提问时把相关知识片段塞给它,让它基于事实回答
  2. LangChain 四件套Document(文档)→ OpenAIEmbeddings(向量化)→ MemoryVectorStore(存储)→ Retriever(检索)
  3. MemoryVectorStore 适合学习 :零配置、开箱即用,但数据不持久。生产环境建议迁移到 pgvector 等持久化数据库
  4. 防幻觉的关键 :Prompt 中必须包含"基于以下片段回答"的约束指令,且 temperature 设为 0
  5. Retriever 是标准接口:底层实现可以随意替换,上层代码零改动

RAG 是目前最实用的 LLM 增强技术之一。从个人知识库到企业文档问答,从客服机器人到法律文档检索,RAG 都在发挥着不可替代的作用。掌握 LangChain 的 RAG 实现,是你从"会用 LLM API"进阶到"能构建 AI 应用"的关键一步。


参考资料


本文基于课堂实战学习内容整理,经内容审查与代码纠错后输出。如果觉得有帮助,欢迎点赞收藏!

相关推荐
呆瑜nuage2 小时前
LangChain 聊天模型 —— 结构化输出详解与实战
langchain·大模型应用开发·结构化输出·agent基础
花千树_0104 小时前
插件加载方式选不好,迟早被同事吐槽——四种姿势全解析
langchain·agent
fenglllle5 小时前
python3t pip3安装langchain失败心得
langchain
大模型真好玩7 小时前
LangChain DeepAgents 速通指南(十一)—— DeepAgents Code 记忆与状态管理
人工智能·langchain·agent
lhxcc_fly7 小时前
LangGraph基础知识点
python·langchain·llm·langgraph
BraveWang10 小时前
【LangChain 1.x】05、结构化输出|三种结构定义模式与两种结果获取方式
langchain
俊俊谢10 小时前
从零搭建:本地LangChain Agent调用远程LLaMA-Factory模型服务
langchain·llama
怕浪猫10 小时前
第15章 安全、伦理与合规
langchain·openai·agent
向日的葵0061 天前
langchain的Tools教程(三)
python·langchain·tools