RAG 是什么?从关键词搜索到语义搜索的完整过程

0 阅读8分钟

rag-retrieve-then-answer-green-ip-20260709.webp

前面讲上下文工程的时候,我们提到过一个问题:

大模型本身并不知道你的私有资料。

它不知道你公司的内部文档,不知道数据库里的最新记录,也不知道你本地项目里刚写完的代码。

如果只靠模型训练时学到的知识,它很容易出现两种情况:

  • 不知道最新信息
  • 一本正经地胡说

这就是为什么现在很多 AI 应用不会只把问题丢给大模型,而是会在回答前先去“找资料”。

这个过程,就是理解 RAG 的入口。

RAG 的全称是 Retrieval-Augmented Generation,中文一般叫“检索增强生成”。

拆开看就是三步:

Retrieval   检索
Augmented   增强
Generation  生成

一句话说:

RAG 不是让大模型凭空回答,而是先从外部资料里检索相关内容,再把这些内容作为上下文交给大模型生成答案。

为什么需要 RAG?

先看一个普通搜索问题。

假设我们有一批文章数据:

[
  {
    "title": "如何使用 Nuxt.js 进行服务器端渲染",
    "category": "前端开发"
  },
  {
    "title": "使用 Nest.js 和 TypeScript 构建一个简单的微服务应用",
    "category": "后端开发"
  },
  {
    "title": "如何在 Vue.js 中使用 Vuetify 实现 Material Design 风格",
    "category": "前端开发"
  }
]

如果用户搜索:

有哪些 Vue 相关的内容?

传统关键词搜索可以做:

title like '%Vue%'

这当然能找到一部分结果。

但关键词搜索有一个明显问题:

它更关心“字面上有没有出现这个词”,不是真的理解语义。

比如:

酸辣土豆丝的做法

和:

马铃薯怎么做?

这两个问题在字面上不一样,一个叫“土豆”,一个叫“马铃薯”。

但人一看就知道它们语义接近。

传统关键词搜索可能会漏掉这种关系。

RAG 的第一步,就是先解决“怎么找到真正相关的资料”。

从关键词搜索到语义搜索

rag-keyword-vs-semantic-green-ip-20260709.webp

语义搜索的核心是 Embedding。

Embedding 可以理解成:

把一段文字转换成一组数字。

这组数字不是随便来的,它会尽量保留文字的语义信息。

比如:

Vue 组件库怎么做?
前端如何做可视化组件库?
Nuxt.js 服务器端渲染怎么实现?

这些文字会被转换成向量。

语义越接近,向量在空间里的距离通常也越近。

所以搜索时,不再只是判断某个词有没有出现,而是:

用户问题 -> 转成向量
文章内容 -> 提前转成向量
计算相似度 -> 找到最接近的文章

这就是语义搜索。

一个最小 RAG Demo

我们用一个本地文章搜索的例子来理解。

项目结构大概是:

posts-demo
├── app.service.mjs
├── create-embedding.mjs
├── semantic-search.mjs
└── data
    ├── posts.json
    └── posts-embedding.json

其中 posts.json 是原始文章数据。

每条数据只有两个字段:

{
  "title": "如何使用 Nuxt.js 进行服务器端渲染",
  "category": "前端开发"
}

我们要做的事情是:

  1. 先把每篇文章转成 embedding
  2. 把 embedding 存到本地 JSON 文件里
  3. 用户输入问题时,把问题也转成 embedding
  4. 计算问题和文章之间的相似度
  5. 返回最相关的文章

这其实是 RAG 的前半段,也就是 Retrieval。

真正完整的 RAG,还会继续把检索结果放进 prompt,再让大模型生成最终答案。

第一步:准备 Embedding Client

项目里用 OpenAI SDK 兼容模式调用通义千问的 DashScope 接口:

import OpenAI from 'openai';
import dotenv from 'dotenv';

dotenv.config();

export const client = new OpenAI({
  apiKey: process.env.DASHSCOPE_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});

这里有两个关键点:

  • DASHSCOPE_API_KEY 放在 .env 里,不要写死到代码中
  • baseURL 指向 DashScope 的 OpenAI 兼容接口

这样后面就可以像调用 OpenAI SDK 一样调用 embedding 接口。

第二步:把文章转成向量

原始文章数据是人能看懂的文本。

但语义搜索需要向量。

所以我们先写一个脚本,把所有文章提前向量化:

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) {
  const response = await client.embeddings.create({
    model: 'text-embedding-v4',
    input: `标题:${title},分类:${category}`
  });

  postsWithEmbedding.push({
    title,
    category,
    embedding: response.data[0].embedding
  });
}

await fs.writeFile(
  outputFilePath,
  JSON.stringify(postsWithEmbedding, null, 2)
);

这一步做完后,posts-embedding.json 里就不只是文章标题了,还会多出一个 embedding 字段。

它看起来大概是这样:

{
  "title": "如何使用 Nuxt.js 进行服务器端渲染",
  "category": "前端开发",
  "embedding": [0.0123, -0.0345, 0.0876]
}

真实 embedding 会很长。

这个 demo 里每条 embedding 是 1024 维。

可以把它理解成:

模型给这篇文章生成了一张“语义坐标”。

以后搜索时,不用每次重新处理所有文章,只要读取已经保存好的 embedding 即可。

第三步:把用户问题也转成向量

文章已经向量化了。

用户输入问题时,也要做同样的事情。

比如用户输入:

有哪些前端可视化相关内容?

程序会调用 embedding 接口:

const response = await client.embeddings.create({
  model: 'text-embedding-v4',
  input: answer
});

const { embedding } = response.data[0];

现在,用户问题也变成了一组向量。

接下来就可以比较:

用户问题的向量
和
每篇文章的向量
谁更接近?

第四步:计算相似度

向量之间怎么比较接近程度?

常见做法是余弦相似度。

代码是:

const 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);
};

不用被公式吓到。

可以先这么理解:

余弦相似度不是看两个向量“数值差多少”,而是看它们“方向像不像”。

方向越接近,说明语义越接近。

所以我们可以对每篇文章算一个分数:

const results = posts
  .map(item => ({
    ...item,
    similarity: cosineSimilarity(embedding, item.embedding)
  }))
  .sort((a, b) => b.similarity - a.similarity)
  .slice(0, 3);

最后返回相似度最高的前三篇文章。

这就是最小语义搜索。

这和 RAG 有什么关系?

到这里,我们其实还没有完成完整 RAG。

我们完成的是:

Retrieval:检索

也就是从资料库中找到最相关的内容。

完整 RAG 还需要后两步:

Augmented:把检索结果塞进 prompt,补充上下文
Generation:让大模型基于这些上下文生成答案

完整流程应该是:

用户问题
  ↓
问题向量化
  ↓
向量相似度检索
  ↓
取出 Top K 相关资料
  ↓
把资料放进 prompt
  ↓
LLM 基于资料生成答案

也就是说:

语义搜索解决的是“找资料”。

RAG 解决的是“找完资料以后,让模型基于资料回答”。

RAG 不是让模型重新学习

这里有一个常见误解:

很多人以为 RAG 是把资料“训练进模型”。

不是。

RAG 不修改模型参数。

它更像考试时开卷。

模型本身还是原来的模型。

只是回答问题前,系统先帮它翻出几页相关资料,然后把资料放到它面前。

所以 RAG 的优势是:

  • 更新知识更方便
  • 不需要重新训练模型
  • 可以接入私有知识库
  • 可以减少幻觉
  • 可以让回答更可追溯

但它也有边界。

如果检索阶段找错了资料,后面的生成也会跟着跑偏。

所以 RAG 的效果,很大程度取决于检索质量。

RAG 和普通搜索有什么不同?

普通搜索更像:

用户输入关键词
系统返回相关文档列表

RAG 更像:

用户提出问题
系统先找资料
再让大模型根据资料组织答案

区别在最后一步。

搜索引擎通常把结果列表交给用户。

RAG 会把结果交给大模型,让模型帮用户读、整理、归纳、回答。

所以 RAG 适合这些场景:

  • 企业知识库问答
  • 客服机器人
  • 法律文档检索
  • 项目代码库问答
  • 课程资料问答
  • 内部制度查询
  • 产品文档助手

用户不想自己翻一堆资料。

用户想直接问:

这个项目里登录逻辑在哪里?

或者:

公司报销超过 5000 元要走什么流程?

这时 RAG 就很有价值。

生产环境里还缺什么?

这个 demo 用 JSON 文件保存 embedding。

这适合学习。

但真实项目里,数据量一大,就需要专门的向量数据库或支持向量检索的存储。

比如:

  • Milvus
  • PostgreSQL + pgvector
  • Elasticsearch 向量检索
  • 向量数据库服务

真实 RAG 系统通常还要考虑:

  • 文档怎么切块
  • chunk 多大合适
  • metadata 怎么存
  • 召回 Top K 取多少
  • 检索结果要不要重排
  • prompt 怎么组织上下文
  • 引用来源怎么返回
  • 敏感资料怎么做权限控制
  • 检索不到资料时怎么回答

所以 RAG 看起来只是“检索 + 生成”,但真正做成产品时,它其实是一个上下文工程系统。

小结

RAG 的核心不是让模型变成数据库。

它真正解决的是:

大模型回答前,如何先拿到正确的外部资料。

关键词搜索只能匹配字面文本。

Embedding 让我们可以按语义检索资料。

语义搜索找到相关内容后,再把内容放进 prompt,让 LLM 基于上下文生成答案,这就是 RAG。

所以可以这样记:

Embedding 让资料可检索
Retrieval 找到相关资料
Augment 把资料塞进上下文
Generation 让模型基于资料回答

RAG 是上下文工程里最常见、也最容易落地的一种方式。

它把大模型从“凭记忆回答”,推进到了“带资料回答”。