告别"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('✅ 向量数据写入成功!');
⚠️ 注意:这里我们特意把
title和category拼在一起作为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 ------ 云原生友好
我们项目里已经在规划这一步了(看图里的 milvus 和 postgresql 标签),后续会补上。
八、总结与思考
通过这个项目,我们实际上手了:
- ✅ RAG 检索环节的完整实现
- ✅ 用 Embedding 模型把自然语言变成向量
- ✅ 余弦相似度的计算与排序
- ✅ 命令行交互式搜索的构建
- ✅ 模块化设计思想(
app.service.mjs复用客户端)
一点小思考
- Embedding 质量决定搜索上限 :选一个好的向量模型(如
text-embedding-v4)比调参更重要。 - 去重和过滤策略:实际业务中还要考虑分类过滤、权限过滤等。
- 延迟与成本的平衡:实时 Embedding 调用 API 有成本,对于高频场景可以预计算 + 缓存。