从零入门 RAG:用 LangChain 搭建你的第一个检索增强生成应用
本文将带你从"LLM 为什么会胡说八道"这个问题出发,一步步理解 RAG(检索增强生成)的核心原理,并通过一个完整的 Node.js 实战案例,让你亲手搭建一个基于 LangChain 的 RAG 应用。
目录
一、LLM 的"幻觉"问题
我们在使用 ChatGPT、Claude 等大语言模型时,经常会遇到一种尴尬的情况:你问了一个它"不知道"的问题,它会一本正经地胡说八道。
比如问它"我公司今年的销售数据是多少?",它可能会编造一个看似合理但完全错误的答案。这种现象在 AI 领域被称为幻觉(Hallucination)。
为什么会这样?因为 LLM 的知识来源于训练时的数据集,它有以下几个局限:
- 知识有截止日期 —— 不知道最新发生的事
- 没有私有知识 —— 不知道你公司的内部文档、个人笔记
- 记忆有限 —— 无法记住超长上下文中的所有细节
那么,如何让 LLM 回答它原本不知道的问题呢?答案就是 RAG(Retrieval-Augmented Generation,检索增强生成)。
二、RAG 是什么?
RAG 的核心思想很简单:在提问之前,先从知识库中检索出相关的文档片段,把这些片段作为"参考资料"一起喂给 LLM,让它基于这些资料来回答。
就像开卷考试一样 —— 学生不需要背诵整本教科书,只需要在考试时快速找到相关章节,基于书中的内容作答。
RAG 的流程可以拆解为三个步骤:
1. Retrieval(检索)
- 将用户的提问转换为向量(Embedding)
- 在预先构建好的知识库向量中,通过余弦相似度(Cosine Similarity)找出最相关的文档片段
2. Augmentation(增强)
- 把检索到的文档片段拼接进原始 Prompt 中
- 形成"问题 + 参考资料"的增强提示
3. Generation(生成)
- LLM 基于增强后的 Prompt 生成答案
- 因为答案有"据"可查,幻觉大幅降低
用户提问 → Embedding → 向量检索 → 获取相关文档 → 拼接 Prompt → LLM 生成答案
三、向量与语义搜索:RAG 的底层逻辑
在 RAG 中,检索这一步是最关键的。传统的关键词匹配(如 SQL 的 LIKE '%苹果%')有一个致命缺陷:它无法理解语义。
比如用户搜索"水果",关键词匹配找不到"苹果"和"香蕉",因为它们字面上不包含"水果"两个字。
什么是向量(Vector)?
向量是一串数字,用来在多维空间中表达一个概念。举个例子:
| 维度 | 食用性 | 硬度 |
|---|---|---|
| 水果 | 0.9 | 0.3 |
| 苹果 | 0.9 | 0.5 |
| 香蕉 | 0.9 | 0.1 |
| 石头 | 0.1 | 0.9 |
在这个二维空间中,"水果"、"苹果"、"香蕉"的距离很近,而"石头"离它们很远。这就是语义相似性的数学表达。
Embedding 的过程
当我们把一段文本输入 Embedding 模型(如 OpenAI 的 text-embedding-3-small),模型会将其压缩成一个高维向量(通常是 1536 维)。语义相近的文本,在向量空间中的距离也更近。
Cosine 相似度
检索时,我们计算"问题向量"和"文档向量"之间的夹角余弦值。值越接近 1,表示语义越相似。
四、实战:用 LangChain 搭建 RAG 应用
理论讲完了,接下来我们用一个完整的 Node.js 项目来实战。我们将基于 LangChain 框架,搭建一个"友情故事问答助手"。
4.1 环境准备
首先创建项目并安装依赖:
mkdir hello-rag && cd hello-rag
npm init -y
npm install langchain @langchain/openai @langchain/core @langchain/community dotenv
创建 .env 文件:
OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=https://api.openai.com/v1
MODEL_NAME=gpt-4o-mini
EMBEDDINGS_MODEL_NAME=text-embedding-3-small
4.2 构建知识库
我们用 Document 类来定义知识库中的每一段内容,并附上 metadata(元数据)方便后续追踪来源。
import { Document } from '@langchain/core/documents'
const documents = [
new Document({
pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛...`,
metadata: {
chapter: 1,
character: "光光",
type: "角色介绍",
mood: "活泼"
},
}),
new Document({
pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩...`,
metadata: {
chapter: 2,
character: "东东",
type: "角色介绍",
mood: "温馨"
},
}),
// ... 更多文档片段
]
💡 为什么要切片? 长文档直接 Embedding 会稀释语义,切成小段后每段都有更集中的主题,检索更精准。
4.3 Embedding 与向量存储
LangChain 提供了 OpenAIEmbeddings 来生成向量,以及 MemoryVectorStore 作为内存中的向量数据库(适合学习和测试)。
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { MemoryVectorStore } from 'langchain/vectorstores/memory'
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
temperature: 0 // 降低随机性,让回答更稳定
})
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL }
})
// 将文档自动切片、Embedding 并存入向量库
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings)
4.4 检索器配置
通过 asRetriever 方法创建检索器,k=3 表示每次检索返回最相关的 3 个文档片段。
const retriever = vectorStore.asRetriever({ k: 3 })
// 检索过程
const retrievedDocs = await retriever.invoke("东东和光光各种擅长什么?")
4.5 增强 Prompt 与生成答案
这是 RAG 的"灵魂"步骤 —— 把检索到的文档拼进 Prompt,明确告诉 LLM"请基于以下资料回答"。
const context = retrievedDocs
.map((doc, i) => `[片段${i + 1}]\n${doc.pageContent}`)
.join("\n\n---\n\n")
const prompt = `
你是一个讲友情故事的老师。
基于以下的故事片段回答问题,用温暖生动的语言。
如果故事中没有提及,就说"这个故事没有提及这个细节"
故事片段:
${context}
问题:
${question}
老师的回答:
`
const response = await model.invoke(prompt)
console.log(response.content)
五、运行结果分析
运行代码后,你会看到类似这样的输出:
================================================================================
问题:东东和光光各种擅长什么?
================================================================================
文档 1 相似度: 0.82
内容: 东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画...
元数据: {"chapter":2,"character":"东东","type":"角色介绍","mood":"温馨"}
文档 2 相似度: 0.79
内容: 光光是一个活泼开朗的小男孩...光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球...
元数据: {"chapter":1,"character":"光光","type":"角色介绍","mood":"活泼"}
文档 3 相似度: 0.75
内容: 从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动,东东教光光画画...
元数据: {"chapter":6,"character":"光光和东东","type":"结局","mood":"欢乐"}
[AI 的回答]
光光是一个充满活力的男孩,他最擅长的就是踢足球,在球场上奔跑时就像一道阳光。而东东则是一个安静聪明的男孩,他喜欢读书和画画,他的画总是充满了想象力。两个好朋友互相学习,光光教东东运动,东东教光光画画,一起变得更好!
如图
关键观察
- 语义检索很精准:问题问的是"擅长什么",系统没有机械匹配"擅长"二字,而是理解了语义,找到了介绍两人特点的相关片段。
- 相似度分数:通过
similaritySearchWithScore可以看到每段文档的相关度,帮助我们调试检索效果。 - 答案有据可查:LLM 的回答完全基于检索到的故事片段,没有编造不存在的信息。
六、RAG 的优化方向
这个示例是一个最基础的 RAG 实现,在实际生产环境中,你还可以从以下方向优化:
| 优化点 | 说明 |
|---|---|
| 文档切分策略 | 使用 RecursiveCharacterTextSplitter 自动按语义切分,避免句子被截断 |
| 持久化向量库 | 将 MemoryVectorStore 替换为 Chroma、Milvus、Pinecone 等持久化数据库 |
| 混合检索 | 向量检索 + 关键词检索(BM25)结合,提升召回率 |
| 重排序(Rerank) | 先用向量召回 Top-K,再用专门的 Rerank 模型排序,取 Top-N |
| 查询改写 | 让 LLM 先理解用户意图,生成更标准的查询语句再检索 |
| 引用溯源 | 在回答中标注信息来源(如"根据第 2 章..."),提升可信度 |
七、总结
RAG 是当下最实用的 LLM 应用架构之一,它巧妙地弥补了大型语言模型的三大短板:知识时效性、私有知识、幻觉问题。
通过本文,你应该已经理解了:
- ✅ 为什么 LLM 会产生幻觉
- ✅ RAG "检索 → 增强 → 生成" 的三步流程
- ✅ 向量 Embedding 和 Cosine 相似度的基本原理
- ✅ 如何用 LangChain + OpenAI 实现一个完整的 RAG 应用
如果你对这个方向感兴趣,建议下一步尝试:
- 接入你自己的 PDF/Word 文档作为知识库
- 尝试使用开源 Embedding 模型(如 BGE、M3E)替代 OpenAI
- 学习 LangChain 的
RetrievalQAChain和createRetrievalChain进一步简化代码
附录
hello-rag.mjs完整代码
import 'dotenv/config'
import {
ChatOpenAI,
OpenAIEmbeddings
} from '@langchain/openai'
// 知识库中一段知识的抽象概念
import {
Document
} from '@langchain/core/documents'
// 内存向量数据库
import {
MemoryVectorStore
} from '@langchain/classic/vectorstores/memory'
const model= new ChatOpenAI({
modelName:process.env.MODEL_NAME,
apiKey:process.env.OPENAI_API_KEY,
configuration:{
baseURL:process.env.OPENAI_BASE_URL
},
temperature:0
})
const embeddings=new OpenAIEmbeddings({
apiKey:process.env.OPENAI_API_KEY,
model:process.env.EMBEDDINGS_MODEL_NAME,
configuration:{
baseURL:process.env.OPENAI_BASE_URL
}
})
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: "温馨",
},
}),
]
const vectorStore=await MemoryVectorStore.fromDocuments(documents,embeddings);
// 检索器,k=2表示检索2个最相关的文档
const retriever=vectorStore.asRetriever({k:3});
const questions=["东东和光光各种擅长什么?"]
for (const question of questions){
console.log("=".repeat(80));
console.log(`问题:${question}`);
console.log("=".repeat(80));
// 先将question 转换为向量
// 再通过向量搜索,consine 知道最相似的文档
const retrievedDocs=await retriever.invoke(question);
console.log(retrievedDocs)
const scoreResults=await vectorStore.similaritySearchWithScore(question,3);
console.log(scoreResults)
console.log("\n[检索到的文档及相关度评分]")
retrievedDocs.forEach((doc,i)=>{
const scoreResult=scoreResults.find(
([scoredDoc])=>scoredDoc.pageContent===doc.pageContent
)
const score=scoreResult ? scoreResult[1]:null;
const similarity=score?(1-score).toFixed(2):"N/A";
console.log(`\n 文档 ${i+1} 相似度:${similarity}`);
console.log(`内容:${doc.pageContent}`);
console.log(`元数据:${JSON.stringify(doc.metadata)}`);
})
const context=retrievedDocs
.map((doc,i)=>`[片段${i+1}\n ${doc.pageContent}]`)
.join("\n\n---\n\n");
const prompt=`
你是一个将友情故事的老师。
基于以下的故事片段回答问题,用温暖生动的语言。
如果故事中没有提及,就说"这个故事没有提及这个细节"
故事片段:
${context}
问题:
${question}
老师的回答:
`;
console.log(`\n [AI 的回答]`);
const response=await model.invoke(prompt);
console.log(response.content);
console.log("\n");
}
工作流程如图
如果这篇文章对你有帮助,欢迎点赞、收藏、关注! 有任何问题欢迎在评论区交流,我们一起学习进步 🚀