前言
如果你正在准备 AI 应用开发方向的面试,RAG(Retrieval Augmented Generation,检索增强生成)这个词你一定绕不开。但市面上的教程要么堆概念不讲代码,要么直接丢一坨复杂项目让人无从下手。
这篇文章用一个完整的故事(光光和东东的友情故事)作为知识库,带你从 0 到 1 把 RAG 跑起来。读完你会掌握:
- RAG 到底是什么、为什么要用它
- LangChain 的核心组件怎么用
- 5 个容易搞混的关键区别(面试常考)
- 完整的 RAG 代码,可以直接跑
一、什么是 RAG?用大白话讲清楚
1.1 先看 LLM 的致命弱点
你有没有遇到过这种情况:问 ChatGPT "昨天的 NBA 比赛谁赢了",它告诉你"我的知识截止到 xxx 年"。或者问它"这是我们公司的产品介绍,帮我写一段文案",它开始一本正经地胡说八道。
LLM 的两大短板:
| 问题 | 原因 |
|---|---|
| 不知道新知识 | 训练数据有截止日期 |
| 瞎编(幻觉) | 它本质是"预测下一个字",没有事实校验 |
1.2 RAG 怎么解决?
RAG 的思路非常朴素------让 LLM 答题之前先翻资料:
没有 RAG: 用户提问 → LLM 凭记忆回答(可能瞎编)
有了 RAG: 用户提问 → 先去知识库翻资料 → 把翻到的内容 + 问题一起给 LLM → 靠谱回答
三个字母拆开记:
| 字母 | 全称 | 做什么 | 一句话 |
|---|---|---|---|
| R | Retrieval | 检索 | 去知识库"翻找"相关内容 |
| A | Augmented | 增强 | 把翻到的资料"塞"进 prompt 当小抄 |
| G | Generation | 生成 | LLM 拿着小抄"写"出靠谱回答 |
⚠️ 面试考点:RAG 全称是 R etrieval A ugmented Generation,不是 Retrival Agument Gererative(这是常见拼写错误)。
1.3 一张图理解 RAG 全流程
┌──────────────────────────────────────────────────────────┐
│ 摄入阶段(存) │
│ │
│ 📄 文档 → ✂️ 切分 → 🧮 向量化 → 📦 存入向量库 │
│ │
├──────────────────────────────────────────────────────────┤
│ 查询阶段(取) │
│ │
│ ❓ 问题 → 🧮 向量化 → 🔍 相似度搜索 → 📋 相关文档 │
│ │
├──────────────────────────────────────────────────────────┤
│ 生成阶段(答) │
│ │
│ 📋 相关文档 + ❓ 问题 → 📝 Prompt模板 → 🤖 LLM 回答 │
│ │
└──────────────────────────────────────────────────────────┘
二、RAG 和 LangChain 的关系(面试高频)
2.1 一句话区分
RAG 是菜谱,LangChain 是厨房。RAG 告诉你怎么做,LangChain 给你工具做。
| RAG | LangChain | |
|---|---|---|
| 本质 | 架构思想 / 方法论 | 开发框架 / 工具包 |
| 谁提出的 | 学术界(Facebook AI, 2020) | 一家创业公司 |
| 能跑吗 | ❌ 只是思路 | ✅ 几行代码就能跑 |
2.2 面试标准回答
"RAG 是一种检索增强生成的架构思想,它定义了'先检索、再增强、最后生成'这个流程。LangChain 是一个 LLM 应用开发框架,它把 RAG 所需的组件------Embedding 模型、向量数据库、ChatModel------封装成了开箱即用的模块。没有 LangChain 也能做 RAG(比如用 LlamaIndex、Haystack,甚至纯 OpenAI SDK),但 LangChain 是目前最流行的实现方案。"
2.3 为什么 LangChain 做 RAG 这么火?
- 组件解耦------换模型、换向量库、换检索策略都不改业务逻辑
- 链式编排 ------
|管道符串联整个流程,代码清晰 - 生态大------内置 700+ 集成,Chroma、Pinecone、FAISS 随便换
- 降门槛------不需要手写余弦相似度,不需要自己调 embedding API
三、核心数据结构:Document(知识的最小单元)
3.1 它长这样
js
new Document({
pageContent: '光光是一个活泼开朗的小男孩...', // ← 会转成向量,用来搜索
metadata: { // ← 不转向量,用来过滤
chapter: 1,
character: '光光',
type: '角色介绍',
mood: '活泼'
}
})
3.2 pageContent vs metadata(面试高频区别)
| pageContent | metadata | |
|---|---|---|
| 做 embedding? | ✅ 是 | ❌ 否 |
| 参与搜索? | ✅ 语义匹配"像不像" | ❌ 精确匹配"是不是" |
| 存什么 | 文本正文 | 标签:章节、作者、来源 |
| 搜索方式 | 向量相似度(模糊) | 等号匹配(精确) |
| 类比 | 书的正文 | 书的书脊标签 |
🔑 面试官追问:"metadata 为什么不也做向量化?"
答:没必要。metadata 是精确标签("第1章"、"角色=光光"),用等号就能解决,绕远路做向量化既浪费资源又不精确。两种搜索各司其职------向量搜"像不像",标签筛"是不是"。
四、手写完整 RAG 代码(逐行讲解)
技术栈:Node.js + LangChain + 阿里云 DashScope(OpenAI 兼容接口)
4.1 环境准备
bash
npm install @langchain/openai @langchain/core @langchain/classic dotenv
.env 文件:
env
MODEL_NAME=qwen-plus
EMBEDDING_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=你的API_KEY
OPENAI_API_BASE_URL=https://dashscope.aliyuncs.com/compatible/v1
🐛 踩坑:
.env变量名和process.env.XXX必须字母完全一致 ,多一个 S 少一个 S 都会读到undefined,导致 API 404。
4.2 Step 1-2:初始化模型 + 准备知识库
js
import 'dotenv/config'
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory'
import { Document } from '@langchain/core/documents'
// 初始化 LLM(大脑)
const model = new ChatOpenAI({
temperature: 0, // 0 = 稳定输出,不做随机发散
model: process.env.MODEL_NAME, // qwen-plus
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_API_BASE_URL // 换成阿里云地址
}
})
// 初始化 Embedding 模型(翻译官:文字→向量)
const embeddings = new OpenAIEmbeddings({
model: process.env.EMBEDDING_MODEL_NAME, // text-embedding-v3
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_API_BASE_URL
}
})
4.3 Step 3:构建知识库文档
我们用"光光和东东"的友情故事作为知识库,一共 7 个 Document:
js
const documents = [
new Document({
pageContent: '光光是一个活泼开朗的小男孩...他特别擅长踢足球...',
metadata: { chapter: 1, character: '光光', type: '角色介绍', mood: '活泼' }
}),
new Document({
pageContent: '东东是光光最好的朋友...从幼儿园就认识了...',
metadata: { chapter: 2, character: '东东', type: '角色介绍', mood: '温馨' }
}),
// ... 共 7 个 Document
]
4.4 Step 4:Embed + Store(一行代码完成向量化 + 存储)
js
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings)
这一行代码背后做了两件事:
| 步骤 | 做了什么 |
|---|---|
| Embed | 把每个 Document 的 pageContent 调 embedding API 转成向量 |
| Store | 把向量 + 原 Document 一起存入内存 |
4.5 Step 5:创建检索器 + 检索
js
// 把向量库包装成标准检索器接口
const retriever = vectorStore.asRetriever({ k: 3 })
// 检索最相关的 3 条文档
const question = '东东和光光是怎么成为朋友的'
const docs = await retriever.invoke(question)
retriever.invoke() 内部做了什么?
scss
问题字符串 → embeddings.embedQuery() → 问题向量 → 计算余弦距离 → 返回 Top-K Document
4.6 彩蛋:带分数检索(调试神器)
js
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3)
// 返回:[[Document, 0.23], [Document, 0.45], [Document, 0.61]]
// ↑ 文档 ↑ 分数(越小越相似)
// 转成正向相似度
const similarity = (1 - score).toFixed(4) // 0.85 = 很相关
4.7 Step 6:Augment + Generate(增强 + 生成)
js
// Augment:把检索到的文档拼成一段 context
const context = docs
.map((doc, i) => `[片段${i}]\n ${doc.pageContent}`)
.join('\n\n-----\n\n')
// Generate:context + question → LLM 生成回答
const prompt = `你是一个讲友情故事的老师。基于以下故事片段回答问题,用温暖生动的语言。如果故事中没有提到,就说"这个故事里还没有提到这个细节"。
故事片段:
${context}
问题:${question}
老师的回答:`
const response = await model.invoke(prompt)
console.log(response.content)
到这里,一个完整的 RAG 就跑通了!🎉
五、5 个你必须搞清的区别(面试重点)
区别 1:retriever.invoke() vs vectorStore.similaritySearch()
retriever.invoke(q) |
vectorStore.similaritySearch(q, k) |
|
|---|---|---|
| 返回 | [Document, ...] |
[Document, ...] |
| k 值 | 创建时锁死 asRetriever({k:3}) |
每次调用时灵活传 |
| 能否链式 ` | ` | ✅ 可以 |
| 抽象层级 | 高(Retriever 标准接口) | 低(VectorStore 原生方法) |
类比:
retriever是餐厅的"套餐"(k 已定好),vectorStore是可以"单点"(每次自己选 k)。
区别 2:similaritySearch() vs similaritySearchWithScore()
| similaritySearch | similaritySearchWithScore | |
|---|---|---|
| 返回 | [Document, Document, Document] |
[[Document, 0.85], [Document, 0.72], ...] |
| 带分数 | ❌ | ✅ |
| 场景 | 生产环境,只关心内容 | 调试 / 过滤低分 / 评估检索质量 |
区别 3:pageContent vs metadata
| pageContent | metadata | |
|---|---|---|
| 做 embedding? | ✅ | ❌ |
| 搜索方式 | 向量相似度 | 精确匹配(像 SQL WHERE) |
| 搜索语义 | "意思相近" | "是不是等于" |
js
// 实战配合
const results = await vectorStore.similaritySearch('光光的朋友是谁', 5, {
filter: { chapter: 1 } // metadata 精确筛选:只看第1章
})
// 先 metadata 筛 → 再 pageContent 向量搜
区别 4:MemoryVectorStore vs 向量数据库
| MemoryVectorStore | Chroma / Pinecone / FAISS | |
|---|---|---|
| 数据位置 | 内存 | 磁盘 |
| 持久化 | ❌ 程序关了数据就没了 | ✅ 持久保存 |
| 适用场景 | 学习 / 原型 / 数据量小 | 生产环境 / 数据量大 |
| 配置 | 零配置 | 需要安装部署 |
区别 5:retriever.invoke() vs 直接调 vectorStore
retriever 不只是简单转发------它会做额外处理:
| 操作 | retriever | 裸 vectorStore |
|---|---|---|
| 向量查询 | ✅ | ✅ |
| 去重 | ✅ | ❌ |
| 过滤 | ✅ | ❌ |
| 重排序(rerank) | ✅ | ❌ |
retriever=vectorStore+ 去重 + 过滤 + rerank。面试时能说出这个区别,会加分。
六、LCEL 链式调用(进阶但实用)
手动写法每步都要接数据、传数据,代码越长越乱。LangChain 的 LCEL(LangChain Expression Language)用 | 管道把步骤串起来:
js
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'
import { RunnableSequence, RunnablePassthrough } from '@langchain/core/runnables'
// 一条链穿到底
const chain = RunnableSequence.from([
{
context: retriever, // 自动检索
question: new RunnablePassthrough() // 原样传递问题
},
ChatPromptTemplate.fromTemplate(`
根据以下资料回答:{context}\n问题:{question}
`),
model, // LLM 生成
new StringOutputParser() // 转纯文本
])
// 一行调用
const answer = await chain.invoke('东东和光光是怎么成为朋友的')
对比:
scss
手动版: question → retriever.invoke() → 拿到docs → 手动拼prompt → model.invoke() → 拿到结果
链式版: question → [ retriever | prompt | model | outputParser ] → 最终结果
七、完整 RAG 流程图(可以写进简历)
scss
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Load │ → │ Embed │ → │ Store │ → │ Retrieve │ → │ Generate │
│ 加载文档 │ │ 向量化 │ │ 存向量库 │ │ 语义搜索 │ │ LLM生成 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
↑ ↑ ↑ ↑ ↑
Document Embedding VectorStore Retriever ChatModel
(知识单元) (翻译官) (笔记本) (检索器) (大脑)
八、面试快速自测 5 题
遮住答案,看能答对几题。
1. RAG 全称是什么?三个字母分别做什么?
答案
- Retrieval 检索:把问题转成向量,去向量库找最相似的文档
- Augmented 增强:把搜到的文档拼进 prompt 当参考资料
- Generation 生成:LLM 基于参考资料 + 问题,生成回答
2. Document 的两个字段,哪个做 embedding?哪个不参与搜索?
答案
pageContent:做 embedding,参与向量相似度搜索(搜"像不像")metadata:不做 embedding,用于精确过滤(筛"是不是")
3. fromDocuments() 一行代码背后做了哪两步?
答案
- Embed :把每个 Document 的
pageContent调 embedding API 转成向量 - Store:把向量 + 原 Document 一起存入向量库
4. retriever.invoke() 和 vectorStore.similaritySearch() 有什么区别?
答案
- 返回值一样,但
retriever的 k 值在asRetriever({k})时就锁定了,similaritySearch每次调用都可传 k retriever实现了标准 Retriever 接口,能链式调用|;similaritySearch是 VectorStore 原生方法,不能链式retriever额外做了去重、过滤、rerank 等处理
5. RAG 和 LangChain 是什么关系?
答案
RAG 是架构思想(方法论),LangChain 是实现框架(工具包)。RAG 定义"先检索再生成"的流程,LangChain 提供 ChatOpenAI、Embeddings、VectorStore 等组件把流程落地。RAG 也可以不用 LangChain 实现(LlamaIndex、Haystack、纯 SDK),但 LangChain 是最流行的实现方案。
九、总结:一句口诀记住 RAG
先存后搜,搜了拼,拼了答。
css
存:文档 → Embedding → 向量库
搜:问题 → Embedding → 相似度 → Top-K 文档
拼:相关文档 → Prompt 模板
答:Prompt → LLM → 回答
代码核心就 5 行:
js
// 1. 存
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings)
// 2. 搜
const retriever = vectorStore.asRetriever({ k: 3 })
const docs = await retriever.invoke('你的问题')
// 3. 拼
const context = docs.map(d => d.pageContent).join('\n')
// 4. 答
const answer = await model.invoke(`根据:${context}\n回答:${question}`)
这篇文章的完整代码见我的 GitHub:链接,跑通之后你会发现 RAG 其实就这么简单。面试别再被问住了! 🚀