从零搭建 RAG 应用 —— 一个故事带你彻底搞懂检索增强生成

10 阅读9分钟

前言

如果你正在准备 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 → 靠谱回答

三个字母拆开记:

字母全称做什么一句话
RRetrieval检索去知识库"翻找"相关内容
AAugmented增强把翻到的资料"塞"进 prompt 当小抄
GGeneration生成LLM 拿着小抄"写"出靠谱回答

⚠️ 面试考点:RAG 全称是 Retrieval Augmented Generation,不是 Retrival Agument Gererative(这是常见拼写错误)。

1.3 一张图理解 RAG 全流程

┌──────────────────────────────────────────────────────────┐
│                      摄入阶段(存)                         │
│                                                          │
│   📄 文档  →  ✂️ 切分  →  🧮 向量化  →  📦 存入向量库     │
│                                                          │
├──────────────────────────────────────────────────────────┤
│                      查询阶段(取)                         │
│                                                          │
│   ❓ 问题  →  🧮 向量化  →  🔍 相似度搜索  →  📋 相关文档  │
│                                                          │
├──────────────────────────────────────────────────────────┤
│                      生成阶段(答)                         │
│                                                          │
│   📋 相关文档 + ❓ 问题  →  📝 Prompt模板  →  🤖 LLM 回答  │
│                                                          │
└──────────────────────────────────────────────────────────┘

二、RAG 和 LangChain 的关系(面试高频)

2.1 一句话区分

RAG 是菜谱,LangChain 是厨房。RAG 告诉你怎么做,LangChain 给你工具做。

RAGLangChain
本质架构思想 / 方法论开发框架 / 工具包
谁提出的学术界(Facebook AI, 2020)一家创业公司
能跑吗❌ 只是思路✅ 几行代码就能跑

2.2 面试标准回答

"RAG 是一种检索增强生成的架构思想,它定义了'先检索、再增强、最后生成'这个流程。LangChain 是一个 LLM 应用开发框架,它把 RAG 所需的组件——Embedding 模型、向量数据库、ChatModel——封装成了开箱即用的模块。没有 LangChain 也能做 RAG(比如用 LlamaIndex、Haystack,甚至纯 OpenAI SDK),但 LangChain 是目前最流行的实现方案。"

2.3 为什么 LangChain 做 RAG 这么火?

  1. 组件解耦——换模型、换向量库、换检索策略都不改业务逻辑
  2. 链式编排——| 管道符串联整个流程,代码清晰
  3. 生态大——内置 700+ 集成,Chroma、Pinecone、FAISS 随便换
  4. 降门槛——不需要手写余弦相似度,不需要自己调 embedding API

三、核心数据结构:Document(知识的最小单元)

3.1 它长这样

new Document({
  pageContent: '光光是一个活泼开朗的小男孩...',  // ← 会转成向量,用来搜索
  metadata: {                                     // ← 不转向量,用来过滤
    chapter: 1,
    character: '光光',
    type: '角色介绍',
    mood: '活泼'
  }
})

3.2 pageContent vs metadata(面试高频区别)

pageContentmetadata
做 embedding?✅ 是❌ 否
参与搜索?✅ 语义匹配"像不像"❌ 精确匹配"是不是"
存什么文本正文标签:章节、作者、来源
搜索方式向量相似度(模糊)等号匹配(精确)
类比书的正文书的书脊标签

🔑 面试官追问:"metadata 为什么不也做向量化?"

答:没必要。metadata 是精确标签("第1章"、"角色=光光"),用等号就能解决,绕远路做向量化既浪费资源又不精确。两种搜索各司其职——向量搜"像不像",标签筛"是不是"。


四、手写完整 RAG 代码(逐行讲解)

技术栈:Node.js + LangChain + 阿里云 DashScope(OpenAI 兼容接口)

4.1 环境准备

npm install @langchain/openai @langchain/core @langchain/classic dotenv

.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:初始化模型 + 准备知识库

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:

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(一行代码完成向量化 + 存储)

const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings)

这一行代码背后做了两件事:

步骤做了什么
Embed把每个 Document 的 pageContent 调 embedding API 转成向量
Store把向量 + 原 Document 一起存入内存

4.5 Step 5:创建检索器 + 检索

// 把向量库包装成标准检索器接口
const retriever = vectorStore.asRetriever({ k: 3 })

// 检索最相关的 3 条文档
const question = '东东和光光是怎么成为朋友的'
const docs = await retriever.invoke(question)

retriever.invoke() 内部做了什么?

问题字符串 → embeddings.embedQuery() → 问题向量 → 计算余弦距离 → 返回 Top-K Document

4.6 彩蛋:带分数检索(调试神器)

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(增强 + 生成)

// 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()

similaritySearchsimilaritySearchWithScore
返回[Document, Document, Document][[Document, 0.85], [Document, 0.72], ...]
带分数
场景生产环境,只关心内容调试 / 过滤低分 / 评估检索质量

区别 3:pageContent vs metadata

pageContentmetadata
做 embedding?
搜索方式向量相似度精确匹配(像 SQL WHERE)
搜索语义"意思相近""是不是等于"
// 实战配合
const results = await vectorStore.similaritySearch('光光的朋友是谁', 5, {
  filter: { chapter: 1 }  // metadata 精确筛选:只看第1章
})
// 先 metadata 筛 → 再 pageContent 向量搜

区别 4:MemoryVectorStore vs 向量数据库

MemoryVectorStoreChroma / Pinecone / FAISS
数据位置内存磁盘
持久化❌ 程序关了数据就没了✅ 持久保存
适用场景学习 / 原型 / 数据量小生产环境 / 数据量大
配置零配置需要安装部署

区别 5:retriever.invoke() vs 直接调 vectorStore

retriever 不只是简单转发——它会做额外处理:

操作retriever裸 vectorStore
向量查询
去重
过滤
重排序(rerank)

retriever = vectorStore + 去重 + 过滤 + rerank。面试时能说出这个区别,会加分。


六、LCEL 链式调用(进阶但实用)

手动写法每步都要接数据、传数据,代码越长越乱。LangChain 的 LCEL(LangChain Expression Language)用 | 管道把步骤串起来:

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('东东和光光是怎么成为朋友的')

对比:

手动版: question → retriever.invoke() → 拿到docs → 手动拼prompt → model.invoke() → 拿到结果
链式版: question → [ retriever | prompt | model | outputParser ] → 最终结果

七、完整 RAG 流程图(可以写进简历)

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│  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() 一行代码背后做了哪两步?

答案
  1. Embed:把每个 Document 的 pageContent 调 embedding API 转成向量
  2. 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

先存后搜,搜了拼,拼了答。

存:文档 → Embedding → 向量库
搜:问题 → Embedding → 相似度 → Top-K 文档
拼:相关文档 → Prompt 模板
答:Prompt → LLM → 回答

代码核心就 5 行:

// 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 其实就这么简单。面试别再被问住了! 🚀