作者:互联网 时间: 2026-07-17 08:21:09
RAG(Retrieval-Augmented Generation,检索增强生成)是一种让 LLM 在回答问题时,先从外部知识库中检索相关文档片段,再基于这些片段生成答案的技术架构。

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