摘要:检索增强生成(RAG)是当前大模型应用落地的核心范式。本文将通过一个温馨的"光光和东东"友情故事案例,手把手带你使用 LangChain 框架,从向量数据库构建、语义检索到 Prompt 工程,完整实现一个基于本地内存的 RAG 问答系统。无需复杂的环境配置,只需几行代码,让你的 AI 拥有"长期记忆"。
前言:为什么我们需要 RAG?
在 2026 年的今天,大语言模型(LLM)已经无处不在。然而,直接使用原生模型往往面临两个痛点:
- 幻觉问题:模型可能会一本正经地胡说八道,编造不存在的事实。
- 知识滞后与私有化缺失:模型训练数据截止于过去,它不知道你的公司内部文档,也不知道你刚刚上传的"光光和东东"的故事。
RAG(Retrieval-Augmented Generation,检索增强生成) 正是为了解决这些问题而生。它的核心逻辑非常简单:先检索,后生成。当用户提问时,系统先去知识库中查找相关片段,将这些片段作为"上下文"连同问题一起喂给大模型,让模型基于事实回答问题。
今天,我们将通过一段关于"光光和东东"的温馨故事,利用 LangChain 框架和 MemoryVectorStore(内存向量库),构建一个零门槛的 RAG 应用。
一、核心概念拆解:RAG 是如何工作的?
在动手写代码之前,我们需要理解 RAG 流水线的三个关键步骤,这也对应了我们即将编写的代码逻辑:
-
索引(Indexing) :将非结构化数据(如我们的故事文本)切分,并通过 Embedding 模型转化为向量(Vector),存入向量数据库。
- 比喻:把故事书撕成小纸条,给每张纸条打上独特的"数字指纹",然后归档到档案室。
-
检索(Retrieval) :将用户的问题也转化为向量,计算它与档案室中所有纸条指纹的相似度(通常用余弦相似度),找出最相关的 Top-K 个片段。
- 比喻:用户问"他们擅长什么?",系统拿着这个问题的"指纹"去档案室比对,找出最匹配的三张纸条。
-
生成(Generation) :将检索到的片段组装成上下文(Context),构造 Prompt,发送给 LLM,让模型基于这些材料回答。
- 比喻:把找到的三张纸条交给老师(LLM),说:"请根据这几张纸条的内容,回答用户的问题。"
二、环境准备与依赖安装
本教程基于 Node.js 环境,使用 LangChain JS 生态。你需要确保安装了 Node.js (建议 v18+)。
首先,初始化项目并安装核心依赖:
bash
mkdir rag-test
cd rag-test
npm init -y
npm install @langchain/openai @langchain/core @langchain/classic dotenv
注:@langchain/classic 包含了经典的向量存储实现如 MemoryVectorStore,适合演示和轻量级应用。
接着,创建 .env 文件,配置你的 API 密钥(支持 OpenAI 或兼容接口如 Moonshot, DeepSeek 等):
ini
OPENAI_API_KEY=sk-your-api-key
OPENAI_BASE_URL=https://api.openai.com/v1 # 或其他兼容地址
MODEL_NAME=gpt-4o-mini # 或你喜欢的模型
EMBEDDING_MODEL_NAME=text-embedding-3-small # 嵌入模型
三、实战编码:构建"友情故事"问答机器人
我们将创建一个 index.js 文件,完整复现 RAG 流程。
1. 初始化模型与嵌入组件
首先,我们需要引入必要的类,并实例化 LLM 和 Embeddings 模型。Embeddings 模型负责将文本转化为向量,是 RAG 的"眼睛"。
arduino
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
// 1. 初始化大语言模型
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
},
temperature: 0, // RAG 场景下,温度设为 0 以保证回答稳定性
});
// 2. 初始化嵌入模型 (用于向量化)
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
},
});
2. 构建知识库:Document 与 Metadata
RAG 的强大之处在于不仅能检索内容,还能利用元数据(Metadata)进行过滤或增强。我们将"光光和东东"的故事拆分为 7 个片段,每个片段都带有丰富的元数据。
go
// 3. 构建文档知识库
const documents = [
new Document({
pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容。光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球,每次在球场上奔跑时,就像一道阳光一样充满活力。`,
metadata: { chapter: 1, character: "光光", type: "角色介绍", mood: "活泼" },
}),
new Document({
pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画,他的画总是充满了想象力。虽然性格不同,但东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。`,
metadata: { chapter: 2, character: "东东", type: "角色介绍", mood: "温馨" },
}),
// ... (此处省略中间章节,实际代码包含全部 7 个章节,涵盖相识、练习、比赛、结局及尾声)
new Document({
pageContent: `多年后,光光成为了一名职业足球运动员,而东东成为了一名优秀的插画师。虽然他们走上了不同的道路,但他们的友谊从未改变。东东为光光设计了球衣上的图案,光光在每场比赛后都会给东东打电话分享喜悦。他们证明了,真正的友情可以跨越时间和距离,永远闪闪发光。`,
metadata: { chapter: 7, character: "光光和东东", type: "尾声", mood: "温馨" },
}),
];
专家提示 :在实际生产中,文档切分(Chunking)策略至关重要。过长的片段会稀释关键信息,过短则可能丢失上下文。本例中我们手动切分,实际项目中可使用 RecursiveCharacterTextSplitter 自动处理。
3. 向量化与存储:MemoryVectorStore
接下来是魔法发生的时刻。我们将文档传入 MemoryVectorStore。这一步会自动调用 embeddings 接口,将所有 pageContent 转化为高维向量,并存储在内存中。
csharp
// 4. 创建内存向量数据库
// fromDocuments 方法会自动遍历数组,对每个 document.pageContent 进行向量化并存储
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
MemoryVectorStore 非常适合开发测试、Demo 演示或小规模数据场景,因为它无需部署额外的数据库(如 Pinecone, Milvus),启动即用,进程结束数据即失。
4. 核心检索:Retriever 与相似度计算
有了向量库,我们需要一个"检索器"。LangChain 的 asRetriever 方法封装了复杂的向量搜索逻辑。
javascript
// 5. 构建检索器 (Retriever)
// k: 3 表示每次检索返回最相关的 3 个文档片段
const retriever = vectorStore.asRetriever({ k: 3 });
const questions = ["光光和东东各自擅长什么?"];
for (const question of questions) {
console.log("=".repeat(80));
console.log(`问题:${question}`);
console.log("=".repeat(80));
// 6. 执行检索
// 内部流程:Question -> Embedding -> Cosine Similarity Search -> Top K Docs
const retrievedDocs = await retriever.invoke(question);
// 7. 获取详细评分 (可选,用于调试)
// similaritySearchWithScore 返回 [Document, score] 元组
// 注意:LangChain 中某些实现的 score 是距离 (Distance),越小越相似;
// 而余弦相似度 (Cosine Similarity) 越大越相似。
// OpenAI Embeddings + MemoryVectorStore 默认返回的是欧氏距离或余弦距离,需根据具体实现转换。
const scoreResults = await vectorStore.similaritySearchWithScore(question, 3);
console.log("\n[检索到的文档及相似度分析]");
retrievedDocs.forEach((doc, i) => {
const scoreResult = scoreResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent
);
// 这里的 score 通常是距离 (Distance)。
// 如果是余弦距离 (Cosine Distance),范围 0-2,0 表示完全相同。
// 相似度 (Similarity) = 1 - Distance (针对归一化后的余弦距离)
const distance = scoreResult ? scoreResult[1] : null;
const similarity = distance !== null ? (1 - distance).toFixed(2) : "N/A";
console.log(`\n文档 ${i+1} | 估算相似度:${similarity}`);
console.log(`内容摘要:${doc.pageContent.substring(0, 50)}...`);
console.log(`元数据:${JSON.stringify(doc.metadata)}`);
});
// ... 后续构建 Prompt 和调用模型
}
深度解析:相似度分数 很多新手会对 score 感到困惑。在向量数据库中,我们计算的是"距离"。
- 余弦相似度 (Cosine Similarity) :范围 [-1, 1],1 表示完全相同。
- 余弦距离 (Cosine Distance) :范围 [0, 2],0 表示完全相同。公式通常为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 − Similarity 1 - \text{Similarity} </math>1−Similarity。 代码中
(1 - score).toFixed(2)的目的就是将"距离"反转回人类直觉的"相似度",数值越接近 1,代表文档与问题越相关。
5. 提示词工程:组装 Context 与生成回答
检索只是手段,回答才是目的。我们需要将检索到的碎片信息拼凑成一段连贯的上下文,并设计一个优秀的 Prompt。
javascript
// 8. 构建上下文 (Context)
const context = retrievedDocs
.map((doc, i) => `[片段${i+1}]\n ${doc.pageContent}`)
.join("\n\n----\n\n");
// 9. 构造 Prompt (提示词工程)
// 关键点:明确角色、限定依据、处理未知情况
const prompt = `
你是一个讲友情故事的老师,语气温暖生动,充满鼓励。
请严格基于以下【故事片段】来回答问题。
如果【故事片段】中没有提及相关信息,请诚实地回答:"这个故事里没有提到这个细节哦",不要编造内容。
【故事片段】:
${context}
【问题】:
${question}
【老师的回答】:
`;
console.log("\n[AI 回答生成中...]");
// 10. 调用模型生成最终回答
const response = await model.invoke(prompt);
console.log(response.content);
console.log("\n");
}
Prompt 设计心法:
- 角色设定 :
讲友情故事的老师,定调了回答的风格(温暖、生动)。 - 边界约束 :
严格基于以下故事片段,这是防止幻觉的关键指令。 - 兜底策略 :
如果没有提及...,教会模型在知识盲区如何优雅地拒绝,而不是胡编乱造。
四、运行结果与分析
当我们运行 node index.js 并提出问题 "光光和东东各自擅长什么?" 时,系统内部发生了如下化学反应:
- 向量化:问题被转化为向量。
- 匹配:系统发现 Chapter 1(光光擅长足球)、Chapter 2(东东喜欢画画)、Chapter 7(职业结局)的向量与问题向量距离最近。
- 组装 :这三个片段被填入 Prompt 的
context区域。 - 生成:LLM 阅读上下文后,输出了类似以下的回答:
"亲爱的孩子,在这个温暖的故事里,光光是一个像阳光一样充满活力的男孩,他特别擅长踢足球,甚至在多年后成为了一名职业足球运动员呢!
而他的好朋友东东,虽然性格安静,但他非常聪明,擅长画画和读书,他的画作充满了想象力,后来也成为一名优秀的插画师,连光光的球衣图案都是他设计的哦。
他们一个在球场奔跑,一个在画板前创作,互相支持,真是一对完美的搭档!"
结果分析:
- 准确性:模型准确提取了"足球"和"画画"这两个核心点,没有混淆。
- 跨段落推理:模型结合了第一章的"擅长"和第七章的"职业",给出了更完整的回答,体现了 RAG 整合多片段信息的能力。
- 风格一致:回答符合"温暖老师"的人设。
五、进阶思考:从 Demo 到生产
虽然本教程使用了 MemoryVectorStore 快速上手,但在真实的生产环境中,我们还需要考虑更多:
- 持久化存储 :内存向量库重启即失。生产环境应选用 Pinecone , Milvus , Weaviate 或 PostgreSQL (pgvector) 。LangChain 对这些数据库都有完善的集成。
- 混合检索(Hybrid Search) :单纯的向量检索(语义匹配)有时不如关键词匹配(BM25)精准。最佳实践是结合两者,既懂语义又懂关键词。
- 重排序(Re-ranking) :检索出的 Top-50 个文档,可以用一个更强大的 Cross-Encoder 模型进行精细重排序,取 Top-5 给 LLM,能显著提升回答质量。
- 元数据过滤 :利用代码中的
metadata(如chapter,character),可以在检索前先过滤数据。例如:"只查找'光光'相关的片段",这在大规模知识库中能大幅降低噪声。
结语
通过这不到 200 行的代码,我们成功构建了一个具备"记忆"和"推理"能力的 RAG 应用。从向量化文档,到语义检索,再到基于上下文的生成,RAG 的核心链路其实并不神秘。
"光光和东东"的故事告诉我们,真正的朋友是互相帮助、共同成长的。同样,大模型与知识库也是这样的关系:大模型提供强大的推理与语言能力,知识库提供精准的事实与数据,两者结合(RAG),才能创造出真正可靠、有价值的 AI 应用。
希望这篇教程能成为你 RAG 之旅的起点。快去尝试替换成你自己的文档,让 AI 读懂你的世界吧!