一个前端开发者视角的 RAG 入门实战:从"只会写 CRUD"到"能解释清楚向量检索是怎么工作的"。
一、为什么要学 RAG?
2024 年以来,RAG(Retrieval-Augmented Generation,检索增强生成)几乎出现在每一个 AI 产品的技术栈里。不管是智能客服、企业知识库问答、还是 AI 搜索,核心套路都是同一套:
用户提问 → 检索相关文档 → 把文档塞进 Prompt → LLM 生成回答
这听起来不复杂,但"检索相关文档"这一步藏着整个系统的灵魂——你让 LLM 回答"公司年假怎么算",它需要先找到《员工手册》里对应的段落,而不是凭空编一个。
我决定不只看文章、看视频,而是自己从头写一个最简化的 RAG Demo,用代码把每个环节都跑一遍。这篇文章就是这个过程的完整记录。
二、先看成品:它能做什么?
搜索"Vue 相关的文章",结果如下:
请输入查询内容:vue相关的内容
最相似的3篇文章是:
1. 如何在 Vue.js 中使用 Vuetify 实现 Material Design 风格.前端开发
2. 如何使用 Vue.js 和 Electron 开发桌面应用程序.前端开发
3. 如何使用 Nuxt.js 和 Firebase 实现服务器端渲染的无后端应用.前端开发
注意 —— 第三条标题里根本没有出现 "Vue" 这个单词,但系统知道 Nuxt.js 是 Vue 生态的一部分,所以把它排在了第三。这就是语义搜索和传统关键词搜索的本质区别。
传统搜索像 Ctrl+F:字符匹配,搜"马铃薯"找不到"土豆" 语义搜索像懂你的人:把文字映射成向量,含义近的向量距离也近
三、整体架构(其实就两个文件)
这个 Demo 的结构出奇地简单:
posts-demo/
├── .env # API Key 配置
├── app.service.mjs # 共享的 OpenAI client 实例
├── create-embedding.mjs # 离线:给所有文章生成向量
├── semantic-search.mjs # 在线:接收查询,返回 Top-3
└── data/
├── posts.json # 34 篇技术文章的标题+分类
└── posts.embedding.json # 同样的数据,多了一个 embedding 字段
整个系统分两个阶段运行,泾渭分明:
┌─── 离线阶段 ─────────────────────────┐
│ │
│ posts.json │
│ │ │
│ ▼ │
│ 逐条请求 Embedding API │
│ (text-embedding-v4) │
│ │ │
│ ▼ │
│ posts.embedding.json │
│ [{title, category, embedding:[…]}] │
└──────────────────────────────────────┘
┌─── 在线阶段 ─────────────────────────┐
│ │
│ 用户输入 "vue相关的内容" │
│ │ │
│ ▼ │
│ 调用 Embedding API → 查询向量 │
│ │ │
│ ▼ │
│ 与 34 篇文章向量逐个计算余弦相似度 │
│ │ │
│ ▼ │
│ 排序 → 取 Top-3 → 输出结果 │
└──────────────────────────────────────┘
四、第一步:搭建基础设施
4.1 选择 Embedding 服务
我用了阿里云的 DashScope,它的 text-embedding-v4 模型兼容 OpenAI SDK 格式,这意味着只需换一个 baseURL 就能用上。
# .env
EMBEDDING_API_KEY=sk-xxx
EMBEDDING_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
4.2 抽离共享模块
这是一个容易被新手忽略但非常重要的工程习惯——把可复用的部分抽出来:
// app.service.mjs
import Openai from 'openai';
import dotenv from 'dotenv';
dotenv.config();
export const client = new Openai({
apiKey: process.env.EMBEDDING_API_KEY,
baseURL: process.env.EMBEDDING_API_BASE_URL,
});
两个核心文件都 import { client } from './app.service.mjs',不用重复配置。未来换模型、换服务商,只改这一个文件。
心得:这看起来是件小事,但项目规模变大后,散落在各处的 API Key 配置是维护的噩梦。从一开始就养成抽离的习惯。
五、第二步:把文章变成向量(核心)
这是 RAG 最关键的预处理步骤。代码在 create-embedding.mjs,一共不到 50 行,但每一步都值得讲清楚。
5.1 用 fs/promises 读写文件
import fs from 'fs/promises';
const data = await fs.readFile('./data/posts.json', 'utf-8');
const posts = JSON.parse(data);
这里有个容易被忽略的选择:为什么是 fs/promises 而不是普通的 fs?
Node.js 的 fs 模块有三种用法:
| 写法 | 特点 |
|---|---|
fs.readFile(p, callback) | 回调风格,容易陷入回调地狱 |
fs.readFileSync(p) | 阻塞线程,不适合服务端 |
fs/promises + await | ✅ 非阻塞 + 代码像同步一样清晰 |
文件 I/O 是毫秒级的"慢操作",用 await 等待时 CPU 完全不浪费,可以继续处理 HTTP 请求或别的事。这是 JavaScript 异步模型的精髓——等,但不堵路。
5.2 调用 Embedding API
for (const { title, category } of posts) {
const response = await client.embeddings.create({
input: `标题:${title}\n分类:${category}`, // ← 结构化拼接
model: 'text-embedding-v4',
});
postsWithEmbeddings.push({
title,
category,
embedding: response.data[0].embedding, // number[],通常是 1024 维
});
}
两个值得关注的细节:
① 模板字符串拼接
注意 `标题:${title}\n分类:${category}` 这里用的是反引号,不是单引号。反引号(模板字符串)支持 ${} 插入变量,单引号只能写纯文本。如果强行用单引号:
// ❌ 又丑又容易拼错
input: '标题:' + title + '\n分类:' + category
// ✅ 清晰直观
input: `标题:${title}\n分类:${category}`
② 为什么要加"标题:"和"分类:"前缀?
直接把 title 丢给 API 当然也可以,但加上结构化标签后,模型更清楚每个字段的语义角色。这跟写 Prompt 时标注 ## 角色、## 任务 是同一个道理——给模型更明确的上下文,检索精度会更高。
5.3 控制请求频率:手写 sleep 函数
const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
};
// 在循环中使用
for (...) {
await client.embeddings.create({...});
await sleep(200); // 每次请求间隔 200ms
}
为什么要 sleep? API 都有速率限制(Rate Limit),34 篇文章如果在 0.1 秒内全部发出,服务端会直接拒绝。sleep(200) 把频率控制在 ≤5 QPS,安全稳定。
为什么不像 Python 那样直接 time.sleep(1)? 因为 JavaScript 是单线程事件循环模型:
await sleep(200)
→ new Promise(resolve => setTimeout(resolve, 200))
→ setTimeout 把 resolve 交给定时器线程,主线程不阻塞
→ 200ms 后 resolve() 被放入微任务队列
→ 事件循环执行它,await 恢复,继续下一次循环
关键认知:sleep 不是让 CPU 空转等 200ms,而是把控制权交还给事件循环。这 200ms 里 Node.js 可以去处理别的事情。
六、第三步:语义搜索
有了 posts.embedding.json(每篇文章都带上了向量),在线搜索就简单了。代码在 semantic-search.mjs。
6.1 余弦相似度:衡量两个向量"有多像"
这是整个 RAG 系统的数学核心。别怕,公式翻译成代码就 5 行:
const cosineSimilarity = (v1, v2) => {
// ① 点积:对应位置相乘再求和
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
// ② 向量长度(L2 范数)
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 → 语义相同
- 两个箭头互相垂直 → 相似度 ≈ 0 → 语义无关
- 两个箭头指向完全相反 → 相似度 ≈ -1 → 语义对立
为什么要除以长度的乘积?因为要归一化——只关心向量的"方向",不关心"长短"。否则一段长文本天然比短文本得分高,这就不公平了。
6.2 完整的搜索流程
const handleInput = async (input) => {
// 1. 把用户输入也变成向量
const response = await client.embeddings.create({
model: 'text-embedding-v4',
input: input,
});
const { embedding } = response.data[0];
// 2. 计算与所有文章的相似度,排序取 Top-3
const result = posts
.map(item => ({
...item,
similarity: cosineSimilarity(embedding, item.embedding),
}))
.sort((a, b) => b.similarity - a.similarity) // 降序
.slice(0, 3)
.map((item, index) => `${index + 1}. ${item.title}.${item.category}`)
.join('\n');
console.log(`最相似的3篇文章是:\n${result}`);
};
整个流程的链条非常清晰:
用户查询 → Embedding → 查询向量 → 逐条计算余弦相似度 → 排序 → Top-K
6.3 命令行交互
import readline from 'readline';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question('请输入查询内容:', handleInput);
readline 是 Node.js 内置模块,用来在终端做问答交互。handleInput 处理完一次查询后,会再次调用 rl.question(...),形成持续对话的循环。
七、检索效果实测
我用 posts.json 里的 34 篇技术文章做了一些测试:
查询:vue相关的内容
1. 如何在 Vue.js 中使用 Vuetify 实现 Material Design 风格
2. 如何使用 Vue.js 和 Electron 开发桌面应用程序
3. 如何使用 Nuxt.js 和 Firebase 实现无后端应用 ← 标题没有"Vue"!
查询:后端微服务
1. 使用 Nest.js 和 TypeScript 构建一个简单的微服务应用
2. 使用 Nest.js 和 TypeORM 构建一个简单的数据驱动的 RESTful API
3. Express.js 与 Koa.js 对比:选择适合的 Node.js 框架
查询:CSS布局
1. CSS Grid 和 Flexbox:哪个更适合你的项目
2. 如何使用 Tailwind CSS 进行快速开发
语义匹配的表现相当不错。即使查询词和标题不完全重叠,系统也能准确找到相关内容。
八、回过头来看:Demo 的局限
这个 Demo 验证了 RAG 的核心链路,但离生产可用还有距离:
| 维度 | 现状 | 改进方向 |
|---|---|---|
| 存储 | JSON 文件,全量加载到内存 | 向量数据库(Milvus / pgvector)支持增删改查 |
| 检索效率 | 暴力计算 N 次余弦相似度 | ANN 近似最近邻索引(HNSW),10 万条也能毫秒返回 |
| 生成 | 只做到了 R(检索),没有 G(生成) | 检索结果拼接 Prompt → 调用 LLM 生成最终回答 |
| 内容粒度 | 只有 title + category,没有正文 | 文档 Chunking:把长文章切分成段落级片段 |
| Prompt 优化 | 简单的字段拼接 | 尝试不同的拼接策略,对比检索命中率 |
后续文章会继续补齐这些环节,下一篇计划做 RAG 的 G —— 把检索结果喂给 LLM,生成真正的答案。
九、我学到了什么(不只是代码)
写这个 Demo 的过程让我理解了几个比具体 API 调用更重要的东西:
1. RAG 的本质是一个"查找-拼接-生成"流水线。 检索的质量决定了生成的上限。LLM 再强,喂给它的上下文不对,答案一定不对。这就是 Garbage In, Garbage Out。
2. Embedding 是连接"自然语言"和"数学计算"的桥梁。 一旦文字变成了向量,搜索问题就退化成了向量空间里的距离计算问题——这是计算机最擅长的事。
3. Node.js 的异步模型不是阻碍,是能力。 fs/promises、sleep 这些看似基础的东西,背后是整个事件循环和 Promise 体系的支撑。理解它们不是为了应付面试,是真的能写出非阻塞的高效代码。
4. 做 Demo 是最好的学习方式。 看十篇文章不如自己写一个。这个项目总共不到 100 行代码,但它串起了 Embedding API、向量相似度、文件 I/O、异步控制、命令行交互……每一个知识点都是亲手敲出来的,理解完全不一样。