从零构建 RAG:用 LangChain 实现检索增强生成
摘要:RAG 让 LLM 从"闭卷考试"变为"开卷考试"------先检索相关资料,再基于资料生成答案。本文用 LangChain 从零搭建一个完整的 RAG 系统,涵盖 Document 设计、Embedding 向量化、MemoryVectorStore 存储、Retriever 检索,以及 Augment + Generate 的全流程。
📑 目录
- RAG 是什么?开卷考试 vs 闭卷考试
- 环境准备与依赖
- Document:知识库的最小单元
- Embedding 与向量存储
- Retriever:检索器的两种用法
- 相似度分数的秘密:距离 vs 点积
- Augment + Generate:完整的 RAG 流程
- 一点总结
- 互动讨论
RAG 是什么?开卷考试 vs 闭卷考试
RAG(Retrieval-Augmented Generation,检索增强生成)是一种让 LLM 在回答问题时,先从外部知识库中检索相关文档片段,再基于这些片段生成答案的技术架构。
一句话本质:RAG = 检索(Retrieval)+ 增强(Augmented)+ 生成(Generation)。让 LLM 从"闭卷考试"变为"开卷考试"。
| 场景 | 类比 |
|---|---|
| 传统 LLM | 闭卷考试。全靠训练时背下来的知识,没学过就瞎编(幻觉) |
| 微调 | 考前突击补习。花大量时间把新知识强行灌进模型 |
| RAG | 开卷考试。允许带参考书进考场,不知道就翻书找答案 |
RAG 的核心流程:
- Retrieval(检索) :用户提问 → 问题向量化 → 在知识库中检索最相关的文档片段
- Augment(增强) :将检索到的文档片段拼接到 Prompt 中
- Generation(生成) :LLM 基于增强后的 Prompt 生成答案
环境准备与依赖
bash
bash
pnpm i @langchain/openai @langchain/classic dotenv
@langchain/openai:提供 ChatOpenAI(对话模型)和 OpenAIEmbeddings(嵌入模型)@langchain/classic:LangChain 经典模块,包含 MemoryVectorStore 等核心组件dotenv:读取.env文件中的环境变量
LangChain 版本更新非常快,建议参考官方文档获取最新 API。
Document:知识库的最小单元
在 RAG 中,Document 是 Embedding 的最小单元。知识库由文件(文本、图片、声音、视频)构成,文件中每个段落是有语义的------之所以分段,肯定带有语义。
Document 对象包含两个字段:
javascript
go
new Document({
pageContent: '要单独 embedding 的内容',
metadata: {
// 元数据,不参与 embedding,用于后续过滤或溯源
chapter: 1,
character: "光光",
type: "角色介绍"
}
})
核心设计原则:
pageContent:会进行 Embedding 向量化,是语义检索的依据metadata:不参与向量化,用于过滤、溯源、展示等辅助功能
在示例中,我用 Document 存储了一个关于"光光和东东"的友情故事,分为 7 个章节,每个章节独立作为一个 Document:
javascript
go
const documents = [
new Document({
pageContent: `光光是一个活泼开朗的小男孩...`,
metadata: { chapter: 1, character: "光光", type: "角色介绍", mood: "活泼" }
}),
// ... 更多 Document
];
metadata 中的 chapter、character、type、mood 等信息虽然不参与语义计算,但在后续展示结果时非常有用------我们可以知道检索到的内容来自哪个章节、关于哪个角色。
Embedding 与向量存储
创建 Embedding 模型
Embedding 模型和对话模型是分开的,各有分工:
javascript
arduino
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
// 对话模型:用于生成答案
const model = new ChatOpenAI({
temperature: 0,
model: process.env.DEEPSEEK_MODEL_PRO,
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_API_BASE_URL
})
// 嵌入模型:用于将文本转为向量
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.DASHSCOPE_API_KEY,
model: process.env.DASHSCOPE_EMBEDDING,
configuration: {
baseURL: process.env.DASHSCOPE_API_BASE_URL
}
})
向量存储
MemoryVectorStore 是 LangChain 提供的内存向量存储,适合数据量不大的场景:
javascript
javascript
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
fromDocuments 做了两件事:
- 将每个 Document 的
pageContent通过 Embedding 模型转为高维向量 - 将向量和对应的 Document 一起存入内存
向量化的过程就是将文本转化为"语义坐标"。语义相近的文本,在向量空间中的距离更近。这样当用户提问时,我们可以通过计算向量距离,找到语义最相关的文档片段。
Retriever:检索器的两种用法
Retriever(检索器)是 RAG 中的核心组件。它将向量数据库封装成一个标准入口:输入问题,输出相关性最强的文档列表。
javascript
ini
const retriever = vectorStore.asRetriever({
k: 3, // 最相似的 3 条
});
k 参数控制返回的文档数量。k 值太小,LLM 可能缺少关键信息导致幻觉;k 值太大,无关信息可能误导 LLM,还会浪费 token。
用法一:retriever.invoke() ------ 检索器入口
javascript
ini
const docs = await retriever.invoke(question);
retriever.invoke() 是生产环境推荐的用法。它在向量检索的基础上,还会执行:
- 去重
- 过滤
- 阈值过滤
- Rerank(重排序)
用法二:vectorStore.similaritySearchWithScore() ------ 直接查询
javascript
ini
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);
这个方法只做向量相似度查询,返回文档和对应的分数。适合需要查看分数、调试知识库质量的场景。
两者的区别:
| 特性 | retriever.invoke() |
vectorStore.similaritySearchWithScore() |
|---|---|---|
| 去重/过滤 | ✅ 会做 | ❌ 不做 |
| Rerank | ✅ 会做 | ❌ 不做 |
| 返回分数 | ❌ 不返回 | ✅ 返回 |
| 适用场景 | 生产环境 RAG | 调试、质量分析 |
相似度分数的秘密:距离 vs 点积
在 similaritySearchWithScore 中,返回的 score 是距离值 ,而不是相似度值。
javascript
ini
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);
// scoredResults[0][1] 是距离值
距离和相似度的区别:
- 距离值(Distance) :越小表示越相似。0 表示完全相同,越大表示差异越大。
- 点积相似度(Cosine Similarity) :越大表示越相似。1 表示完全相同,0 表示无关。
代码中做了转换:
javascript
ini
const similarity = score != null ? (1 - score).toFixed(4) : "N/A";
为什么用 1 - score?因为 score 是距离值,范围通常在 0 到 2 之间。1 - score 可以粗略转换为"相似度"的感觉(值越大越相似)。但这不是精确的余弦相似度,只是一个近似的指标。
Augment + Generate:完整的 RAG 流程
Augment(增强):拼接上下文
检索到的文档需要通过 Augment 步骤拼接到 Prompt 中:
javascript
javascript
const context = docs.map((doc, i) =>
`[片段${i}]\n ${doc.pageContent}`
).join("\n\n----\n\n");
const prompt = `你是一个讲友情故事的老师。基于以下故事片段回答问题,用温暖生动的语言。如果故事中没有提到,就说"这个故事里还没有提到这个细节"。
故事片段:
${context}
问题:${question}
老师的回答:`;
这一步把检索到的文档片段"塞进"Prompt,让 LLM 基于这些资料生成答案。这就是 RAG 的"增强"环节------用外部知识增强 LLM 的上下文。
Generate(生成):调用 LLM
javascript
ini
const response = await model.invoke(prompt);
console.log(response.content);
LLM 根据增强后的 Prompt 生成答案。由于 Prompt 中包含了相关文档片段,LLM 能基于事实回答,而不是凭"记忆"编造。
一点总结
通过这个 RAG 项目,我理解了:
- RAG = 检索 + 增强 + 生成:先查资料,再回答,让 LLM 从闭卷变开卷。
- Document 是 Embedding 的最小单元 :
pageContent做向量化,metadata用于过滤和溯源,互不干扰。 - Embedding 模型与对话模型分工不同:一个负责理解语义,一个负责生成答案。
- MemoryVectorStore 适合数据量不大的场景,将 Document 向量化后存入内存。
- Retriever 是生产环境的推荐用法:除了向量检索,还会做去重、过滤、Rerank 等操作。
similaritySearchWithScore返回的是距离值而非相似度值:距离越小越相似。- RAG 的完整流程:文档 → Document → Embedding → 向量存储 → 检索 → 增强 → 生成。
互动讨论
pageContent和metadata的分工是什么? 为什么metadata不做 Embedding?retriever.invoke()和vectorStore.similaritySearchWithScore()的区别是什么? 分别适合什么场景?- 为什么
similaritySearchWithScore返回的是距离值而不是相似度值?1 - score的转换精确吗? - 如果知识库很大(如 10 万条文档),
MemoryVectorStore还适用吗? 应该用什么替代? - RAG 中
k值的选择会影响什么? 如果k太大或太小分别会有什么问题?
📌 一点心得:RAG 的价值不在于"让 AI 更聪明",而在于"让 AI 在特定场景下更靠谱"。它不修改模型,却能让模型回答出原本答不出的私有知识问题。LangChain 把 RAG 的每个环节都标准化了------Document 设计、Embedding、向量存储、检索器、Augment------你只需要组合这些组件,就能搭出完整的 RAG 系统。