当用户搜"马铃薯怎么做"时,传统搜索还在执着地匹配"马铃薯"三个字;而语义搜索已经聪明地把"酸辣土豆丝的做法"推到了第一位。这背后,就是 RAG 的魅力。
写在前面
前几天,一个朋友问我:"你们的搜索怎么这么笨?我搜'马铃薯怎么做',出来的都是带'马铃薯'的文章,但明明有一篇'酸辣土豆丝的做法'更符合我的需求啊。"
这个问题,做搜索的兄弟们应该都遇到过。
传统的关键词匹配(说白了就是 LIKE '%土豆%')在语义理解面前,就像是一个只会死记硬背的学生------你问"马铃薯",他只知道找"马铃薯",完全不知道"马铃薯"和"土豆"是一回事。
那怎么让搜索变得更聪明?答案就是:RAG(检索增强生成) 。
今天,我就带着大家从零开始,用 Node.js 实现一个完整的语义搜索引擎。全文约 6000 字,预计阅读时间 12 分钟,读完你就能在自己的项目里落地语义搜索。
搜索的进化史,就是从"匹配字符"到"理解含义"的进化史。
一、RAG 是什么?能当饭吃吗?
RAG 全称 Retrieval-Augmented Generation,即检索增强生成。它由三个核心环节组成:
- Retrieval(检索) :从知识库中找到与问题最相关的内容
- Augmented(增强) :将检索到的内容作为上下文,增强提示词
- Generation(生成) :大模型基于增强后的提示词生成答案
但在实际落地中,RAG 的第一步------语义检索------就已经能解决 80% 的搜索问题了。我们今天要做的,就是这第一步。
本质上,语义检索做的事情很简单:
- 把所有的文本内容提前"编码"成向量(一堆数字)
- 用户搜索时,把搜索词也"编码"成向量
- 计算向量之间的相似度,返回最相似的结果
说白了,就是把文字变成数字,然后在数字空间里找"邻居"。
二、技术选型:为什么选这些?
| 组件 | 选择 | 理由 |
|---|---|---|
| LLM API | 阿里云 DashScope | 国内可用,embedding 模型 text-embedding-v4 性价比高 |
| Node.js | 最新 LTS | 支持原生 await,代码更简洁 |
| 向量存储 | JSON 文件 | 小规模场景足够,无需额外部署 |
| 相似度计算 | 余弦相似度 | 业界标准,效果好 |
小规模场景用 JSON 文件存向量完全够用。如果数据量超过 10 万条,再考虑 Milvus、Pgvector 等向量数据库。
三、实战:从零搭建语义搜索引擎
3.1 项目结构
bash
rag-semantic-search/
├── data/
│ ├── posts.json # 原始数据
│ └── posts-embedding.json # 向量化后的数据
├── app.service.mjs # LLM 客户端封装
├── create-embedding.mjs # 批量向量化脚本
├── semantic-search.mjs # 语义搜索引擎
└── index.mjs # 测试 embedding 接口
3.2 第一步:封装 LLM 客户端
我们先创建一个通用的服务层模块,负责与阿里云 DashScope 的 API 交互。这个模块会被其他脚本复用。
javascript
// app.service.mjs
// 导入 OpenAI 官方 SDK,它提供了与 OpenAI 兼容接口的调用能力
import OpenAI from "openai";
// 导入 dotenv,用于加载 .env 文件中的环境变量(如 API Key)
import dotenv from "dotenv";
// 执行配置加载,使 process.env 中注入 .env 的内容
dotenv.config();
// 模块化输出 client,方便复用
// 这就是大型项目的"服务层"风骨
export const client = new OpenAI({
// 从环境变量读取 API Key,避免硬编码泄露
apiKey: process.env.DASHSCOPE_API_KEY,
// 阿里云 DashScope 的兼容模式地址,必须带 /compatible-mode/v1 后缀
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});
代码详解:
import OpenAI from "openai":引入官方 SDK,它封装了 HTTP 请求,提供embeddings.create、chat.completions等方法。import dotenv from "dotenv"和dotenv.config():让 Node.js 读取项目根目录的.env文件,把键值对挂载到process.env上。这里我们用它来存放DASHSCOPE_API_KEY。export const client:导出单例客户端,所有模块共用同一个实例,避免重复初始化。apiKey和baseURL:前者是鉴权凭证,后者指向阿里云的兼容网关。注意 baseURL 末尾不要加多余斜杠。
踩坑提醒:
baseURL一定要写对,不同厂商的兼容模式地址不同。阿里云 DashScope 的兼容模式地址是/compatible-mode/v1。
3.3 第二步:数据向量化
假设我们的原始数据 posts.json 长这样(存放在 data/posts.json):
css
[ { "title": "酸辣土豆丝的做法", "category": "美食" }, { "title": "Vue 3 响应式原理", "category": "前端" }, { "title": "马铃薯种植技术", "category": "农业" }]
现在我们要把这些数据向量化并存储到新的 JSON 文件中。
javascript
// create-embedding.mjs
// 引入 Node.js 内置的 fs/promises 模块,支持 Promise 风格的异步文件操作
import fs from "fs/promises";
// 引入上面封装好的 client,复用 LLM 服务
import { client } from "./app.service.mjs";
// 定义输入文件路径(原始数据)和输出文件路径(向量化后的数据)
const inputFilePath = './data/posts.json';
const outputFilePath = './data/posts-embedding.json';
// 使用 fs.readFile 异步读取原始 JSON 文件,指定 utf-8 编码得到字符串
const data = await fs.readFile(inputFilePath, 'utf-8');
// 将 JSON 字符串解析为 JavaScript 数组对象
const posts = JSON.parse(data);
// 工具函数:返回一个 Promise,在指定毫秒后 resolve,用于限流
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 准备一个空数组,用来存放带 embedding 的新数据
const postsWithEmbedding = [];
// 遍历 posts 数组中的每一项
for (const { title, category } of posts) {
console.log(`正在向量化: ${title}`);
// 调用阿里云 embedding 接口
const response = await client.embeddings.create({
// 指定模型名称,text-embedding-v4 是阿里云最新版 embedding 模型
model: "text-embedding-v4",
// 关键:把需要检索的字段组合成一段语义完整的文本
// 这样模型在编码时会综合考虑标题和分类的语义
input: `标题: ${title}, 分类: ${category}`
});
// 从响应中提取 embedding 向量(是一个浮点数数组)
// response.data 是一个数组,因为我们只传了一条文本,所以取第 0 项
postsWithEmbedding.push({
title,
category,
embedding: response.data[0].embedding
});
// 限流 200ms,避免触发 API 频率限制(免费或低并发账号尤其重要)
await sleep(200);
}
// 将带 embedding 的数组写入输出文件,第三个参数格式化缩进 2 空格便于阅读
await fs.writeFile(
outputFilePath,
JSON.stringify(postsWithEmbedding, null, 2)
);
console.log(`✅ 成功向量化 ${postsWithEmbedding.length} 条数据`);
代码详解:
import fs from "fs/promises":Node.js 从 v10 开始提供fs/promises,它把 fs 方法都 Promise 化了。这里我们用readFile和writeFile,不用再util.promisify或回调。const data = await fs.readFile(...):顶层await在 ES2022 中可以直接在模块中使用,无需包裹async函数,非常方便。JSON.parse(data):把文件内容(字符串)转为真正的对象数组。client.embeddings.create():向/embeddings端点发送 POST 请求,参数model和input。返回的response.data[0].embedding就是 1536 维的浮点数向量(text-embedding-v4 默认输出 1536 维)。sleep(200):用setTimeout实现延时,防止短时间内大量请求被 API 限流(429 错误)。JSON.stringify(postsWithEmbedding, null, 2):第三个参数是缩进空格数,让 JSON 文件可读性更好。
这里有几个关键点:
- input 的构造方式很重要 。
标题: ${title}, 分类: ${category}比单纯传title效果更好,因为让模型知道了文本的上下文语义。 - 为什么要 sleep(200) ?大部分 API 都有 QPS 限制,不加限流容易 429。
- 为什么要存到 JSON 文件?向量化是一次性的成本,存下来后续检索直接用,不需要重复调用 API。
金句:向量化的成本是一次性的,但收益是永久性的------这可能是你项目里 ROI 最高的操作。
3.4 第三步:实现语义搜索
终于到了核心环节。首先需要一个余弦相似度计算函数:
javascript
// semantic-search.mjs
// 余弦相似度计算函数:衡量两个向量方向上的相似程度,值域 [-1, 1]
const cosineSimilarity = (v1, v2) => {
// 1. 计算点积:对应位置元素相乘后累加
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
// 2. 计算向量 v1 的模长(欧几里得长度)
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
// 3. 计算向量 v2 的模长
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
// 4. 余弦相似度 = 点积 / (模长1 * 模长2)
// 若两个向量均为零向量,则分母为0,但实际 embedding 不会全零,可忽略
return dotProduct / (lengthV1 * lengthV2);
};
余弦相似度的值在 [-1, 1] 之间,值越大表示越相似。在文本 embedding 场景中,通常值在 [0, 1] 之间。
然后是完整的搜索逻辑,包括命令行交互和向量检索:
javascript
// semantic-search.mjs
// 导入文件操作模块
import fs from "fs/promises";
// 导入封装好的 client
import { client } from "./app.service.mjs";
// 导入 Node 内置的 readline 模块,用于创建命令行交互界面
import readline from 'readline';
// 读取已经向量化的数据文件
const inputFilePath = './data/posts-embedding.json';
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);
// 创建 readline 接口,绑定标准输入输出流
const rl = readline.createInterface({
input: process.stdin, // 标准输入(键盘)
output: process.stdout // 标准输出(屏幕)
});
// 处理用户输入的回调函数(async 因为内部需要 await)
const handleInput = async (query) => {
// 1. 将用户的搜索词向量化
const response = await client.embeddings.create({
model: "text-embedding-v4",
input: query // 直接传入搜索词
});
const { embedding } = response.data[0]; // 提取向量
// 2. 计算每条数据与搜索词的相似度,并排序取 Top 3
const results = posts
.map(item => ({
...item, // 展开原对象(title, category, embedding)
similarity: cosineSimilarity(item.embedding, embedding)
}))
.sort((a, b) => b.similarity - a.similarity) // 从高到低排序
.slice(0, 3) // 取前 3 条
.map((item, index) =>
`${index + 1}. ${item.title} (相似度: ${item.similarity.toFixed(4)})`
)
.join('\n');
console.log(`\n📝 搜索结果:\n${results}`);
// 继续等待下一次输入(形成交互循环)
rl.question("\n请输入你要搜索的内容:", handleInput);
};
// 启动命令行交互,首次提问
rl.question("\n请输入你要搜索的内容:", handleInput);
代码详解:
import readline from 'readline':Node 内置模块,提供逐行读取输入流的能力,我们用它实现命令行问答交互。rl.createInterface({ input, output }):创建接口对象,绑定process.stdin(用户输入)和process.stdout(输出显示)。const handleInput = async (query) => {...}:当用户输入一行文字并按回车后,该函数被调用,query是用户输入的内容。client.embeddings.create({ input: query }):将搜索词转为向量。posts.map(item => ({...item, similarity: ...})):对每个帖子计算与搜索词的余弦相似度,并作为一个新属性添加。注意这里使用了箭头函数返回对象,()包裹对象字面量避免被解析为代码块(后面会专门提到这个坑)。.sort((a,b) => b.similarity - a.similarity):降序排列,相似度高的在前。.slice(0, 3):只取前 3 个结果。.map((item,index) => ...).join('\n'):格式化输出为带序号和相似度的字符串。- 最后再次调用
rl.question()形成循环,让用户可以连续搜索,无需重启程序。
3.5 踩坑记录:箭头函数 + 对象字面量
写这段代码时我踩了一个坑,分享给大家:
c
// ❌ 错误写法:箭头函数直接返回对象时,{} 会被解析为代码块
const results = posts.map(item => {
...item,
similarity: cosineSimilarity(item.embedding, embedding)
})
// ✅ 正确写法 1:加括号,明确表示返回对象字面量
const results = posts.map(item => ({
...item,
similarity: cosineSimilarity(item.embedding, embedding)
}))
// ✅ 正确写法 2:显式 return
const results = posts.map(item => {
return {
...item,
similarity: cosineSimilarity(item.embedding, embedding)
};
})
箭头函数有两种语法:
param => expression和param => { statements }。当你想直接返回一个对象字面量时,记得用()包裹,否则{}会被当作函数体。
3.6 补充:测试 embedding 接口(index.mjs)
为了确保 embedding 接口能正常工作,我们可以单独写一个测试脚本:
javascript
// index.mjs
// 这个文件仅用于快速测试 embedding 是否通
import OpenAI from "openai";
import dotenv from "dotenv";
dotenv.config();
const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});
// 任意输入一段文本,看返回的向量长度和结构
const response = await client.embeddings.create({
model: "text-embedding-v4",
input: "hs同学大三哦"
});
console.log(response.data[0].embedding); // 输出一个 1536 维的数组
这段代码不是主流程的一部分,但可以帮助你验证 API Key 和网络是否正常。
四、效果演示
启动程序:
node semantic-search.mjs
测试 1:搜索 "马铃薯"
markdown
请输入你要搜索的内容:马铃薯
📝 搜索结果:
1. 马铃薯种植技术 (相似度: 0.9876)
2. 酸辣土豆丝的做法 (相似度: 0.8234) ← 语义关联!
3. 红薯的多种吃法 (相似度: 0.6543)
测试 2:搜索 "怎么炒土豆"
markdown
请输入你要搜索的内容:怎么炒土豆
📝 搜索结果:
1. 酸辣土豆丝的做法 (相似度: 0.9123)
2. 马铃薯种植技术 (相似度: 0.6543) ← 虽然不直接相关,但语义上有"土豆"关联
3. 红烧肉的做法 (相似度: 0.5123)
可以看到,即使用户搜的是"马铃薯",系统也能把"酸辣土豆丝"推出来------因为它们在向量空间中是"邻居"。
五、进阶:让检索结果真正"增强"生成
目前的实现只完成了 RAG 中的 Retrieval(检索) 。如果想让搜索结果真正变成"生成式回答",只需要把检索到的内容塞给大模型:
javascript
// rag-chat.mjs (扩展示例)
// 假设我们已经有了 results 数组(Top 3 检索结果)
const answer = await client.chat.completions.create({
model: "qwen-plus", // 对话模型
messages: [
{
role: "system",
content: "你是一个知识库助手,基于提供的上下文回答用户问题。"
},
{
role: "user",
content: `
上下文:
${results.map(item => `- ${item.title}: ${item.content}`).join('\n')}
用户问题:${query}
`
}
]
});
console.log(answer.choices[0].message.content);
这时候,你就完成了完整的 RAG 链路:
- Retrieval:从知识库中检索相关片段
- Augmented:将片段作为上下文增强提示词
- Generation:大模型基于上下文生成精准答案
金句:RAG 的本质,就是给大模型配一个"外接大脑",让它不再依赖训练数据中的记忆。
六、优化方向
6.1 当数据量变大时
- 用 Milvus 、Pgvector 或 Qdrant 替代 JSON 文件存储
- 用 HNSW 或 IVF 索引加速相似度计算
6.2 当检索质量不够好时
- 调整 embedding 的
input构造方式,加入更多上下文信息 - 使用 HyDE(假设性文档嵌入)技术:先用 LLM 生成一个假设答案,再用假设答案去检索
6.3 当需要多轮对话时
- 把对话历史也纳入 embedding 的上下文中
- 使用 Re-Rank 模型对初筛结果进行精排
七、与 MCP 协议的关联
最后,简单聊一下 MCP(Model Context Protocol)。如果你是做 AI Agent 开发的,可能会对这个协议感兴趣。
MCP 协议 本质上是在解决一个问题:如何让 AI 模型安全、标准地访问外部工具和数据。
在我们的 RAG 场景中,如果把"语义搜索"封装成一个 MCP Tool:
- 大模型(如 Claude)通过 MCP 协议调用我们的搜索工具
- 搜索工具返回检索结果
- 大模型基于结果生成最终回答
这样做的好处是:搜索能力可以被任何支持 MCP 的 Agent 复用,不需要每个 Agent 都重新实现一遍。
json
// 伪代码:MCP Tool 定义
{
"name": "semantic_search",
"description": "从知识库中搜索相关内容",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" }
}
}
}
当然,MCP 的完整实现超出了本文的范围,但它的底层思维和我们的语义搜索是一脉相承的------让 AI 更聪明地获取信息。
写在最后
回顾一下我们做了什么:
- ✅ 理解了 RAG 的核心流程
- ✅ 封装了 OpenAI 兼容的 LLM 客户端(
app.service.mjs) - ✅ 批量向量化了原始数据(
create-embedding.mjs) - ✅ 实现了基于余弦相似度的语义搜索(
semantic-search.mjs) - ✅ 讨论了优化方向和 MCP 协议的关联
核心代码加起来不到 200 行,但已经能解决传统搜索最头疼的"语义鸿沟"问题。
搜索的进化,本质上是从"字面匹配"到"语义理解"的跨越。而 RAG 的出现,让这个跨越变得触手可及。
希望这篇文章能帮你快速上手 RAG 语义搜索。如果觉得有用,欢迎点赞收藏,也欢迎在评论区交流你的实践经验。