RAG 知识库问答:从概念到代码的完整实现
一、问题的起源:LLM 的两个致命缺陷
大语言模型(LLM)在 2024-2025 年席卷了软件开发,但每个使用过 GPT 类产品的人都能感知到两个"不舒服":
-
幻觉(Hallucination):模型会自信地编造不存在的信息。你问"本项目的架构是什么",它答得头头是道------全是虚构的。
-
知识截止:训练数据有时效性。问"React 19 有哪些新特性",如果模型训练于 2023 年,它压根不知道 React 19 的存在。
RAG(Retrieval-Augmented Generation,检索增强生成) 就是为解决这两个问题而生的。它的核心思想一句话就能说完:
在让 LLM 回答之前,先帮它"翻书"------从外部知识库中检索相关文档,塞进 prompt,再让模型基于这些真实素材作答。
二、RAG 的三步架构
vbnet
┌──────────────────────────────────────────────┐
│ 用户提问 │
│ "React 是什么?" │
└──────────────────┬───────────────────────────┘
│
┌───────────▼───────────┐
│ Step 1: RETRIEVE │ ← 检索
│ 向量相似度搜索 │
│ 从知识库找最相关的文档 │
└───────────┬───────────┘
│ 相关文档
┌───────────▼───────────┐
│ Step 2: AUGMENT │ ← 增强
│ 将文档注入 Prompt │
│ "请基于以下资料回答..." │
└───────────┬───────────┘
│ 增强后的 Prompt
┌───────────▼───────────┐
│ Step 3: GENERATE │ ← 生成
│ LLM 基于上下文回答问题 │
│ 输出最终答案 │
└───────────┬───────────┘
│
▼
最终答案
这三个步骤的首字母就是 R-A-G。下面结合本项目代码逐一深入。
三、Step 1 --- Retrieve(检索):从知识库中"翻书"
3.1 知识库的构建
在检索之前,需要先有一个可被检索的知识库 。本项目的知识库由硬编码的三条 Document 构成(ai.service.ts:134-147):
typescript
const vectorStore = await MemoryVectorStore.fromDocuments(
[
new Document({
pageContent: 'React是一个用于构建用户界面的JavaScript库。'
}),
new Document({
pageContent: 'NestJS是一个用于构建服务器端应用的JavaScript框架,擅长企业级开发。'
}),
new Document({
pageContent: 'RAG通过检索外部知识增强大模型的回答能力。'
}),
],
this.embeddings,
)
MemoryVectorStore.fromDocuments() 做了什么?
这是 LangChain 提供的内存向量数据库。调用 fromDocuments(docs, embeddings) 时,内部执行了两步:
- 向量化(Embedding) :将每一篇 Document 的
pageContent文本送入OpenAIEmbeddings(text-embedding-ada-002模型),生成一个 1536 维的浮点数向量 - 建索引(Indexing):将所有向量存储在内存数组中,等待后续相似度搜索
结果是一个包含三条记录的向量数据库:
| Document | 内容 | 向量 |
|---|---|---|
| Doc #1 | React 是一个用于构建用户界面的... | [0.014, -0.009, 0.021, ...] (1536维) |
| Doc #2 | NestJS 是一个用于构建服务器端... | [0.008, 0.013, -0.017, ...] (1536维) |
| Doc #3 | RAG 通过检索外部知识增强... | [-0.011, 0.019, 0.006, ...] (1536维) |
3.2 相似度搜索
当用户提问 "React 是什么?" 时(ai.service.ts:149):
typescript
const docs = await vectorStore.similaritySearch(question, 1);
similaritySearch 的执行流程:
perl
1. 将 question "React 是什么?" 送入 embedding 模型
→ 得到查询向量 Q = [0.013, -0.008, 0.022, ...]
2. 计算 Q 与每条文档向量的余弦相似度:
cos(Q, Doc#1) = 0.95 ← 最高,语义匹配
cos(Q, Doc#2) = 0.12
cos(Q, Doc#3) = 0.23
3. 返回相似度最高的 1 条文档 → Doc#1
为什么是余弦相似度? 已经在搜索功能文章中详解过,这里不再展开。简单说:余弦相似度衡量两个向量在方向上的接近程度,范围 [-1, 1],值越接近 1 表示语义越相似。它不关心向量长度,只看方向------适合比较不同长度的文本。
3.3 内存向量数据库 vs 生产级向量数据库
本项目使用 MemoryVectorStore,优点是零配置、零依赖,适合学习和原型开发。但它有一个明显的局限:数据存在内存中,重启进程就丢失 。(本文的 rag() 方法每次调用都重新建库,恰好规避了这个问题,但代价是每次都重新调用 embedding API。)
生产环境中常见的向量数据库选型:
| 数据库 | 特点 |
|---|---|
| Pinecone | 全托管,零运维,按使用量计费 |
| Weaviate | 开源,支持 GraphQL 查询,可自托管 |
| Qdrant | Rust 实现,高性能,极低资源消耗 |
| pgvector | PostgreSQL 插件,与业务库合为一体 |
| Chroma | 开源,Python/JS 双语言 SDK,开发者友好 |
四、Step 2 --- Augment(增强):把知识注入 Prompt
检索到的文档不能直接喂给 LLM------需要构建一个结构化的 Prompt,告诉模型"基于这些资料来回答"。
typescript
const context = docs.map(d => d.pageContent).join('\n');
const prompt = `
你是一个专业的JS工程师,请基于下面资料回答问题。
资料:${context}
问题:${question}
`;
这段代码完成了一次上下文组装:
sql
┌─────────────────────────────────┐
│ 你是一个专业的JS工程师, │ ← 角色设定(System Prompt)
│ 请基于下面资料回答问题。 │ ← 约束指令
│ │
│ 资料: │
│ React是一个用于构建用户界面的 │ ← 检索到的外部知识
│ JavaScript库。 │ ← (RAG 的核心注入点)
│ │
│ 问题: │
│ React 是什么? │ ← 用户原始问题
└─────────────────────────────────┘
Prompt 设计的四个要素:
- 角色设定("专业的JS工程师"):锚定模型的回答立场和风格
- 约束指令("请基于下面资料回答问题"):这是 RAG 最关键的一句话------它告诉模型不要用训练数据中的记忆,而是严格基于提供的资料作答。这句话是防止幻觉的关键防线
- 知识注入 (
资料:${context}):检索结果的"着陆点" - 用户提问 (
问题:${question}):保持用户原始问题的完整性
五、Step 3 --- Generate(生成):LLM 基于增强 Prompt 作答
typescript
const res = await this.chatModel.invoke(prompt);
return res.content;
chatModel 是 DeepSeek 的实例(deepseek-v4-flash 模型),配置了 temperature: 0.7。invoke(prompt) 将组装好的 prompt 发送给 DeepSeek,等待模型逐字生成回答。
整个过程的信息流:
arduino
问题: "React 是什么?"
│
▼
┌──────────────┐
│ 向量检索 │ 知识库中找最相关的文档
│ │ → "React是一个用于构建用户界面的JavaScript库。"
└──────┬───────┘
│ 检索结果
▼
┌──────────────┐
│ Prompt 组装 │ 检索结果 + 角色设定 + 用户问题
│ │ → "请基于下面资料回答问题..."
└──────┬───────┘
│ 增强后的 Prompt
▼
┌──────────────┐
│ DeepSeek │ 基于 Prompt 中的资料生成回答
│ LLM 生成 │ → "React 是一个用于构建用户界面的 JavaScript 库..."
└──────┬───────┘
│
▼
返回给用户
对比------有 RAG vs 无 RAG:
| 维度 | 无 RAG(纯 LLM) | 有 RAG |
|---|---|---|
| Prompt | "React 是什么?" | "你是一个JS工程师,请基于资料:React是一个用于构建用户界面的JavaScript库。回答问题:React是什么?" |
| 信息来源 | 训练数据(可能过时) | 外部知识库(实时、可控) |
| 答题准确性 | 无法验证 | 可溯源到指定资料 |
| 幻觉风险 | 高 | 低(被 prompt 约束) |
六、控制器层:RAG 接口
typescript
@Post('rag')
async rag(@Body() { question }: { question: string }) {
const answer = await this.aiService.rag(question);
return {
code: 0,
answer
}
}
与 Search 接口(GET /api/ai/search)不同,RAG 接口使用 POST 方法,因为 question 可能很长(用户可能输入多句话),不适合放在 URL 查询参数中。
返回结构标准化为 { code, answer },与 Search 的 { code, data } 保持一致的 API 风格约定。
七、RAG vs 语义搜索:两个功能的界限
本项目同时实现了语义搜索(ai.service.ts:102-117)和 RAG(ai.service.ts:130-164),它们的区别清晰:
arduino
语义搜索 RAG
───────── ─────
输入: "React" 输入: "React 是什么?"
│
│ ▼
▼ ┌──────────┐
┌────────────┐ │ 检索文档 │
│ 向量相似度 │ └────┬─────┘
│ 排序 │ │
└─────┬──────┘ ┌────▼─────┐
│ │ 注入LLM │
▼ └────┬─────┘
返回标题列表 │
["如何使用React...", ┌────▼─────┐
"如何在React中..."] │ LLM生成 │
│ 自然语言 │
│ 回答 │
└──────────┘
输出: 文章列表 输出: 一段完整的文字回答
搜索 告诉你"有哪些相关文章";RAG 直接回答你的问题。两者都基于向量检索,但 RAG 多了一步------把检索结果交给 LLM 做二次加工。
八、终端演示:语义搜索命令行工具
demo/embedding-demo/semantic-search.mjs 是一个独立的命令行搜索工具,演示了语义搜索的最简实现:
javascript
// 加载带向量的文章数据
const posts = JSON.parse(await fs.readFile('./data/posts-embedding.json', 'utf-8'));
// 交互式输入循环
rl.question('\n 请输入搜索内容: ', handleInput);
const handleInput = async (input) => {
// 1. 将用户输入转为向量
const { embedding } = (await client.embeddings.create({
model: 'text-embedding-ada-002',
input,
})).data[0];
// 2. 计算余弦相似度 → 排序 → Top 3
const results = posts.map(item => ({
...item,
similarity: cosineSimilarity(embedding, item.embedding),
}))
.sort((a, b) => a.similarity - b.similarity)
.reverse()
.slice(0, 3)
.map((item, i) => `${i + 1}. ${item.title}, ${item.category}`)
.join('\n');
console.log(`\n${results}\n`);
rl.question('\n 请输入搜索内容: ', handleInput);
};
运行效果示例:
markdown
请输入搜索内容: 移动端开发
1. 如何使用 Flutter 开发一个简单的计数器应用, 移动开发
2. 如何使用 React Native 开发跨平台移动应用, 移动开发
3. 如何使用 Kotlin 和 Android Studio 开发一个简单的 Todo 应用, 移动开发
请输入搜索内容:
这个 Demo 的价值在于:它是 RAG 中"R"步骤的教学版本------不涉及 LLM 调用,只演示向量检索,让人专注于理解"如何从一堆文章中找到语义最匹配的那几篇"。
九、生产环境中的 RAG 进阶
本项目实现的是一个最小可用的 RAG 示例------三条硬编码文档、内存向量库、单次 LLM 调用。从这出发,生产级 RAG 系统通常会在以下方向扩展:
9.1 文档分块(Chunking)
长文档不能整篇作为单个 Document------超过 embedding 模型的上下文限制(text-embedding-ada-002 单次最多 8191 token),且检索精度下降。常见做法是按语义边界切块:
yaml
10000 字的文章
│
▼ 语义分块 (semantic-splitter)
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│Chunk1│ │Chunk2│ │Chunk3│ │Chunk4│ │Chunk5│
│ 1800字│ │ 2000字│ │ 1700字│ │ 2100字│ │ 2400字│
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
每个 chunk 独立向量化,检索时返回与问题最相似的 K 个 chunk。
9.2 持久化向量库
如第三节所述,MemoryVectorStore 只存在于内存。生产环境使用 Pinecone、Qdrant 等持久化方案,初始化一次后持续服务。
9.3 混合检索(Hybrid Search)
纯向量检索有时会漏掉精确关键词匹配。混合检索 同时做向量搜索和关键词搜索(如 BM25 算法),将两边结果融合排序。
9.4 Rerank(重排序)
初步检索返回 Top N 条文档后,用一个专门的重排序模型(如 Cohere Rerank)对候选文档做更精细的相关性判断,精选出最合适的 Top K 条送入 LLM。这能显著提升回答质量。
9.5 引用溯源
在生成的回答中标注每句话的信息来源------这是 RAG 区别于纯 LLM 的核心优势。实现方式是要求 LLM 在生成时输出引用标记,前端渲染为可点击的脚注链接。
十、总结
RAG 的本质是一个给 LLM 配了一个"参考书管理员"的架构。当用户提问时,不是直接把问题丢给模型,而是先检索相关文档,把文档内容注入 prompt,再让模型基于这些真实素材作答。
项目代码中,三步对应关系清晰:
| 步骤 | 代码位置 | 核心操作 |
|---|---|---|
| Retrieve | MemoryVectorStore.fromDocuments() + similaritySearch() |
文档向量化 + 余弦相似度检索 |
| Augment | Prompt 模板字符串拼接 | 检索结果 + 角色 + 问题注入 prompt |
| Generate | chatModel.invoke(prompt) |
DeepSeek 基于增强 prompt 生成答案 |
理解了这三步,你就掌握了 RAG 的全部基本原理。剩下的------换更强大的检索策略、换更大的知识库、换更复杂的 prompt 模板------都是在这个骨架上的皮肉填充。