前言
嗨,各位全栈练习生们!👋 欢迎回到我们的 AI 全栈项目!
在上一篇文章中,我们实现了一个基于关键词匹配的搜索功能。虽然它能用,但在 AI 时代,这种“老古董”搜索方式显然不够看。 举个栗子:用户搜 "Hello",文章标题里只有 "你好"。
- 关键词搜索:❌ 匹配失败(字符完全不同)。
- 人类大脑:✅ 这俩不是一个意思吗?
如何让计算机拥有像人一样的“理解能力”?答案就是——Embedding(向量嵌入)。
今天,我们就来玩点高端的!我们将抛弃传统的字符串匹配,用数学的方式来重新定义搜索!🚀
🧐 什么是 Embedding?
在计算机的世界里,"Hello" 和 "你好" 是两个完全不同的字符串。但在高维向量空间里,它们的含义可能非常接近。
Embedding 就是把一段文本变成一串数字列表(向量)。
- 比如 "苹果" 可能是
[0.1, 0.2, 0.9] - 比如 "香蕉" 可能是
[0.1, 0.3, 0.8] - 比如 "卡车" 可能是
[0.9, 0.1, 0.1]
你会发现,"苹果"和"香蕉"的数字长得比较像,和"卡车"差得远。
我们这次使用的 OpenAI text-embedding-ada-002 模型,会用 1536 个数字(维度)来精准描述一段文本的含义。
🔍 如何比较相似度? 我们使用 余弦相似度 (Cosine Similarity)。在数学上,两个向量夹角的余弦值越接近 1,表示它们的方向越一致(含义越相似);越接近 0,表示它们毫不相关。
原理懂了,代码搞起!💪
🧪 第一步:Demo 尝鲜 —— 万物皆可向量化
在集成到项目之前,我们先写个脚本,把我们的文章数据“升级”一下,给它们加上向量 Buff。
进入 demo/embedding-demo 目录。
1.1 初始化 OpenAI 客户端
首先,我们需要一个能和 OpenAI 对话的客户端。
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // 🔑 记得在 .env 里配好你的 Key
baseURL: process.env.OPENAI_BASE_URL, // 如果用了代理,这里要配置
});
我们来看看 OpenAI 提供的神奇接口:
// completions.create() -> 文本生成
// completions.chat.create() -> 聊天生成
// embeddings.create() -> 向量生成 [ 0.23, ...... ]
// 👇 我们今天的主角
const response = await client.embeddings.create({
model: 'text-embedding-ada-002', // 💡 业内公认性价比最高的 Embedding 模型
input: '你好',
});
1.2 批量生产向量脚本
我们有一份 posts.json,里面是文章标题和分类。我们要把它们变成带有 embedding 字段的新文件。
打开 create-embedding.mjs。
📦 引入模块
// 💡 Node.js 新版福利:fs/promises
// 以前写 fs.readFile还得传回调函数,现在直接 await,爽!
import fs from 'fs/promises';
📂 路径配置与数据读取
const inputFilePath = './data/posts.json'; // 源文件
const outputFilePath = './data/posts-embedding.json'; // 目标文件
// 📖 读取文件是 I/O 操作,记得 await
const data = await fs.readFile(inputFilePath, 'utf-8');
// 序列化:把字符串变成 JS 对象
const posts = JSON.parse(data);
🔄 循环生成与写入
这里是脚本的核心。我们需要遍历每篇文章,把它的关键信息喂给模型。
// 💡 这里简单起见用了 for...of 串行执行
// 如果数据量大,可以考虑 Promise.all 并发,但要注意 API Rate Limit
for(const {title, category} of posts){
const response = await client.embeddings.create({
model: 'text-embedding-ada-002',
// 💡 拼接 prompt:我们要搜索标题和分类,所以把它们拼在一起生成向量
input: `标题:${title} 分类: ${category}`,
});
postsWithEmbedding.push({
title,
category,
// 🎁 拿到核心资产:1536维的向量数组
embedding: response.data[0].embedding,
});
// 💾 实时保存:每处理一条就写一次文件(生产环境别这么干,这里是为了演示进度)
await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));
}
跑完这个脚本,你就得到了一份价值连城的 posts-embedding.json!💎
🏗️ 第二步:后端实战 —— NestJS 集成
数据准备好了,现在回到我们的 NestJS 后端项目 (backend/posts)。
2.1 搬运数据
先把刚才生成的 posts-embedding.json 复制到 src/data/ 目录下。这是我们的“向量数据库”(虽然是基于文件的简易版)。
2.2 Controller 层:接收请求
打开 src/ai/ai.controller.ts,我们需要一个新的接口。
@Get('search')
async search(@Query() dto: SearchDto) {
const { keyword } = dto;
// 💡 URL 解码:前端传过来的可能是 %E4%BD%A0%E5%A5%BD,要转回 "你好"
let decoded = decodeURIComponent(keyword);
return this.aiService.search(decoded);
}
对应的 DTO 校验 (src/ai/dto/search.dto.ts) 也很简单,确保 keyword 非空且是字符串即可。
2.3 Service 层:核心逻辑
这是见证奇迹的地方。打开 src/ai/ai.service.ts。
⚙️ 初始化与配置
我们这里使用了 LangChain 封装的 OpenAIEmbeddings,它帮我们简化了调用流程。
this.embeddings = new OpenAIEmbeddings({
configuration: {
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL
},
model: 'text-embedding-ada-002'
})
// 🚀 服务启动时,先把本地的向量数据加载到内存里
this.loadPosts();
📂 加载数据与工程化深坑
这里有一个经典的 Node.js 路径陷阱。
private async loadPosts() {
try {
// ⚠️ 坑点预警:
// 代码运行的时候是在 dist 目录下的,而不是 src 目录
// 所以要用 path.join 和 __dirname 来定位
const filePath = path.join(__dirname, "../../", "data", "posts-embedding.json");
const data = await fs.readFile(filePath, 'utf-8');
this.posts = JSON.parse(data);
} catch(err) {
// ... 错误处理
}
}
🚨 注意:默认情况下,NestJS 编译 (npm run build) 只会编译 .ts 文件,src/data 里的 .json 文件不会被复制到 dist 目录。
解决办法:修改 nest-cli.json。
{
"compilerOptions": {
"deleteOutDir": true,
// ✅ 告诉 Nest:编译的时候把 data 文件夹里的东西也带上!
"assets": [
{ "include": "data/**/*", "outDir": "dist" }
]
}
}
🧮 搜索算法实现
终于到了核心的搜索逻辑。
- 用户输入向量化:把用户的查询词变成向量。
- 相似度计算:拿着这个向量去和数据库里的几百个向量一一比对。
async search(keyword: string, topK: number = 3) {
// 1️⃣ 将用户搜索词转换为向量 (调用 OpenAI 接口)
const vector = await this.embeddings.embedQuery(keyword);
const results = this.posts.map(post => ({
...post,
// 2️⃣ 数学魔法:计算余弦相似度
// cosineSimilarity 是我们自己实现的一个数学公式函数
similarity: cosineSimilarity(vector, post.embedding)
}))
// 3️⃣ 排序:相似度高的排前面
.sort((a, b) => b.similarity - a.similarity)
// 4️⃣ 截取:只取前 K 个最相关的
.slice(0, topK)
// 5️⃣ 瘦身:只返回标题给前端
.map(item => item.title);
return results;
}
➕ 附赠:余弦相似度公式
虽然有很多库可以用,但手写一个能让你更懂原理:
export function cosineSimilarity(v1: number[], v2: number[]): number {
// 分子:点积 (A · B)
const dotProduct = v1.reduce((sum, val, i) => sum + val * v2[i], 0);
// 分母:模长乘积 (|A| * |B|)
const normV1 = Math.sqrt(v1.reduce((sum, val) => sum + val * val, 0));
const normV2 = Math.sqrt(v2.reduce((sum, val) => sum + val * val, 0));
return dotProduct / (normV1 * normV2);
}
🎉 总结
到这里,我们的语义化搜索后端就大功告成了!🏆
现在,当你在前端搜索 "前端框架" 时,哪怕文章标题里没有这四个字,但因为 "Vue", "React", "Nuxt" 的向量与 "前端框架" 在高维空间里靠得很近,它们依然会被精准地检索出来。
这就是 Embedding 的魔力,也是 AI 赋予传统应用的“灵魂”。
回顾一下今天的知识点:
- Embedding 概念:文本 -> 向量。
- OpenAI API:
createEmbedding的使用。 - Node.js 技巧:
fs/promises和nest-cli的静态资源配置。 - 数学基础:余弦相似度算法的实现。
前端只需要像调用普通接口一样调用这个 /ai/search 接口,就能立刻拥有一个比肩谷歌的智能搜索体验!
快去试试吧!Happy Coding! 🚀
如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!我们下期见!💖