RAG 实战:用 LangChain 从零打造智能知识库问答系统
RAG 不是让大模型重新学习一遍知识,而是在它回答之前,先把相关资料找出来,再把资料放进 Prompt。本文从大模型幻觉讲起,结合"光光和东东"的故事数据,完整拆解一个 LangChain RAG Demo。
【首图:请插入 rag-flow-cartoon.svg,放在标题下方】
一、为什么需要 RAG?
大模型能回答什么,主要取决于训练数据。如果问题涉及企业内部文档、项目规范、产品最新说明或实时业务数据,模型可能根本没有这些知识。
更麻烦的是,模型不一定会直接说"不知道",而可能生成一段听起来合理、实际上没有依据的内容,这就是大模型幻觉。
例如,用户问:
text
光光和东东是怎么成为朋友的?
如果模型没有读过这段故事,它可能会根据常见的叙事模式自行猜测。RAG 的做法是先从故事知识库里找出相关片段,再让模型根据片段回答。
text
用户问题
-> 检索知识库中的相关片段
-> 把片段加入 Prompt
-> 大模型基于资料生成回答
RAG 的全称是 Retrieval-Augmented Generation:
Retrieval:检索;Augmented:增强上下文;Generation:生成回答。
二、RAG 和微调有什么区别?
微调适合改变模型的任务能力、输出格式或表达风格;RAG 更适合给模型补充经常变化的外部知识。
| 对比项 | RAG | 微调 |
|---|---|---|
| 主要解决 | 让模型获得新资料 | 改变模型能力或风格 |
| 知识更新 | 更新知识库即可 | 通常需要重新训练 |
| 成本 | 相对较低 | 通常更高 |
| 是否方便溯源 | 可以关联原文和元数据 | 通常不容易追踪来源 |
| 典型场景 | 企业文档、产品手册、客服知识库 | 固定格式、特定任务、稳定风格 |
如果目标只是"让模型知道公司的内部资料",通常应该先考虑 RAG,而不是直接微调。
【配图:请插入 rag-vs-finetune-cartoon.svg,放在本节表格之后】
三、RAG 的核心链路
RAG 可以拆成入库和查询两个阶段。
3.1 入库阶段
text
原始文档
-> 文档切分
-> Embedding 向量化
-> 保存向量、原文和元数据
真实项目中的文档可能来自 PDF、网页、Markdown、数据库或企业内部系统。长文档通常需要按标题、章节、段落或固定长度切成多个片段。
切得太大,检索结果会包含大量无关内容;切得太小,又可能破坏语义。因此,文档切分是 RAG 效果的第一个关键因素。
3.2 查询阶段
text
用户问题
-> 问题向量化
-> 和文档向量计算相似度
-> 返回 Top-K 片段
-> 拼接成上下文
-> 交给大模型生成答案
四、向量搜索是怎么工作的?
关键词搜索依赖字面匹配,但用户问题和文档的表达方式可能不同。例如"他们的友谊是怎么变深的"和"他们在比赛中互相帮助,关系变得更加亲密",关键词不同,语义却很接近。
Embedding 模型会把文本转换成一组数字,也就是向量。语义接近的文本,通常会在向量空间中更接近。
text
"东东喜欢画画" -> [0.12, -0.34, 0.56, ...]
查询时需要做两次向量化:
- 入库时,把每个文档片段转成向量;
- 查询时,把用户问题转成向量。
然后计算问题向量和文档向量之间的相似度。常见方法是余弦相似度:
text
cos(A, B) = A · B / (|A| × |B|)
不要把向量维度简单理解成"维度越高,效果越好"。最终效果还取决于 Embedding 模型、文档切分、数据质量和检索策略。
【配图:请插入 rag-vector-search-cartoon.svg,放在本节公式之后】
五、项目结构和环境配置
text
rag-demo/
├── .env
├── src/
│ ├── index.mjs
│ └── custom-embeddings.mjs
└── readme.md
.env 示例:
env
MODEL_NAME=qwen-plus
EMBEDDINGS_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
不要把真实 API Key 提交到 Git 仓库。生产环境还应通过后端服务保存密钥,避免直接暴露在浏览器端。
六、逐段解析核心代码
6.1 导入依赖
javascript
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { CustomEmbeddings } from "./custom-embeddings.mjs";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { Document } from "@langchain/core/documents";
| 依赖 | 作用 |
|---|---|
dotenv/config |
将 .env 配置加载到 process.env |
ChatOpenAI |
调用聊天模型生成回答 |
CustomEmbeddings |
提供 Embedding 接口 |
MemoryVectorStore |
在内存中保存和搜索向量 |
Document |
封装正文和元数据 |
6.2 初始化聊天模型
javascript
const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
temperature: 0 通常能让回答更稳定,但它不能保证模型绝对没有幻觉。是否产生幻觉,还取决于检索结果、Prompt、资料质量和模型本身。
baseURL 允许 LangChain 通过兼容 OpenAI 格式的接口访问不同厂商的模型服务。
6.3 初始化 Embedding
javascript
const embeddings = new CustomEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
baseURL: process.env.OPENAI_BASE_URL,
});
这里的 embeddings 不是负责写答案的聊天模型,而是负责把文本转换成向量。
入库和查询必须使用同一套向量空间。也就是说,文档和问题最好使用同一个 Embedding 模型,否则它们的向量不一定可以直接比较。
6.4 创建 Document
javascript
const documents = [
new Document({
pageContent: `光光是一个活泼开朗的小男孩,他特别擅长踢足球。`,
metadata: {
chapter: 1,
character: "光光",
type: "角色介绍",
mood: "活泼",
},
}),
new Document({
pageContent: `东东是光光最好的朋友,他们从幼儿园就认识了。`,
metadata: {
chapter: 2,
character: "东东",
type: "角色介绍",
mood: "温馨",
},
}),
];
一个 Document 主要有两个部分:
pageContent:正文,会参与向量化和检索;metadata:附加信息,可用于过滤、分类和显示来源。
例如,可以用 chapter 标记章节,用 character 标记角色。元数据通常不参与 Embedding,但在调试和回答溯源时非常有用。
【配图:请插入 document-structure-cartoon.svg,放在本节代码之后】
6.5 创建向量存储
javascript
const vectorStore = await MemoryVectorStore.fromDocuments(
documents,
embeddings,
);
这行代码会遍历 documents,调用 Embedding 模型把每个 pageContent 转成向量,然后把向量、原文和元数据保存起来。
text
Document[]
-> embedDocuments()
-> 向量数组
-> { content, embedding, metadata }
MemoryVectorStore 只把数据放在当前进程的内存里,程序重启后数据会丢失。因此它适合学习和测试,生产环境应使用持久化向量数据库。
【配图:请插入 vector-db-compare-cartoon.svg,放在本节最后】
6.6 创建 Retriever
javascript
const retriever = vectorStore.asRetriever({
k: 3,
});
Retriever 可以理解成"向量检索接口"。k: 3 表示每次返回最相关的 3 个文档片段。
k 太小,可能漏掉必要信息;k 太大,又会把无关内容带进 Prompt。实际值需要结合片段长度、问题类型和模型上下文窗口调整。
6.7 执行检索
javascript
const question = "光光和东东是怎么成为朋友的?";
const docs = await retriever.invoke(question);
调用 invoke 后,内部大致会完成:
text
问题
-> embedQuery(question)
-> 和所有文档向量计算相似度
-> 排序
-> 返回前 k 个 Document
调用者不需要手动处理向量化和排序,只需要处理返回的 Document[]。
6.8 查看相似度评分
javascript
const scoredResults =
await vectorStore.similaritySearchWithScore(question, 3);
返回结果通常是:
javascript
[
[document1, score1],
[document2, score2],
]
这里必须注意:不同向量库、不同版本的实现可能使用不同的评分定义。有的返回相似度,分数越大越相关;有的返回距离,分数越小越相关。
因此,不能看到一个分数就直接称为"百分比"。使用前应查看当前向量库的实现或官方文档,确认分数的含义和方向。
对于本示例,如果确认返回的是距离,可以这样转换:
javascript
const similarity = score != null
? (1 - score).toFixed(4)
: "N/A";
但这个转换只适用于明确知道 score 是距离且范围符合预期的情况。
6.9 调试检索结果
javascript
docs.forEach((doc, index) => {
console.log(`[文档 ${index + 1}]`);
console.log(`内容:${doc.pageContent.substring(0, 50)}...`);
console.log(`章节:${doc.metadata.chapter}`);
console.log(`角色:${doc.metadata.character}`);
});
调试 RAG 时,不要只看最后的回答。先检查检索出来的文档是否真正相关,能够更快定位问题到底出在检索、Prompt 还是生成阶段。
七、为什么示例使用 CustomEmbeddings?
学习代码中的自定义 Embedding 通过正弦函数生成向量,核心逻辑类似这样:
javascript
export class CustomEmbeddings {
constructor() {
this.dimension = 1536;
}
async embedDocuments(texts) {
return texts.map((text, index) => {
const seed = text.length * 1000 + index;
return Array.from(
{ length: this.dimension },
(_, i) => Math.sin(seed + i) * 0.5,
);
});
}
async embedQuery(text) {
const seed = text.length * 1000;
return Array.from(
{ length: this.dimension },
(_, i) => Math.sin(seed + i) * 0.5,
);
}
}
这段代码的价值是演示 LangChain 所需要的 Embedding 接口,而不是提供真正的语义理解能力。
它有一个非常重要的限制:主要根据文本长度生成向量,两个长度相同但意思完全不同的句子,可能得到相同或非常接近的向量。因此,使用它得到的检索结果不能代表真实 RAG 效果。
生产环境必须替换成真实 Embedding 模型,并确保文档向量和问题向量使用同一模型、同一维度和兼容的向量空间。
八、构建上下文并生成回答
先把检索到的文档片段拼接成上下文:
javascript
const context = docs
.map((doc, index) => `[片段 ${index + 1}]\n${doc.pageContent}`)
.join("\n\n-----\n\n");
然后把上下文放进 Prompt:
javascript
const prompt = `
请严格根据下面的资料回答问题。
如果资料中没有答案,请直接说"资料中没有找到相关信息",不要编造。
资料:
${context}
问题:
${question}
请用自然、友好的语言回答,并尽量说明答案来自哪一个片段。
`;
最后调用模型:
javascript
const answer = await model.invoke(prompt);
console.log("[AI 回答]");
console.log(answer.content);
到这里,RAG 的三个步骤就完整了:
retriever.invoke:检索;context加入 Prompt:增强;model.invoke:生成。
九、完整代码主线
把前面的步骤压缩起来,核心逻辑就是:
javascript
const vectorStore = await MemoryVectorStore.fromDocuments(
documents,
embeddings,
);
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke(question);
const context = docs
.map((doc, index) => `[片段 ${index + 1}]\n${doc.pageContent}`)
.join("\n\n-----\n\n");
const prompt = `
请根据资料回答问题,不要编造信息。
资料:
${context}
问题:${question}
`;
const answer = await model.invoke(prompt);
console.log(answer.content);
十、RAG 不等于绝对不会幻觉
RAG 能为模型提供外部资料,但不能保证答案百分之百正确。常见问题包括:
- 文档切分破坏了完整语义;
- 检索没有找到真正相关的片段;
k设置不合理;- 知识库本身过期或存在错误;
- Prompt 没有明确要求模型以资料为依据;
- 模型对资料理解错误。
因此,RAG 效果是多个环节共同决定的:
text
知识库质量 × 切分策略 × Embedding 模型
× 检索策略 × Prompt 设计 × 生成模型
十一、生产环境优化方向
11.1 使用真实 Embedding 模型
javascript
import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
真实 Embedding 模型才具备语义表示能力。选择时要确认模型接口、向量维度、语言覆盖和价格是否适合项目。
11.2 使用持久化向量数据库
| 方案 | 特点 |
|---|---|
| Chroma | 轻量,适合开发和小型项目 |
| PostgreSQL + pgvector | 适合已有 PostgreSQL 技术栈的项目 |
| Milvus | 面向较大规模向量检索 |
| Pinecone | 托管服务,减少基础设施维护 |
11.3 改进检索质量
常见方向包括:
- 调整切分长度和重叠区间;
- 使用元数据过滤;
- 关键词检索和向量检索混合召回;
- 对召回结果进行重排序;
- 设置最低相关性阈值;
- 为回答增加来源和引用片段。
不要直接假设所有向量库都支持相同的 filter 或重排序 API。具体写法应以当前使用的向量库和 LangChain 版本文档为准。
十二、总结
RAG 的本质是:
大模型回答之前,先把与问题相关的外部知识找出来,再让模型基于这些资料生成回答。
| 组件 | 作用 | 类比 |
|---|---|---|
Document |
封装文本和元数据 | 书本的一页 |
Embedding |
把文本转换成向量 | 语义翻译官 |
VectorStore |
保存和搜索向量 | 图书馆 |
Retriever |
找出相关片段 | 图书管理员 |
ChatModel |
生成自然语言回答 | 老师 |
Prompt |
组织问题和背景资料 | 答题纸 |
完整链路可以记成:
text
文档切分
-> Embedding 向量化
-> VectorStore 存储
-> Retriever 检索
-> Prompt 增强
-> ChatModel 生成
学习 RAG 时,先抓住"检索、增强、生成"这条主线,再去理解 LangChain 的 API。框架只是把这些步骤组织起来,真正决定效果的,仍然是知识库、检索策略和提示词设计。