RAG 实战:给大模型装上"开卷考试"的外挂
摘要:RAG 让 LLM 从"闭卷考试"变为"开卷考试"。本文从定义出发,拆解 RAG 的建库与查询流程,用代码演示如何将 JSON 数据向量化并实现语义搜索。
📑 目录
- RAG 是什么?开卷考试 vs 闭卷考试
- RAG 的工作流程:建库与查询
- 切割策略:JSON 条目即 Chunk
- 代码实战:将 JSON 数据向量化
- 语义搜索:用余弦相似度找答案
- 一个完整的查询流程演示
- 核心误区:LLM 没有"预训练向量数据库"
- 一点总结
- 互动讨论
RAG 是什么?开卷考试 vs 闭卷考试
RAG(Retrieval-Augmented Generation)的核心思想很简单:让大模型在回答问题前,先去外部知识库检索相关材料,再基于这些材料生成答案。
- 传统 LLM:闭卷考试。全靠训练时背下来的知识,没学过就瞎编(幻觉)。
- 微调:考前突击补习。花大量时间把新知识强行灌进模型。
- RAG:开卷考试。允许带参考书进考场,不知道就翻书。
RAG = 检索(Retrieval)+ 增强(Augmented)+ 生成(Generation)。它不是让 LLM 变聪明,而是让 LLM 每次答题都带上"参考书"。
RAG 的工作流程:建库与查询
RAG 分为两个阶段:
阶段一:建库(离线,做一次就行)
text
javascript
原始数据(JSON/PDF/Word)
↓
文档切块(Chunking)→ 切成小段落
↓
向量化(Embedding)→ 每段文字转为高维向量
↓
存储(Store)→ 存入向量数据库
阶段二:查询(在线,每次提问都执行)
text
css
用户提问
↓
问题向量化 → 转为向量
↓
相似度检索 → 在向量数据库中找最相似的 Top-K 个段落
↓
增强拼接 → 将检索到的原文拼成 Prompt
↓
生成答案 → LLM 基于 Prompt 回答
切割策略:JSON 条目即 Chunk
在 RAG 系统中,文档切块(Chunking) 是非常关键的一步。切得太粗,检索精度低;切得太碎,上下文丢失。
常见的切块策略有三种:
| 策略 | 说明 |
|---|---|
| 固定大小分块 | 每段固定字数,简单但可能切断语义 |
| 语义分块 | 按段落、章节或句子边界切分 |
| 重叠分块 | 相邻段落有重叠,保证上下文衔接 |
在本次实现中,我采用了以 JSON 条目为最小 Chunk 单元 的切分方式。每条文章记录(包含 title 和 category)作为一个独立的向量单元。
json
json
// 一个 JSON 条目 = 一个 Chunk
{
"title": "如何使用 Nuxt.js 进行服务器端渲染",
"category": "前端开发"
}
这样做的好处:
- 语义完整:每条文章本身就是一个完整的知识单元,不会被切断。
- 结构清晰 :
title + category的组合提供了丰富的语义上下文。 - 检索精准:用户搜索"Vue 响应式布局"时,能直接匹配到相关文章标题。
代码实战:将 JSON 数据向量化
create-embedding.mjs 实现了建库阶段的核心逻辑:读取 JSON 数据 → 逐条向量化 → 保存结果。
javascript
javascript
import fs from 'fs/promises';
import { client } from './app.service.mjs';
const inputFilePath = './data/posts.json';
const outputFilePath = './data/posts-embedding.json';
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);
const postsWithEmbedding = [];
for (const { title, category } of posts) {
console.log(title, category, 'embedding...');
const response = await client.embeddings.create({
model: 'text-embedding-v4',
input: `标题:${title},分类:${category}`
});
postsWithEmbedding.push({
title,
category,
embedding: response.data[0].embedding
});
await sleep(200); // 防止限流
}
await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));
关键设计:
- 每条 JSON 记录独立向量化 :每条文章的
title + category被拼成一段文本,由 Embedding 模型转化为 1024 维向量。 - input 加中文前缀强化语义 :
标题:${title},分类:${category}让模型更精准地理解这段文本的结构。 - 200ms 延时:防止请求过快被大模型服务限流。
输出的 posts-embedding.json 长这样:
json
less
[ { "title": "如何使用 Nuxt.js 进行服务器端渲染", "category": "前端开发", "embedding": [0.0341, -0.0671, 0.0435, ...] // 1024 维向量
}
]
语义搜索:用余弦相似度找答案
查询阶段的核心是计算余弦相似度------用用户问题的向量去匹配已存储的所有 Chunk 向量,找出最相似的。
javascript
javascript
function cosineSimilarity(v1, v2) {
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
return dotProduct / (lengthV1 * lengthV2);
}
余弦相似度的值介于 -1 和 1 之间,越接近 1 表示两个向量的方向越一致,语义越相似。
语义搜索的威力:用户搜索"马铃薯怎么做",传统关键词匹配找不到"酸辣土豆丝",但向量搜索能理解"马铃薯"和"土豆"是同一个东西,返回相关结果。这就是 Embedding 将语义相似度转化为数学距离计算。
一个完整的查询流程演示
semantic-search.mjs 实现了一个完整的命令行交互搜索:
javascript
javascript
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const handleInput = async (answer) => {
// 1. 用户问题 → 向量
const response = await client.embeddings.create({
model: 'text-embedding-v4',
input: answer
});
const { embedding } = response.data[0];
// 2. 计算所有 Chunk 的相似度并排序
const results = posts
.map(item => ({
...item,
similarity: cosineSimilarity(embedding, item.embedding)
}))
.sort((a, b) => a.similarity - b.similarity)
.reverse()
.slice(0, 3) // 取 Top 3
.map((item, index) => `${index + 1}.${item.title}.${item.category}`)
.join('\n');
console.log(`\n搜索结果:\n${results}`);
rl.question("\n请输入你要搜索的内容:", handleInput);
};
rl.question("\n请输入你要搜索的内容:", handleInput);
用户输入"Vue 响应式布局"时,系统会:
- 将问题向量化
- 与所有 Chunk 的 embedding 计算余弦相似度
- 返回相似度最高的 3 篇文章标题
- 这些标题/内容可以作为上下文,喂给 LLM 生成最终答案
核心误区:LLM 没有"预训练向量数据库"
关于 RAG,有几个常见的误区需要澄清:
| 常见误区 | ✅ 真相 |
|---|---|
| ❌ "LLM 有预训练向量数据库,RAG 就是结合两个库。" | ✅ LLM 根本没有"预训练向量数据库"! 训练完成后,训练语料已经被压缩成了固定的模型权重(神经网络参数),LLM 无法"检索"自己的训练数据。 |
| ❌ "RAG 是让 LLM 用自己的知识 + 外部知识一起回答。" | ✅ RAG 是强行把外部资料"塞进"LLM 的上下文(Prompt) ,让 LLM 完全基于这些资料来回答。LLM 只是充当"优秀阅读理解者"的角色。 |
| ❌ "RAG 是让 LLM 变聪明。" | ✅ RAG 是让 LLM"带书考试" ,它本身的智力(模型权重)没有变化,只是每次考试都带了参考书。 |
也就是说,RAG 本质上是一种上下文工程:把检索到的内容拼进 Prompt,让 LLM 根据这些内容生成答案。它不修改模型本身。
一点总结
通过这次 RAG 实战,我理解了:
- RAG 的核心是检索 + 生成:先查资料,再回答,让 LLM 从闭卷变开卷。
- Chunk 策略影响检索效果:以 JSON 条目为 Chunk 单元,语义完整、检索精准。
- Embedding 将语义转化为数学计算:余弦相似度让机器能"理解"语义相近的文本。
- RAG 不改变模型本身:它只是每次答题时给 LLM 带上"参考书",解决的是知识更新和私有数据的问题。
- LLM 没有预训练向量数据库:训练完成后,数据已经被压缩成模型权重,无法检索。RAG 是唯一能让 LLM 访问外部知识的手段。
互动讨论
- 为什么固定大小分块可能导致语义丢失? 你遇到过因为切块不当导致检索不准的情况吗?
title + category作为 Chunk 的输入文本,如果换成title + category + 正文摘要,效果会更好吗?- 余弦相似度的值在什么范围内表示强相关? 你平时会设一个阈值来过滤低相关结果吗?
- 如果你的知识库有 100 万条数据,遍历计算余弦相似度会非常慢,有什么优化方案?
- RAG 和微调(Fine-tuning)有什么区别? 什么时候用 RAG,什么时候用微调?
📌 一点心得:RAG 的价值不在于"让 AI 更聪明",而在于"让 AI 在特定场景下更靠谱"。它不修改模型,却能让模型回答出原本答不出的私有知识问题。