RAG 语义搜索全栈实战:用 Node.js + Embedding 从零构建智能搜索引擎
当用户搜"马铃薯怎么做",传统搜索找不到"酸辣土豆丝"——因为字不一样。而语义搜索通过 Embedding 向量化 + 余弦相似度计算,让机器理解"马铃薯"和"土豆"是同一种东西。本文从 RAG 原理出发,用 Node.js + 通义千问 text-embedding-v4 API,手把手实现一个完整的命令行语义搜索引擎。
前言
传统搜索靠关键词匹配:搜 "Vue" 只能找到包含 "Vue" 这个词的结果。但如果你搜"前端框架推荐"呢?没有"Vue"这个关键词,传统搜索就帮不了你。
语义搜索(Semantic Search) 是 RAG(检索增强生成)的核心组件。它通过将文本转化为语义向量,用向量距离衡量相关性,从而实现"理解意思,而非匹配字面"。
本文将从原理到代码,完整实现一个语义搜索引擎:
- 理解 RAG 的三阶段原理
- 搭建 Node.js + OpenAI SDK 项目结构
- 批量 Embedding 数据并持久化存储
- 实现余弦相似度算法
- 构建命令行交互式搜索
一、RAG:检索增强生成
1.1 什么是 RAG?
RAG(Retrieval-Augmented Generation,检索增强生成)是当前 AI 工程化最核心的应用模式之一。
RAG = Retrieval(检索)+ Augmentation(增强)+ Generation(生成)
| 阶段 | 作用 | 本质 |
|---|---|---|
| Retrieval 检索 | 从知识库中找到相关内容 | 向量相似度搜索 |
| Augmentation 增强 | 将检索结果注入 Prompt | 构建上下文 |
| Generation 生成 | LLM 基于增强上下文生成回答 | 概率预测 |
1.2 为什么需要 RAG?
LLM 的预训练数据有截止日期,且无法访问你的私有数据。RAG 让 LLM 在回答前先"查阅资料",大幅减少幻觉。
1.3 传统搜索 vs 语义搜索
传统搜索(关键词匹配):
搜索 "马铃薯怎么做" → 匹配 "马铃薯" → ❌ 找不到 "酸辣土豆丝"
语义搜索(向量距离):
搜索 "马铃薯怎么做" → embedding →
"酸辣土豆丝" 的 embedding 距离最近 → ✅ 找到了!
| 维度 | 传统搜索 | 语义搜索 |
|---|---|---|
| 匹配方式 | 字面匹配(LIKE '%vue%') | 语义理解(向量距离) |
| 同义词处理 | 无法处理 | 马铃薯 ≈ 土豆 |
| 跨语言 | 不支持 | 可以(向量空间中语义相近) |
| 技术实现 | 正则 / SQL LIKE | Embedding + 余弦相似度 |
二、项目架构
2.1 技术栈
| 技术 | 用途 |
|---|---|
| Node.js + ESM (.mjs) | 运行环境 |
| OpenAI SDK | 统一 API 接口 |
| 通义千问 text-embedding-v4 | Embedding 模型 |
| fs/promises | 文件读写 |
| readline | 命令行交互 |
2.2 项目结构
rag-semantic-search/
├── app.service.mjs # 服务层:封装 OpenAI client
├── create-embedding.mjs # Embedding 生成:批量向量化
├── semantic-search.mjs # 语义搜索:交互式搜索
├── data/
│ ├── posts.json # 原始文章数据(标题+分类)
│ └── posts-embedding.json # 向量化后的数据(+1024维向量)
└── .env # API Key 配置
三、服务层:封装 OpenAI Client
3.1 为什么单独抽离 service?
大型项目中,LLM client 会被多个模块复用(Embedding、Chat、Tool Calling 等)。将其抽离为独立服务模块,是项目架构的基本功。
app.service.mjs:
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
// 模块化输出 client 可复用
// 大型项目的风骨:app 应用 → service 服务 → 获取 LLM 能力
export const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});
3.2 关键设计点
- 模块化导出:
export const client让其他文件import { client }即可复用 - OpenAI SDK 兼容:通义千问 DashScope 提供了 OpenAI 兼容模式,只需改
baseURL,代码完全通用 - dotenv 安全:API Key 存在
.env文件中,不硬编码到代码里
四、Embedding 生成:将文章批量向量化
4.1 数据准备
data/posts.json 包含 35 篇技术文章,每篇有 title 和 category:
[
{ "title": "如何使用 Nuxt.js 进行服务器端渲染", "category": "前端开发" },
{ "title": "使用 Nest.js 和 TypeScript 构建一个简单的微服务应用", "category": "后端开发" },
{ "title": "如何在 Vue.js 中使用 Vuetify 实现 Material Design 风格", "category": "前端开发" },
...
]
4.2 批量 Embedding 脚本
create-embedding.mjs:
import fs from 'fs/promises'; // Node.js 内置的 Promise 版 fs 模块
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);
// 防止 API 限流:休眠函数
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const postsWithEmbeddings = [];
// 遍历每篇文章,生成 Embedding
for (const { title, category } of posts) {
console.log(title, category, "embedding...");
const response = await client.embeddings.create({
model: 'text-embedding-v4',
// 组合标题+分类 → 语义更准确
input: `标题:${title},分类:${category}`
});
postsWithEmbeddings.push({
title,
category,
embedding: response.data[0].embedding
});
// 休眠 200ms 防止触发 API 限流
await sleep(200);
}
console.log("embedding 完成");
// 持久化写入文件
await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbeddings, null, 2));
4.3 关键技术点
1. fs/promises 是 Node.js 内置的 Promise 版文件系统模块
Node.js 从 2009 年出世,到 ES6 2015 年引入 Promise,再到内置 fs/promises 模块,异步文件操作终于可以用 await 优雅地处理了。
2. Embedding 输入的拼接技巧
input: `标题:${title},分类:${category}`
将标题和分类拼接,能让 Embedding 同时捕捉文章的主题语义和领域语义,比单独用标题更准确。
3. 限流控制(Rate Limiting)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await sleep(200);
大多数 Embedding API 都有请求频率限制。每次请求后休眠 200ms,是生产环境的常见做法。
4.4 生成结果
data/posts-embedding.json:每篇文章多了一个 embedding 字段(1024 维浮点数组):
[
{
"title": "如何使用 Nuxt.js 进行服务器端渲染",
"category": "前端开发",
"embedding": [
-0.03077, 0.04174, 0.04222, -0.06729,
0.02360, -0.03552, 0.01319, ...
// 共 1024 维
]
},
...
]
五、语义搜索:余弦相似度 + 命令行交互
5.1 余弦相似度算法
余弦相似度(Cosine Similarity)是衡量两个向量方向一致性的指标,值域为 [-1, 1]:
A · B
cos(θ) = --------
|A| × |B|
A · B = 向量点积(对应元素相乘再求和)
|A| = 向量 A 的模(各元素平方和的平方根)
|B| = 向量 B 的模
- 值越接近 1 → 方向越一致 → 语义越相似
- 值越接近 0 → 正交无关 → 语义无关
- 值越接近 -1 → 方向相反 → 语义相反
JavaScript 实现:
const cosineSimilarity = (v1, v2) => {
// 步骤1:计算点积
const dotProduct = v1.reduce((acc, cur, idx) => acc + cur * v2[idx], 0);
// 步骤2:计算向量 A 的模
const magnitudeV1 = Math.sqrt(v1.reduce((acc, cur) => acc + cur * cur, 0));
// 步骤3:计算向量 B 的模
const magnitudeV2 = Math.sqrt(v2.reduce((acc, cur) => acc + cur * cur, 0));
// 步骤4:余弦值 = 点积 / (模A × 模B)
return dotProduct / (magnitudeV1 * magnitudeV2);
};
5.2 命令行交互式搜索
semantic-search.mjs:
import fs from 'fs/promises';
import { client } from './app.service.mjs';
import readline from 'readline'; // Node.js 内置的命令行输入输出模块
// 加载向量化后的数据
const inputFilePath = './data/posts-embedding.json';
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);
// 余弦相似度函数
const cosineSimilarity = (v1, v2) => {
const dotProduct = v1.reduce((acc, cur, idx) => acc + cur * v2[idx], 0);
const magnitudeV1 = Math.sqrt(v1.reduce((acc, cur) => acc + cur * cur, 0));
const magnitudeV2 = Math.sqrt(v2.reduce((acc, cur) => acc + cur * cur, 0));
return dotProduct / (magnitudeV1 * magnitudeV2);
};
// 创建命令行交互接口
const rl = readline.createInterface({
input: process.stdin, // 标准输入:键盘输入
output: process.stdout // 标准输出:屏幕打印
});
// 处理用户搜索请求
const handleInput = async (answer) => {
console.log(answer);
// 步骤1:将用户输入向量化
const response = await client.embeddings.create({
model: 'text-embedding-v4',
input: answer
});
const { embedding } = response.data[0];
// 步骤2:计算与所有文章的相似度,排序取 Top 3
const results = posts
.map(item => ({
...item,
similarity: cosineSimilarity(embedding, item.embedding)
}))
.sort((a, b) => a.similarity - b.similarity) // 从低到高排序
.reverse() // 反转为从高到低
.slice(0, 3) // 取前 3 名
.map((item, index) =>
`${index + 1}. ${item.title} - ${item.category}`
)
.join("\n");
console.log(`\n搜索结果:\n${results}`);
// 继续下一轮搜索(不关闭 rl)
rl.question("\n请输入你要搜索的内容: ", handleInput);
};
// 启动第一轮交互
rl.question("\n请输入你要搜索的内容: ", handleInput);
5.3 搜索效果演示
请输入你要搜索的内容: 马铃薯怎么做
搜索结果:
1. 使用 JavaScript 实现一个简单的计算器应用 - 前端开发
2. 如何使用 CSS 实现网页响应式布局 - 前端开发
3. 如何使用 Scikit-learn 进行机器学习任务 - 数据科学
请输入你要搜索的内容: Vue 相关内容
搜索结果:
1. 如何在 Vue.js 中使用 Vuetify 实现 Material Design 风格 - 前端开发
2. 如何在 Vue.js 中使用 Vuex 进行状态管理 - 前端开发
3. 如何使用 Vue.js 和 Electron 开发桌面应用程序 - 前端开发
搜索"Vue"能精确找到 Vue.js 相关文章,搜索"前端框架"也能找到 React、Vue 等相关内容——这就是语义搜索的威力。
六、项目数据流全景
┌─────────────────────────────────────────────────────────────┐
│ RAG 语义搜索数据流 │
│ │
│ ┌──────────────┐ │
│ │ posts.json │ 原始文章数据(title + category) │
│ └──────┬───────┘ │
│ ↓ │
│ ┌──────────────────────┐ │
│ │ create-embedding.mjs │ │
│ │ │ 遍历每篇文章 │
│ │ 对每篇文章调用 │ → 拼接标题+分类 │
│ │ text-embedding-v4 │ → 调用 Embedding API │
│ │ 生成 1024 维向量 │ → 休眠 200ms 防限流 │
│ └──────┬───────────────┘ │
│ ↓ │
│ ┌───────────────────────┐ │
│ │ posts-embedding.json │ 文章数据 + 1024维向量 │
│ └──────┬────────────────┘ │
│ ↓ │
│ ┌──────────────────────┐ │
│ │ semantic-search.mjs │ │
│ │ │ │
│ │ 用户输入 │ → 向量化查询词 │
│ │ ↓ │ → 计算余弦相似度 │
│ │ Embedding API │ → 排序取 Top N │
│ │ ↓ │ → 展示搜索结果 │
│ │ 余弦相似度计算 │ → 循环等待下一次搜索 │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
七、内容审查与纠错
7.1 代码中可优化的地方
1. 排序逻辑可简化
原代码使用 sort().reverse().slice(),可以简化为更语义化的写法:
// 原写法(正确但不够直观)
.sort((a, b) => a.similarity - b.similarity)
.reverse()
.slice(0, 3)
// 优化写法:直接降序排列
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
2. 建议添加异常处理
当前代码缺少对 Embedding API 调用失败的容错处理,生产环境应添加:
try {
const response = await client.embeddings.create({...});
} catch (err) {
console.error('Embedding API 调用失败:', err.message);
}
3. sleep 函数可抽取为公共工具
sleep 在批量 Embedding 和搜索限流中都可能用到,建议抽到 utils.mjs 中。
八、进阶方向
| 方向 | 说明 |
|---|---|
| 向量数据库 | 用 Milvus / pgvector 替代 JSON 文件,支持百万级数据 |
| 完整 RAG | 将 Top N 检索结果注入 Prompt,让 LLM 生成综合回答 |
| 实时更新 | 监听数据变更,增量更新 Embedding |
| 多模态 | 支持图片 Embedding(如 CLIP 模型),实现图文混合搜索 |
| 前端可视化 | 用 Express + HTML 构建搜索界面,替代命令行 |
知识树
RAG 语义搜索全栈实战
├── RAG 原理
│ ├── Retrieval(检索)← Embedding + 余弦相似度
│ ├── Augmentation(增强)← 注入 Prompt
│ └── Generation(生成)← LLM 回答
├── 项目结构
│ ├── app.service.mjs ← OpenAI Client 封装
│ ├── create-embedding.mjs ← 批量向量化
│ ├── semantic-search.mjs ← 交互式搜索
│ └── data/ ← JSON 数据文件
├── 核心技术
│ ├── Embedding API(text-embedding-v4)
│ ├── 余弦相似度算法(点积/模)
│ ├── fs/promises 文件操作
│ ├── readline 命令行交互
│ └── sleep 限流控制
└── 进阶方向
├── 向量数据库(Milvus / pgvector)
├── 完整 RAG(R + A + G)
└── 多模态搜索
结语
RAG 语义搜索是 AI 工程化最实用的技术之一。本文从 0 到 1 实现了一个完整的搜索系统,核心只有三步:
- 向量化:把文字变成数字(Embedding API)
- 算距离:用余弦相似度衡量相关性(纯数学计算)
- 取结果:排序取 Top N 返回给用户(数组操作)
这三步看似简单,却构成了现代搜索引擎、推荐系统、智能客服的底层逻辑。掌握了这个模式,你就掌握了 AI 应用开发的核心技能之一。
从关键词匹配到语义理解,从数据库 LIKE 到向量余弦距离——搜索的进化,就是 AI 工程化的缩影。
参考与拓展阅读:
- 通义千问 text-embedding-v4 API 文档
- 《LLM 分词与向量化原理实战》—— Tokenization 与 Embedding 完全指南
- Milvus 向量数据库官方文档
- pgvector —— PostgreSQL 的向量搜索扩展
- 《上下文工程实战》—— RAG 是上下文工程的核心应用
如果本文帮你理解了 RAG 语义搜索的完整实现,欢迎点赞 + 收藏。有任何疑问,欢迎在评论区交流讨论 👇
#RAG #语义搜索 #Embedding #Node.js #AI工程化 #掘金技术社区