用RAG + Node.js 手搓一个语义搜索引擎,真香!

告别"Like %关键词%",让搜索真正理解你的意思

你有没有遇到过这种情况:在后台管理系统中搜"马铃薯怎么做",结果却搜出一堆包含"土豆"但完全不相关的文章?或者想找"Vue相关的内容",却只能靠标题精确匹配,漏掉了大量同义但表述不同的优质内容?

这就是传统关键词搜索的痛点------它只看字面,不理解语义。

今天,我们就用 RAG(检索增强生成) 的思路,配合 Node.js + 向量数据库(pgvector/Milvus),手搓一个语义搜索引擎。代码量不大,但效果立竿见影。


一、RAG 是什么?先拆解一下

RAG 全称 Retrieval-Augmented Generation ,但在这个场景里,我们主要用它的检索(Retrieval) 部分,不一定要接大模型生成。

它的核心流程就三步:

步骤 含义 我们的做法
Retrieval(检索) 从知识库中找回最相关的内容 向量相似度匹配
Augment(增强) 把检索结果拼进上下文 可选,这里暂不展开
Generate(生成) 让LLM基于上下文回答 后续可接大模型

今天我们的目标是:输入"酸辣土豆丝的做法",能精准召回"马铃薯怎么做好吃"这条内容,哪怕它不包含"酸辣"和"土豆丝"这两个词。


二、整体架构:从数据到搜索

我们项目的目录结构长这样(别嫌乱,真实项目就是这么朴实无华):

text

bash 复制代码
posts-demo/
├── data/
│   └── posts.json          # 原始数据(标题 + 分类)
├── app.service.mjs         # 封装 OpenAI 客户端(复用)
├── create-embedding.mjs    # 离线生成向量数据
├── semantic-search.mjs     # 命令行交互式搜索
├── .env                    # 环境变量(API Key)
└── package.json

一句话概括流程:

数据 → Embedding(向量化)→ 存储 → 用户问题 → 向量化 → 余弦相似度匹配 → 返回 TopK


三、第一步:封装 LLM 客户端(app.service.mjs)

为了方便复用,我们单独抽出一个 app.service.mjs,负责管理 OpenAI 兼容的 Embedding 服务

javascript

arduino 复制代码
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();

// 大型项目的风骨:app 应用 service 获取 LLM 服务
// 模块化输出 client,方便复用
export const client = new OpenAI({
  apiKey: process.env.DASHSCORE_API_KEY,
  baseURL: process.env.DASHSCORE_API_BASE_URL,
});

这里用的是 DashScope 的 text-embedding-v4 模型,你也可以替换成 OpenAI 的 text-embedding-ada-002 或其他。


四、第二步:给数据做 Embedding(create-embedding.mjs)

我们需要把 posts.json 里的每一条内容(标题 + 分类)转成高维向量

javascript

javascript 复制代码
import { client } from './app.service.mjs';
import fs from 'fs/promises';

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

const posts = JSON.parse(await fs.readFile('./data/posts.json', 'utf-8'));
const postsWithEmbedding = [];

for (const { title, category } of posts) {
  console.log(title, category, 'embedding 中...');
  
  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); // 限频,避免被 API 拒
}

await fs.writeFile(
  './data/posts-embedding.json',
  JSON.stringify(postsWithEmbedding, null, 2)
);

console.log('✅ 向量数据写入成功!');

⚠️ 注意:这里我们特意把 titlecategory 拼在一起作为 input,让向量同时包含主题和分类信息,语义更丰富。


五、第三步:语义搜索的核心(semantic-search.mjs)

5.1 加载向量数据 + 定义余弦相似度

javascript

javascript 复制代码
import { client } from './app.service.mjs';
import fs from 'fs/promises';

const data = await fs.readFile('./data/posts-embedding.json', 'utf-8');
const posts = JSON.parse(data);

// 余弦相似度:衡量两个向量在高维空间的"方向一致性"
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));
  return dotProduct / (lengthV1 * lengthV2);
};

5.2 命令行交互 + 搜索逻辑

我们用 Node.js 内置的 readline 模块实现一个问答式交互界面

javascript

javascript 复制代码
import readline from 'readline';

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const handleInput = async (answer) => {
  // 1. 把用户的问题也向量化
  const response = await client.embeddings.create({
    model: 'text-embedding-v4',
    input: answer,
  });
  const { embedding } = response.data[0];

  // 2. 计算每条数据的相似度,排序,去重,取 Top3
  const results = posts
    .map(item => ({
      ...item,
      similarity: cosineSimilarity(item.embedding, embedding),
    }))
    .sort((a, b) => b.similarity - a.similarity)
    .filter((item, index, arr) => 
      index === arr.findIndex(t => t.title === item.title)
    )
    .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);

六、效果对比:传统搜索 vs 语义搜索

用户输入 传统 SQL (LIKE '%vue%') 语义搜索
"Vue 相关的内容" 只能匹配标题包含 "vue" 的 能召回 "Vue3 组合式 API 实战"、"Pinia 状态管理" 等语义相关内容
"酸辣土豆丝的做法" 必须含 "酸辣" 或 "土豆丝" 能召回 "马铃薯怎么做好吃"
"怎么学前端" 可能匹配不到 "前端工程化" 能召回所有涉及前端学习路径的内容

核心区别 :语义搜索把文本映射到高维空间,用余弦距离衡量"意思的远近",而不是"字面的重合"。


七、进阶:接入向量数据库(生产环境必备)

在 demo 里我们直接 JSON.parse 全量加载,数据量大了肯定扛不住。

生产环境建议接入:

  • pgvector(PostgreSQL 扩展)------ 适合中小型项目,沿用 SQL 习惯
  • Milvus ------ 专门为向量检索设计的分布式数据库
  • Qdrant / Weaviate ------ 云原生友好

我们项目里已经在规划这一步了(看图里的 milvuspostgresql 标签),后续会补上。


八、总结与思考

通过这个项目,我们实际上手了:

  1. RAG 检索环节的完整实现
  2. ✅ 用 Embedding 模型把自然语言变成向量
  3. 余弦相似度的计算与排序
  4. ✅ 命令行交互式搜索的构建
  5. ✅ 模块化设计思想(app.service.mjs 复用客户端)

一点小思考

  • Embedding 质量决定搜索上限 :选一个好的向量模型(如 text-embedding-v4)比调参更重要。
  • 去重和过滤策略:实际业务中还要考虑分类过滤、权限过滤等。
  • 延迟与成本的平衡:实时 Embedding 调用 API 有成本,对于高频场景可以预计算 + 缓存
相关推荐
甲维斯3 小时前
普通人慎入!Fable5原创游戏“死线求生”第一版!
人工智能·ai编程·游戏开发
北鹤M3 小时前
ai模特画图高效实现换装,批量生图工具实测体验
人工智能·aigc
码哥字节3 小时前
OpenSpec+Superpowers焊死后,AI编码工作流终于自洽了
ai编程·openspec·superpowers·spec-superflow
代码不接地4 小时前
【时间序列预测】0.时间序列任务知识地图
python·ai编程
海兰4 小时前
【AI编程思考:第六篇】学习记忆与 Agent 状态:让 AI 真正“记得”你
人工智能·学习·ai编程
雨辰AI5 小时前
【零基础实战】大模型入门面试 100 问:基础概念 + 环境实操(一问一答版,直接背诵)
人工智能·ai·面试·职场和发展·ai编程
anyup5 小时前
uView Pro 正式支持 MCP 协议!让 AI 秒懂你的组件库
前端·uni-app·ai编程
FogLetter5 小时前
从零到一打造RAG智能问答系统:当《蛊真人》遇上了AI
aigc·openai