1. 开篇:传统搜索的痛点
你有没有遇到过这种情况?想在项目里找一些 Vue 相关的代码,搜"Vue"结果出来一堆"review"、"value"、"virtual"------因为这些词里都包含"vue"这个子串。或者想找"性能优化"的文章,搜"性能"出来的结果却全是"性能测试"、"性能指标",完全不是你想要的。
这就是传统搜索的痛点:它只能匹配文字,听不懂语义。
就像你跟一个只会查字典的机器人说话,你说"我想吃水果",它给你找出所有包含"水果"的条目------但它不知道你其实想要的是苹果、香蕉、橙子。
有没有一种办法,让搜索能理解你话里的"弦外之音"?
答案是:RAG(Retrieval-Augmented Generation)语义搜索。
2. RAG 是什么?------ AI 的外挂大脑
在讲 RAG 之前,先讲一个小故事。
你玩游戏的时候,有没有用过"外挂"?比如透视挂能看到墙后面的敌人,加速挂能跑得比别人快。RAG 就像是给 AI 装了一个"记忆外挂"------让它能访问外部知识库,而不是只靠自己的"内置知识"。
RAG 的核心思想
RAG 是一种 AI 架构模式,它由三个步骤组成:
- Retrieval(检索):从知识库中找到与用户问题最相关的内容
- Augmentation(增强):把检索到的内容作为上下文,喂给 AI
- Generation(生成):AI 基于增强后的上下文,生成更准确的回答
RAG 架构概览
scss
┌─────────────────────────────────────────────────────────────────────┐
│ RAG 系统架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 原始数据 │ │ 向量数据库 │ │ LLM │ │
│ │ (文章/文档) │ ──→ │ (Embedding) │ │ (生成回答) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ 离线向量化 │ │ │
│ ▼ │ │ │
│ ┌──────────────┐ │ │ │
│ │ Embedding API│ ──→ 存入向量库 │ │
│ └──────────────┘ │ │ │
│ │ │ │
│ │ 在线检索 │ │
│ │ │ │
│ 用户提问 ──→ 生成查询向量 ──→ 相似度计算 ──→ 检索结果 ──→ 增强上下文│
│ │ │ │
└─────────────────────────────────────────────────────────────────────┘
这个架构的精妙之处在于 解耦:
- 数据准备和查询分离:离线批量向量化,在线实时查询
- 向量数据库作为中间层:存储和检索向量,支持高效相似度计算
- LLM 只负责生成:不需要记住所有知识,只需要根据上下文生成回答
本项目的定位
我们今天要拆解的 posts-demo,是一个极简的 RAG 实现------没有使用专门的向量数据库,而是用 JSON 文件存储向量,用 JavaScript 实现余弦相似度计算。它虽小,但完整展示了 RAG 的核心流程。
3. 项目骨架:package.json 和配置文件
一个项目就像一辆汽车,package.json 是汽车的配置表,.env 是油箱里的油。
package.json ------ 项目的配置表
json
{
"name": "posts-demo",
"version": "1.0.0",
"type": "module",
"dependencies": {
"dotenv": "^17.4.2",
"openai": "^6.45.0"
}
}
这里有两个关键依赖:
- openai ------ OpenAI 的官方 SDK,用来调用 Embedding API 生成向量
- dotenv ------ 用来加载环境变量,把 API Key 从代码中分离出来
注意 "type": "module" 这一行,它告诉 Node.js 使用 ESM 模块系统,所以我们能使用 import 语法。
.env ------ 藏在暗处的秘密
虽然项目里没有 .env 文件(因为它包含敏感信息,不会提交到版本控制),但我们知道它的结构:
ini
DASHSCOPE_API_KEY=你的阿里云API密钥
DASHSCOPE_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
为什么用 DashScope?
这个项目使用阿里云的 DashScope API,但通过 OpenAI 兼容模式调用。这样做的好处是:
- 可以使用 OpenAI 的 SDK,不需要学习新的 API
- 切换到其他兼容 OpenAI 协议的服务(如智谱 AI)时,代码几乎不需要改动
- 降低了学习成本,对新手友好
4. 核心组件一:LLM 客户端封装
在开始向量化之前,我们需要先封装一个 LLM 客户端。这个客户端就像是一个"翻译官",把文字翻译成向量。
javascript
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'
})
这 6 行代码做了三件事:
第一步:加载环境变量
javascript
import dotenv from 'dotenv';
dotenv.config();
dotenv.config() 会读取 .env 文件,把里面的键值对注入到 process.env 中。这样我们就能通过 process.env.DASHSCOPE_API_KEY 访问 API 密钥。
为什么不直接写在代码里?
- 安全:API Key 是敏感信息,写在代码里会泄露
- 灵活:不同环境(开发/测试/生产)可以用不同的 Key
- 合规:很多公司禁止在代码中硬编码密钥
第二步:创建 OpenAI 客户端
javascript
export const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
})
这里有两个关键参数:
| 参数 | 说明 |
|---|---|
apiKey |
API 密钥,从环境变量读取 |
baseURL |
API 地址,指向阿里云 DashScope 的兼容模式接口 |
为什么能用 OpenAI SDK 调用阿里云 API?
因为阿里云 DashScope 提供了 OpenAI 兼容模式,它的 API 接口和 OpenAI 完全一致。这意味着:
- 请求格式一样
- 响应格式一样
- SDK 可以直接使用
这种设计非常聪明,它降低了用户的迁移成本------你可以先用 OpenAI 开发,然后无缝切换到阿里云。
第三步:导出客户端
javascript
export const client = new OpenAI({...})
使用 export 关键字导出 client,这样其他文件就能通过 import { client } from './app.service.mjs' 来使用它。
小结 :这个文件虽然只有 6 行,但它体现了一个重要的设计原则------关注点分离。把客户端封装在一个单独的文件里,其他模块不需要关心 API 是怎么配置的,只需要知道怎么用。
5. 核心组件二:离线向量生成
现在我们有了客户端,接下来要做的是把文章数据转换成向量。这个过程就像是给每篇文章拍一张"语义快照"。
整体流程
scss
┌─────────────────────────────────────────────────────────────────────┐
│ 离线向量生成流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ posts.json ──→ fs.readFile() ──→ JSON.parse() ──→ 文章数组 │
│ │ │
│ ▼ │
│ for 循环遍历 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ client.embeddings.create() │ │
│ │ 生成 Embedding 向量 │ │
│ └─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 存入 postsWithEmbedding │
│ │ │
│ ▼ │
│ fs.writeFile() ──→ posts-embedding.json │
│ │
└─────────────────────────────────────────────────────────────────────┘
代码拆解
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);
读取数据
javascript
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);
这两行代码做了两件事:
fs.readFile()读取文件内容,返回字符串JSON.parse()把字符串转换成 JavaScript 对象数组
posts.json 的结构很简单,包含 35 篇文章的标题和分类:
json
[
{"title": "Vue.js 入门教程", "category": "前端开发"},
{"title": "React Hooks 详解", "category": "前端开发"},
...
]
定义 sleep 函数
javascript
const sleep = (ms) =>
new Promise(resolve => setTimeout(resolve, ms));
这是一个极简的 sleep 函数,用来控制 API 调用频率。为什么需要这个?
- 防止限流:大多数 API 都有调用频率限制,如果短时间内发送太多请求,会被拒绝
- 200ms 的间隔:这个项目用的是 200ms,相当于每秒 5 次请求,比较保守
生成向量
javascript
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 sleep(200);
}
这段代码是核心,它做了以下事情:
- 遍历每篇文章 :使用
for...of循环遍历posts数组 - 调用 Embedding API :
client.embeddings.create()生成向量 - 构造输入文本 :把标题和分类拼接成
"标题:xxx,分类:xxx"的格式 - 保存向量 :把标题、分类和向量存入
postsWithEmbedding数组 - 等待 200ms:避免 API 限流
为什么要拼接标题和分类?
如果只给标题生成向量,可能会丢失一些上下文信息。比如"性能优化"这个标题,如果不知道它属于"前端开发"还是"后端开发",向量的语义会不够准确。把标题和分类放在一起,可以让向量更精确地表达文章的主题。
保存结果
javascript
await fs.writeFile(
outputFilePath,
JSON.stringify(postsWithEmbedding, null, 2)
)
JSON.stringify(postsWithEmbedding, null, 2) 把数组转换成格式化的 JSON 字符串,null 是 replacer 函数(不需要),2 是缩进空格数(让输出更易读)。
生成的 posts-embedding.json 是什么样的?
json
[
{
"title": "Vue.js 入门教程",
"category": "前端开发",
"embedding": [0.0123, -0.0456, 0.0789, ...]
},
...
]
每个 embedding 是一个 768 维的浮点数数组,代表文章的语义向量。
6. 核心组件三:余弦相似度算法
现在我们有了向量,接下来要解决的问题是:如何判断两个向量的相似度?
余弦相似度的原理
想象一下,你在一个高维空间里有两个向量。余弦相似度就是这两个向量之间夹角的余弦值:
- 如果夹角是 0°(方向相同),余弦值是 1,相似度最高
- 如果夹角是 90°(方向垂直),余弦值是 0,完全不相似
- 如果夹角是 180°(方向相反),余弦值是 -1,完全相反
计算公式:
scss
cos(θ) = (v1 · v2) / (||v1|| × ||v2||)
其中:
v1 · v2是向量的点积||v1||是向量 v1 的长度(L2 范数)||v2||是向量 v2 的长度
代码实现
javascript
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));
const similarity = dotProduct / (lengthV1 * lengthV2);
return similarity;
};
计算点积
javascript
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
reduce 函数遍历向量的每个元素,把 v1[i] * v2[i] 累加起来。这就是向量的点积。
计算向量长度
javascript
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
向量的长度是每个元素平方和的平方根,也就是 L2 范数。
计算相似度
javascript
const similarity = dotProduct / (lengthV1 * lengthV2);
把点积除以两个向量长度的乘积,得到余弦相似度。
边界条件讨论
这段代码有一个潜在的问题:除零错误。
如果 lengthV1 或 lengthV2 是 0(也就是向量全是 0),那么 lengthV1 * lengthV2 也是 0,会导致除零错误。
修复方案:
javascript
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));
if (lengthV1 === 0 || lengthV2 === 0) {
return 0;
}
const similarity = dotProduct / (lengthV1 * lengthV2);
return similarity;
};
在计算相似度之前,先检查向量长度是否为 0。如果是,直接返回 0(表示完全不相似)。
7. 核心组件四:语义搜索实现
现在我们有了向量和相似度算法,最后一步就是把它们组合起来,实现语义搜索。
整体流程
┌─────────────────────────────────────────────────────────────────────┐
│ 语义搜索完整流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 用户输入 ──→ 生成查询向量 ──→ 计算与所有文章的相似度 │
│ │ │
│ ▼ │
│ 排序(从高到低) │
│ │ │
│ ▼ │
│ 取前 3 个结果 │
│ │ │
│ ▼ │
│ 格式化输出 │
│ │ │
│ ▼ │
│ 等待下一次输入 │
│ │
└─────────────────────────────────────────────────────────────────────┘
代码拆解
javascript
import fs from 'fs/promises';
import { client } from './app.service.mjs';
import readline from 'readline';
const inputFilePath = './data/posts-embedding.json';
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);
加载向量数据
和向量化阶段一样,先读取 posts-embedding.json 文件,把向量数据加载到内存中。
创建命令行交互
javascript
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
readline 是 Node.js 的内置模块,用来创建命令行交互界面。input: process.stdin 表示从标准输入读取(键盘),output: process.stdout 表示输出到标准输出(屏幕)。
处理用户输入
javascript
const handleInput = async (answer) => {
const response = await client.embeddings.create({
model: 'text-embedding-v4',
input: answer
});
const { embedding } = response.data[0];
const results = posts.map(item => ({
...item,
similarity: cosineSimilarity(embedding, item.embedding)
})).sort((a, b) => a.similarity - b.similarity)
.reverse()
.slice(0, 3)
.map((item, index)=>`${index + 1}. ${item.title}. ${item.category}`)
.join('\n');
console.log(`\n 搜索结果:\n${results}`)
rl.close();
rl.question("\n请输入你要搜索的内容:", handleInput);
}
这段代码是核心,我们一步步拆解:
第一步:生成查询向量
javascript
const response = await client.embeddings.create({
model: 'text-embedding-v4',
input: answer
});
const { embedding } = response.data[0];
把用户输入的文本(answer)转换成向量,和向量化阶段使用的是同一个 API。
第二步:计算相似度并排序
javascript
const results = posts.map(item => ({
...item,
similarity: cosineSimilarity(embedding, item.embedding)
})).sort((a, b) => a.similarity - b.similarity)
.reverse()
.slice(0, 3)
这段代码做了以下事情:
map:遍历所有文章,计算每篇文章与查询向量的相似度,返回一个新数组sort:按相似度从小到大排序(a.similarity - b.similarity)reverse:反转数组,变成从大到小排序slice(0, 3):取前 3 个结果
简化优化:
其实 sort().reverse() 可以简化为直接按降序排序:
javascript
.sort((a, b) => b.similarity - a.similarity)
这样更简洁,也避免了额外的反转操作。
第三步:格式化输出
javascript
.map((item, index)=>`${index + 1}. ${item.title}. ${item.category}`)
.join('\n');
把结果数组转换成字符串格式,比如:
markdown
1. Vue.js 入门教程. 前端开发
2. Vue3 Composition API 实战. 前端开发
3. React vs Vue 对比分析. 前端开发
第四步:输出结果并等待下一次输入
javascript
console.log(`\n 搜索结果:\n${results}`)
rl.close();
rl.question("\n请输入你要搜索的内容:", handleInput);
输出搜索结果,然后等待用户输入下一个查询。
潜在问题:
这里有一个 bug:rl.close() 关闭了 readline 接口,然后又调用 rl.question()。虽然在实践中可能能工作,但这不是正确的用法。正确的做法是不调用 rl.close(),让接口保持打开状态。
修复方案:
javascript
console.log(`\n 搜索结果:\n${results}`)
rl.question("\n请输入你要搜索的内容:", handleInput);
去掉 rl.close(),让用户可以连续输入多个查询。
启动交互
javascript
rl.question("\n请输入你要搜索的内容:", handleInput);
显示提示信息,等待用户输入。
8. 进阶优化:从玩具到生产
这个项目是一个很好的入门示例,但离生产环境还有一段距离。以下是一些改进方向:
8.1 使用向量数据库
当前实现把所有向量加载到内存中,数据量大时会 OOM。生产环境应该使用专门的向量数据库:
| 向量数据库 | 特点 |
|---|---|
| Pinecone | 托管服务,开箱即用 |
| Milvus | 开源,可自部署 |
| Chroma | 轻量级,适合开发 |
| Weaviate | 支持 GraphQL 查询 |
8.2 添加索引
当前实现是线性搜索(O(n)),数据量大时性能很差。向量数据库通常支持:
- HNSW(Hierarchical Navigable Small World):近似最近邻搜索,O(log n) 复杂度
- IVF(Inverted File):倒排索引,适合大规模数据
8.3 添加缓存机制
对于频繁查询的内容,可以添加缓存,避免重复计算:
javascript
const cache = new Map();
const CACHE_TTL = 60 * 1000; // 1分钟
const getSimilarPosts = async (query) => {
const cached = cache.get(query);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
// ... 计算逻辑 ...
cache.set(query, { results, timestamp: Date.now() });
return results;
};
8.4 添加错误处理
当前代码没有错误处理,如果 API 调用失败或文件读取失败,程序会崩溃。应该添加 try-catch:
javascript
const handleInput = async (answer) => {
try {
// ... 搜索逻辑 ...
} catch (err) {
console.error('搜索失败:', err.message);
} finally {
rl.question("\n请输入你要搜索的内容:", handleInput);
}
};
8.5 添加流式输出
对于长篇文章,可以添加流式输出,而不是一次性返回所有内容。
结尾:语义搜索的未来
回到开头那个"字典机器人"的故事。RAG 语义搜索就像是给这个机器人装上了一颗真正的大脑------它不再只会查字典,而是能理解你的意图,找到真正相关的内容。
今天我们用 60 行代码实现了一个简单的语义搜索系统,这只是 RAG 能力的冰山一角。想象一下:
- 一个能搜索整个文档库的智能助手
- 一个能理解你的问题并自动查找答案的客服系统
- 一个能根据你的兴趣推荐内容的个性化推荐引擎
这些场景都离不开语义搜索技术。未来的搜索,不再是关键词匹配,而是语义理解。
最后,留给你一个问题:如果让你为这个项目添加一个新功能,你会选择什么?为什么?