从零搭建一个 RAG 语义搜索引擎 ------ 基于阿里云 DashScope
不用数据库,不用搜索引擎,30 行核心代码,让你的应用"看懂"用户到底在搜什么。
一、什么是 RAG?为什么需要语义搜索?
传统的搜索依赖关键词匹配。用户搜"Vue 状态管理",你只能返回标题里包含"Vue"和"状态管理"的文章。但假如有一篇标题是《如何在 Vue.js 中使用 Vuex 进行状态管理》------它完美匹配用户意图,却因为标题措辞不同而排在结果之外。
这就是 RAG(Retrieval-Augmented Generation,检索增强生成)要解决的问题。RAG 的核心思路是:
- 把文本变成向量(Embedding):用大模型将任意文本映射到高维数学空间中的一个点;
- 语义相近的文本,向量也相近:"Vue 状态管理"和"Vuex 状态管理"在向量空间中靠得很近;
- 用向量相似度做搜索:不再匹配关键词,而是匹配语义。
本文带你用一个 Node.js 项目,从零实现这个流程。
二、项目总览
项目结构非常简洁:
bash
posts-demo/
├── index.mjs # 快速验证:调用 Embedding API
├── app.service.mjs # 服务层:封装 OpenAI 客户端
├── create-embedding.mjs # 数据预处理:批量向量化文章
├── semantic-search.mjs # 核心功能:语义搜索
├── data/
│ ├── posts.json # 原始文章数据(35 篇)
│ └── posts-embedding.json # 向量化后的数据(运行后生成)
└── readme.md
四个文件,各司其职,清晰分层。运行顺序是:index.mjs(验证连通) → create-embedding.mjs(预处理数据) → semantic-search.mjs(搜索交互)。下面我们逐一拆解。
三、第一步:接入 Embedding 服务
项目使用**阿里云 DashScope(百炼)**作为 LLM 服务商,选择 text-embedding-v4 模型。由于 DashScope 提供了 OpenAI 兼容的 API 格式,我们可以直接使用 OpenAI 的 Node.js SDK,无需额外的适配层。
app.service.mjs ------ 服务层,复用客户端
javascript
// 引入 OpenAI 官方 SDK,兼容任何 OpenAI 格式的 API
import Openai from 'openai';
// dotenv 用于从 .env 文件加载环境变量,避免 API Key 硬编码在源码中
import dotenv from 'dotenv';
dotenv.config(); // 执行后 process.env 中即可读取 .env 文件里定义的变量
// export 导出客户端实例,其他模块 import 即可复用,无需重复 new
export const client = new Openai({
// 从环境变量读取 Key,安全且便于切换不同环境(开发/生产)
apiKey: process.env.DASHSCOPE_API_KEY,
// 关键:将 baseURL 指向阿里云 DashScope 的兼容端点
// "/compatible-mode/v1" 表示以 OpenAI v1 协议兼容模式通信
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});
逐行解析:
| 代码 | 做了什么 | 为什么这样写 |
|---|---|---|
import Openai from 'openai' |
引入 OpenAI SDK | 阿里云 DashScope 兼容 OpenAI 接口格式,所以可以直接用 OpenAI 的 SDK 调用,无需单独安装阿里云的 SDK |
import dotenv from 'dotenv' |
引入环境变量管理库 | API Key 属于敏感信息,绝不能硬编码在代码里提交到 Git。.env 文件通常被 .gitignore 忽略,保证安全 |
dotenv.config() |
加载 .env 文件 |
将 .env 中的 DASHSCOPE_API_KEY=xxx 注入到 process.env,后续通过 process.env.DASHSCOPE_API_KEY 读取 |
export const client = new Openai({...}) |
创建并导出客户端实例 | export 使得这是一个 ES Module 的命名导出,其他文件 import {client} from './app.service.mjs' 即可拿到同一个实例,避免重复创建连接 |
apiKey: process.env.DASHSCOPE_API_KEY |
从环境变量读取 Key | 安全性:Key 不在代码中;灵活性:不同环境可以配置不同的 Key |
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1' |
重定向 API 端点 | 这是整个项目的"开关"------OpenAI SDK 默认请求 api.openai.com,改这一行就能切换到阿里云,无需改任何业务代码 |
设计要点 :将客户端实例化单独抽成一个模块。这在大项目中称为"服务层"------所有需要调用 LLM 的地方都
import {client} from './app.service.mjs',统一管理 API Key 和 baseURL,避免到处重复配置。以后想切换到 DeepSeek、豆包等任何兼容 OpenAI 协议的服务商,只改这一个文件即可。
index.mjs ------ 快速验证连通性
javascript
// 从服务层引入已配置好的客户端(不需要再次 new Openai)
import Openai from 'openai';
import dotenv from 'dotenv';
dotenv.config();
const client = new Openai({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});
// 调用 embeddings.create 方法向 DashScope 发起 HTTP 请求
const response = await client.embeddings.create({
model: "text-embedding-v4", // 指定 Embedding 模型,v4 是 DashScope 最新版本
input: "aaa" // 待向量化的文本,这里是随便写的测试字符串
});
// response.data 是一个数组,每个元素对应一个 input
// response.data[0].embedding 就是 "aaa" 对应的向量(浮点数数组)
console.log(response.data[0].embedding);
// 输出示例: [0.023, -0.045, 0.112, -0.008, ...] ------ 通常有 1024 或 1536 维
逐行解析:
| 代码 | 做了什么 | 为什么这样写 |
|---|---|---|
import Openai from 'openai' |
引入 SDK | --- |
dotenv.config() |
加载 .env |
让 process.env.DASHSCOPE_API_KEY 有值 |
new Openai({...}) |
创建客户端实例 | index.mjs 是独立验证脚本,所以自己 new 了一个客户端。正式项目中其他文件用 app.service.mjs 导出的 client,避免重复配置 |
model: "text-embedding-v4" |
指定模型 | DashScope 的 Embedding v4 模型,支持中英文,1024 维向量。v4 相比 v3 在检索精度上有明显提升 |
input: "aaa" |
待向量化的文本 | 单个字符串;也可以传字符串数组 ["aaa", "bbb"] 批量向量化,但本 demo 逐条调用便于控制频率 |
response.data[0].embedding |
取出向量结果 | data 是数组,长度与 input 数量一一对应。embedding 是 number[],即高维浮点数向量 |
console.log(...) |
打印验证 | 如果能输出一长串 [0.023, -0.045, ...],说明网络通了、Key 对了、模型可用 |
运行一下,如果能打印出一长串浮点数,说明服务通了。
四、第二步:批量向量化文章
create-embedding.mjs 完成了数据预处理的核心工作。它做的事情很清晰:
javascript
读取 JSON 文件 → 逐条调用 Embedding API → 写入新文件
javascript
// fs/promises:Node.js 内置模块的 Promise 版本
// 所有文件操作返回 Promise,支持 async/await,告别回调地狱
import fs from 'fs/promises';
// 从服务层引入共享的客户端实例
import { client } from './app.service.mjs';
// 定义输入输出路径为常量,方便修改
const inputFilePath = './data/posts.json';
const outputFilePath = './data/posts-embedding.json';
// readFile 读取文件原始内容,'utf-8' 指定编码得到字符串
const data = await fs.readFile(inputFilePath, 'utf-8');
// JSON.parse 将 JSON 字符串反序列化为 JavaScript 对象数组
const posts = JSON.parse(data);
// sleep 工具函数:返回一个在 ms 毫秒后 resolve 的 Promise
// 配合 await 使用,暂停执行一段时间
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 存储向量化结果的数组
const postsWithEmbedding = [];
// for...of 遍历数组,解构取出每篇文章的 title 和 category
for (const { title, category } of posts) {
// 调用 Embedding API,将标题和分类拼接后一起向量化
const response = await client.embeddings.create({
model: "text-embedding-v4",
// 拼接标题+分类形成更丰富的语义输入
input: `标题:${title},分类:${category}`
});
// 将原始数据 + 向量合并存入新数组
postsWithEmbedding.push({
title, // 保留原标题
category, // 保留原分类
embedding: response.data[0].embedding // 新增:向量字段
});
// 每次请求后暂停 200ms,避免触发 API 频率限制
await sleep(200);
}
// writeFile 将结果写入新 JSON 文件
// JSON.stringify 的第三个参数 2 表示缩进 2 个空格,方便人类阅读
await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));
逐行解析:
| 代码 | 做了什么 | 为什么这样写 |
|---|---|---|
import fs from 'fs/promises' |
引入 Promise 版 fs | 旧式 fs.readFile(path, callback) 需要回调嵌套。fs/promises 返回 Promise,配合顶层 await 代码像同步一样直观 |
import { client } from './app.service.mjs' |
引入共享客户端 | 不是 new Openai({...}),而是复用 app.service.mjs 中已经配置好的实例,体现了模块化思想 |
const data = await fs.readFile(inputFilePath, 'utf-8') |
读文件 | 'utf-8' 参数让 Node.js 直接返回字符串而非 Buffer。在 ES Module 中可以使用顶层 await,代码直接写在模块最外层 |
const posts = JSON.parse(data) |
反序列化 JSON | data 是字符串,JSON.parse 后 posts 变成对象数组,每个对象有 title 和 category 属性 |
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) |
延时工具 | setTimeout 本身是回调风格,包一层 Promise 使其可以被 await。这是 JS 中常见的"promisify"模式 |
for (const { title, category } of posts) |
遍历 + 解构 | { title, category } 是解构赋值,直接从每个 post 对象中提取需要的字段,代码更简洁 |
input: \标题: title,分类:{category}`` |
拼接语义输入 | 模板字符串将两个字段组合成一句完整的中文描述。这比只传 title 提供了更多上下文,向量质量更高 |
response.data[0].embedding |
提取向量 | embeddings.create 的返回结果中,data 数组与 input 数组一一对应。这里 input 只有一个字符串,所以取 [0] |
postsWithEmbedding.push({...}) |
组装结果 | 保留原始 title 和 category(后续展示搜索结果需要),新增 embedding 字段(后续相似度计算需要) |
await sleep(200) |
限频等待 | DashScope 等云 API 有 QPS(每秒请求数)限制。不加等待连续发请求可能被暂时封禁。200ms 约等于 5 QPS,是比较安全的频率 |
JSON.stringify(postsWithEmbedding, null, 2) |
序列化 JSON | 第三个参数 2 是缩进空格数,生成的文件格式化良好。如果传 null 或不传则压缩成一行,不利于查看和调试 |
数据变化示意
向量化前后数据对比------之前 (posts.json):
json
{ "title": "如何在 Vue.js 中使用 Vuex 进行状态管理", "category": "前端开发" }
之后 (posts-embedding.json):
json
{
"title": "如何在 Vue.js 中使用 Vuex 进行状态管理",
"category": "前端开发",
"embedding": [0.023, -0.045, 0.112, -0.008, ...]
}
每条数据多了一个 1024 维的浮点数数组------这就是"语义指纹"。
五、第三步:语义搜索
semantic-search.mjs 是整个项目的灵魂。它实现了一个命令行交互式的语义搜索引擎:
javascript
// fs/promises:读取已向量化的数据文件
import fs from 'fs/promises';
// 复用服务层的 OpenAI 客户端
import { client } from './app.service.mjs';
// readline:Node.js 内置模块,用于在终端中读取用户输入
import readline from 'readline';
// ===== 第一步:加载已向量化的文章数据 =====
// 顶层 await 直接读取文件并解析 JSON
const posts = JSON.parse(await fs.readFile('./data/posts-embedding.json', 'utf-8'));
// ===== 第二步:余弦相似度 ------ 衡量两个向量的"方向一致性" =====
const cosineSimilarity = (v1, v2) => {
// reduce 遍历向量每一维,累加 product = v1[i] * v2[i]
// acc 是累加器(初始值 0),curr 是当前元素值,i 是索引
// 最终 dotProduct = Σ(v1[i] × v2[i]),即向量的点积(内积)
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
// 向量的模(长度):||v|| = √(Σ(v[i]²))
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
// 余弦相似度 = 点积 / (模长之积)
// 值域 [-1, 1],1 表示方向完全一致(语义最接近)
const similarity = dotProduct / (lengthV1 * lengthV2);
return similarity;
};
// ===== 第三步:创建命令行交互界面 =====
// readline.createInterface 创建一个输入输出接口
const rl = readline.createInterface({
input: process.stdin, // 绑定进程的标准输入(键盘输入)
output: process.stdout // 绑定进程的标准输出(终端显示)
});
// handleInput 是每次用户输入后的处理函数
const handleInput = async (answer) => {
// 3.1 将用户的搜索文本向量化
// 解构赋值从深层嵌套中直接取出 embedding 向量
const { embedding } = (await client.embeddings.create({
model: 'text-embedding-v4',
input: answer // 直接把用户的输入作为待向量化的文本
})).data[0];
// 3.2 计算与每篇文章的相似度,排序取 Top 5
const results = posts
// map:给每篇文章附加 similarity 字段
.map(item => ({
...item, // 展开原有属性(title, category, embedding)
similarity: cosineSimilarity(embedding, item.embedding) // 计算该文章与查询的相似度
}))
// sort:按 similarity 从小到大排序
// a.similarity - b.similarity 返回负数时 a 排在 b 前面
.sort((a, b) => a.similarity - b.similarity)
// reverse:反转数组,变成从大到小(相似度高的在前)
.reverse()
// slice:只取前 5 条
.slice(0, 5)
// map:格式化为带序号的易读字符串
.map((item, index) => `${index + 1}. ${item.title} | ${item.category}`)
// join:用换行符拼接成最终输出文本
.join('\n');
// 3.3 输出搜索结果
console.log(`\n搜索结果:\n${results}`);
// 3.4 递归调用:再次提问,形成"输入→搜索→输出→再输入"的循环
rl.question("\n请输入你要搜索的内容:", handleInput);
};
// ===== 第四步:启动交互循环 =====
// 第一次提问,用户输入后将触发 handleInput
rl.question("\n请输入你要搜索的内容:", handleInput);
逐行解析:
| 代码 | 做了什么 | 为什么这样写 |
|---|---|---|
import readline from 'readline' |
引入命令行交互模块 | Node.js 内置模块,无需安装。提供 question(query, callback) 方法在终端问答 |
JSON.parse(await fs.readFile(...)) |
加载向量数据 | 读取 create-embedding.mjs 的输出文件。所有文章已带 embedding 字段,直接可用 |
v1.reduce((acc, curr, i) => acc + curr * v2[i], 0) |
计算向量点积 | reduce 是 JS 数组的归并方法。这里遍历 1024 维向量,对每一维做乘法然后累加。0 是初始累加值 |
Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0)) |
计算向量模长 | 先累加每一维的平方,再开方。curr * curr 比 curr ** 2 或 Math.pow(curr, 2) 更简洁 |
dotProduct / (lengthV1 * lengthV2) |
计算余弦值 | 除法将点积归一化到 -1, 1 区间,消除了向量长度的影响,只比较方向 |
readline.createInterface({ input: process.stdin, output: process.stdout }) |
创建 CLI 界面 | process.stdin 和 process.stdout 是 Node.js 进程的标准输入输出流 |
const { embedding } = (...).data0` |
解构提取向量 | 直接从 response.data[0] 中解构出 embedding,省去了中间变量。注意这里用到了顶层 await 的嵌套解构 |
.map(item => ({ ...item, similarity: cosineSimilarity(...) })) |
附加相似度 | 展开运算符 ...item 保留原有字段,新增 similarity 字段 |
.sort((a, b) => a.similarity - b.similarity) |
升序排列 | JS 的 sort 默认按字符串排序,必须传入比较函数。a - b 返回负数时 a 在前,实现数值升序 |
.reverse() |
反转为降序 | 升序 + 反转 = 降序(也可以用 .sort((a,b) => b.similarity - a.similarity) 一步到位,两种写法等价) |
.slice(0, 5) |
取 Top 5 | 只保留相似度最高的 5 条结果 |
.map((item, index) => \${index + 1}. ...`)` |
格式化序号 | index 从 0 开始,加 1 人类习惯从 1 开始编号 |
.join('\n') |
拼接多行文本 | 每个结果一行,用换行符连接 |
rl.question("...", handleInput) |
递归交互 | question 的第二个参数是回调函数,用户按回车后触发。handleInput 内部再次调用 rl.question,形成无限问答循环 |
核心算法:余弦相似度
这是整个搜索的数学核心。公式很简单:
cosine(A,B)=∣∣A∣∣×∣∣B∣∣A⋅B
- 分子:两个向量的点积------对应维度相乘后求和,反映两个向量在方向上的"协同程度";
- 分母:两个向量模长(欧几里得长度)的乘积------将结果归一化到 -1, 1,消除向量绝对长度的影响;
- 结果范围:-1, 1,越接近 1 表示方向越一致,语义越相近;0 表示正交(无关);-1 表示完全相反。
代码实现对应关系:
javascript
// A · B ------ 点积
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
// ||A|| ------ v1 的模长
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
// ||B|| ------ v2 的模长
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
// cos(A, B) = (A · B) / (||A|| × ||B||)
return dotProduct / (lengthV1 * lengthV2);
为什么用余弦相似度而不是欧几里得距离? 因为 Embedding 向量的方向 比绝对长度更能反映语义。两段语义相近的文本,它们的向量指向大致相同方向,但可能因为文本长度不同导致向量长度差异较大。余弦相似度只看方向不看长度,更适合文本语义比对。
搜索流程
用 readline 模块实现了命令行对话式搜索:
- 用户输入查询 → 2. 调用 Embedding API 向量化 → 3. 与所有文章逐条计算余弦相似度 → 4. 排序取 Top 5 → 5. 展示结果 → 6. 等待下一次输入
用户输入"前端状态管理",结果可能是:
markdown
1. 如何在 Vue.js 中使用 Vuex 进行状态管理 | 前端开发
2. 如何使用 React Hooks 构建可复用的组件 | 前端开发
3. 如何在 React 中实现无限滚动 | 前端开发
4. 如何使用 React Router 实现客户端路由 | 前端开发
5. 如何使用 TypeScript 编写高质量的 JavaScript 代码 | 前端开发
注意:没有一篇文章的标题精确包含"状态管理"之外的"前端"二字组合------但语义搜索依然找到了最相关的内容。"前端状态管理"和"Vuex 状态管理"在向量空间中距离很近,因为 Embedding 模型理解它们说的是同一件事。
六、RAG 的完整图景
这个 demo 实现了 RAG 的 R(Retrieval) 部分。完整的 RAG 流程是:
css
用户提问 → Embedding 向量化 → 向量相似度检索 → 取 Top-K 相关文档
→ 拼入 Prompt → LLM 基于上下文生成回答
也就是说,你还可以在 semantic-search.mjs 的基础上,把搜出来的 5 篇文章喂给 GPT,让它基于这些文章回答用户问题。比如:
javascript
// 将 Top 5 结果拼接成上下文文本
const context = results.map(r => r.title).join('\n');
// 调用 LLM 的 chat.completions 接口,基于检索到的文章生成回答
const answer = await client.chat.completions.create({
model: 'qwen-plus', // 阿里云的通义千问模型,用于生成回答
messages: [
// system 消息设定 AI 的角色和行为约束
{ role: 'system', content: `基于以下文章回答问题:\n${context}` },
// user 消息传入用户的原始问题
{ role: 'user', content: answer }
]
});
这段代码的 RAG 逻辑:
context:从向量搜索中检索到的相关文章标题集合------这是 RAG 的 Retrieval 部分;system消息:将检索结果作为"背景知识"注入给 LLM,告诉它"你只能基于这些文章回答"------这叫 Augmented;user消息:用户的实际问题------LLM 会综合上下文和问题,生成(Generation) 最终答案。
这就是 ChatGPT 的"知识库问答"、企业文档助手、AI 客服等产品的底层原理。
七、总结
| 文件 | 职责 | 关键点 |
|---|---|---|
app.service.mjs |
服务层 | 统一管理 LLM 客户端,模块复用 |
create-embedding.mjs |
数据预处理 | 批量向量化 + API 限流控制 |
semantic-search.mjs |
语义搜索 | 余弦相似度 + 命令行交互 |
index.mjs |
快速验证 | 确保 API 连通 |
这个不到 100 行代码的项目,完整演示了 RAG 的核心链路:文本 → 向量 → 相似度 → 检索。它没有引入任何向量数据库(如 Pinecone、Milvus),所有数据都存在本地 JSON 文件中------对于小规模 demo 或原型验证来说,这完全够用。
当数据量增长到成千上万条时,你可以把 cosineSimilarity 的暴力遍历替换成向量数据库的 ANN(近似最近邻)索引,但核心思想完全不变。
语义搜索不是魔法,它只是把"理解语义"这件事外包给了 Embedding 模型。 而这,正是 AI 时代应用开发的新范式。
本文基于 cjh_ai/ai/rag/posts-demo 项目源码撰写。