RAG = Retrieval(检索) + Augmented(增强) + Generation(生成)
在大模型时代,RAG 是解决幻觉、接入私有知识的核心方案。而这一切的起点,都在于那个最关键的"R"------检索。今天我们就抛开复杂的框架,用最纯粹的 Node.js 代码,彻底搞懂语义搜索的底层原理。
🤔 为什么传统搜索在 AI 时代"失灵"了?
假设你的 data/posts.json 里有一万篇技术文章,用户问:"有哪些 Vue 相关的内容? "
传统搜索的困境
- 正则 / LIKE '%vue%' :只能精确匹配字符。如果文章写的是"渐进式前端框架",哪怕内容全是 Vue,也搜不出来。
- 文字匹配:"酸辣土豆丝的做法" vs "马铃薯怎么做?" 字面完全不同,但语义 100% 相同。传统搜索引擎对此束手无策。
语义搜索的破局之道
人类理解语言靠的是"意思",而不是"字形"。要让机器也能做到这一点,我们需要一个翻译官:Embedding 模型。它能把任何文本变成一串高维浮点数数组(向量),在这个数学空间里,"意思相近"就等于"距离相近"。
🛠️ 第一步:封装 LLM 服务客户端
在大型项目中,API Key 和客户端实例不应该散落在业务代码里。我们遵循工程化规范,将服务层独立抽离:
javascript
编辑
javascript
1// app.service.mjs - 应用服务层,体现大型项目的风骨
2import OpenAI from 'openai';
3import dotenv from 'dotenv';
4dotenv.config();
5
6// 模块化输出 client,供所有业务模块复用
7export const client = new OpenAI({
8 apiKey: process.env.DASHSCOPE_API_KEY,
9 baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
10});
💡 设计思想 :
app.service.mjs作为统一的服务出口,未来无论是更换模型提供商、添加重试机制还是接入缓存,只需修改这一个文件,业务代码零感知。
我们可以快速验证一下 Embedding 的效果:
javascript
编辑
javascript
1const response = await client.embeddings.create({
2 model: "text-embedding-v4",
3 input: "红绳同学大三哦", // 任意自然语言
4});
5// 输出一串高维向量,这就是这句话在 AI 眼中的"数字指纹"
6console.log(response.data[0].embedding);
🔄 第二步:构建离线向量知识库
语义搜索的前提是:知识库里的所有内容必须提前向量化。这是一个一次性的离线 ETL 过程。
核心流程
- 用 Node.js 内置
fs/promises读取原始 JSON(ES2015+ 支持 Promise 化 I/O) - 逐条调用 Embedding API,将标题+分类转为向量
- 写入新文件,实现向量的长期持久化存储
javascript
编辑
javascript
1// vectorize.mjs - 离线向量化脚本
2import fs from 'fs/promises';
3import { client } from './app.service.mjs';
4
5const inputFilePath = './posts.json';
6const outputFilePath = './posts-embedding.json';
7
8// Node.js 新版支持顶层 await,直接从硬盘读取到内存
9const data = await fs.readFile(inputFilePath, 'utf-8');
10const posts = JSON.parse(data);
11
12// 简单的限流函数,避免触发 API Rate Limit
13const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
14
15const postsWithEmbedding = [];
16
17for (const { title, category } of posts) {
18 const response = await client.embeddings.create({
19 model: 'text-embedding-v4',
20 // 🔑 关键技巧:拼接结构化提示词,让语义表达更精准
21 input: `标题:${title},分类:${category}`
22 });
23
24 postsWithEmbedding.push({
25 title,
26 category,
27 embedding: response.data[0].embedding
28 });
29
30 // 保持代码可读性,同时保护 API 配额
31 await sleep(200);
32}
33
34console.log('✅ 向量化完成,共处理', postsWithEmbedding.length, '条数据');
35
36// 格式化写入,方便人工检查调试
37await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));
⚠️ 避坑指南:永远不要在生产环境对百万级数据做这种串行全量向量化!这里是为了教学演示原理。工业界会使用消息队列 + 批量 API + 向量数据库来完成这一步。
🔍 第三步:实现交互式语义搜索(核心)
现在有了向量知识库,我们来实现真正的 RAG 检索引擎。这段代码是整个实战的灵魂:
javascript
编辑
javascript
1// search.mjs - 语义搜索主程序
2import fs from 'fs/promises';
3import { client } from './app.service.mjs';
4import readline from 'readline';
5
6// 1. 加载向量知识库到内存
7const data = await fs.readFile('./data/posts-embedding.json', 'utf-8');
8const posts = JSON.parse(data);
9
10// 2. 余弦相似度:衡量两个向量"意思有多像"的数学公式
11const cosineSimilarity = (v1, v2) => {
12 // 点积:衡量方向重合度
13 const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
14 // 模长:归一化,消除文本长度差异
15 const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
16 const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
17
18 return dotProduct / (lengthV1 * lengthV2);
19};
20
21// 3. 创建命令行交互界面
22const rl = readline.createInterface({
23 input: process.stdin,
24 output: process.stdout
25});
26
27// 4. 处理用户查询的核心逻辑
28const handleInput = async (answer) => {
29 if (!answer.trim()) return rl.close();
30
31 // 将用户问题实时向量化
32 const response = await client.embeddings.create({
33 model: 'text-embedding-v4',
34 input: answer
35 });
36 const { embedding } = response.data[0];
37
38 // 🔥 语义检索核心:全量比对 → 排序 → 截取 Top-K
39 const results = posts
40 .map(item => ({
41 ...item,
42 similarity: cosineSimilarity(embedding, item.embedding)
43 }))
44 .sort((a, b) => b.similarity - a.similarity) // 从高到低排序
45 .slice(0, 3) // 只取最相关的 Top 3
46 .map((item, index) => `${index + 1}. [${item.category}] ${item.title} (相似度: ${item.similarity.toFixed(4)})`)
47 .join('\n');
48
49 console.log(`\n🎯 语义搜索结果:\n${results}\n`);
50 rl.close();
51};
52
53rl.question("\n🔍 请输入你要搜索的内容: ", handleInput);
🔬 深度拆解:用户按下回车后发生了什么?
表格
| 步骤 | 动作 | 本质 |
|---|---|---|
| ① | client.embeddings.create({ input: answer }) |
把人类语言翻译成机器能懂的坐标点 |
| ② | posts.map(...cosineSimilarity...) |
在高维空间中计算问题与每篇文章的几何距离 |
| ③ | .sort().slice(0, 3) |
捞出最近的 3 个邻居,过滤噪音 |
| ④ | console.log(results) |
将数学结果还原为人类可读的搜索结果 |
🎓 为什么学 RAG 必须手写一遍这段代码?
很多初学者直接上手 LangChain / LlamaIndex,却连余弦相似度都没写过。老师强调手写的原因在于:
- 祛魅 :AI 不懂中文,它只做数学运算。理解了
cosineSimilarity,你就理解了所有语义搜索产品的底层真相。 - 建立质量意识 :RAG 的回答质量 = 检索质量 × 生成质量。如果检索阶段找错了文章,后面接 GPT-5 也会一本正经地胡说八道。Garbage In, Garbage Out。
- 暴露性能瓶颈 :当你亲手写出
posts.map()的全量扫描后,你会立刻意识到:100 万条数据每次搜索都要算 100 万次点积,这根本不可用。这会自然而然地驱动你去学习 向量数据库(Milvus/Qdrant) 和 ANN 近似最近邻索引,这才是工业级 RAG 的正确打开方式。
📌 总结
从 LIKE '%vue%' 到 cosineSimilarity,我们跨越的不是一个 API,而是一种全新的信息检索范式。
这篇实战代码虽然朴素,但它完整覆盖了 RAG 中 Retrieval 的全链路:服务封装 → 离线向量化 → 实时语义检索。当你真正跑通这个 Demo,再去看那些复杂的 RAG 框架时,你会发现它们不过是在这套基础逻辑上叠加了分块、重排序、混合检索等优化手段而已。
下一步进阶建议 :尝试将
posts.map替换为 Qdrant/Milvus 的向量检索 API,体验从 O(N) 暴力扫描到 O(logN) 索引检索的性能飞跃。那才是 RAG 走向生产环境的真正起点。