🧠 AI全栈项目第十四天 - 赋予搜索灵魂:Embedding 实现语义化搜索

4 阅读6分钟

前言

嗨,各位全栈练习生们!👋 欢迎回到我们的 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" }
    ]
  }
}

🧮 搜索算法实现

终于到了核心的搜索逻辑。

  1. 用户输入向量化:把用户的查询词变成向量。
  2. 相似度计算:拿着这个向量去和数据库里的几百个向量一一比对。
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 赋予传统应用的“灵魂”。

回顾一下今天的知识点:

  1. Embedding 概念:文本 -> 向量。
  2. OpenAI APIcreateEmbedding 的使用。
  3. Node.js 技巧fs/promisesnest-cli 的静态资源配置。
  4. 数学基础:余弦相似度算法的实现。

前端只需要像调用普通接口一样调用这个 /ai/search 接口,就能立刻拥有一个比肩谷歌的智能搜索体验!

快去试试吧!Happy Coding! 🚀


如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!我们下期见!💖