从“酸辣土豆丝”到“马铃薯做法”:手把手教你用 RAG 实现语义搜索

发布时间:2026/7/6 13:26:31

从“酸辣土豆丝”到“马铃薯做法”:手把手教你用 RAG 实现语义搜索 当用户搜“马铃薯怎么做”时传统搜索还在执着地匹配“马铃薯”三个字而语义搜索已经聪明地把“酸辣土豆丝的做法”推到了第一位。这背后就是 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 项目结构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 交互。这个模块会被其他脚本复用。// 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[ { title: 酸辣土豆丝的做法, category: 美食 }, { title: Vue 3 响应式原理, category: 前端 }, { title: 马铃薯种植技术, category: 农业 } ]现在我们要把这些数据向量化并存储到新的 JSON 文件中。// 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/promisesNode.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 第三步实现语义搜索终于到了核心环节。首先需要一个余弦相似度计算函数// 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]之间。然后是完整的搜索逻辑包括命令行交互和向量检索// 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 readlineNode 内置模块提供逐行读取输入流的能力我们用它实现命令行问答交互。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 踩坑记录箭头函数 对象字面量写这段代码时我踩了一个坑分享给大家// ❌ 错误写法箭头函数直接返回对象时{} 会被解析为代码块 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 接口能正常工作我们可以单独写一个测试脚本// 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搜索 马铃薯请输入你要搜索的内容马铃薯 搜索结果 1. 马铃薯种植技术 (相似度: 0.9876) 2. 酸辣土豆丝的做法 (相似度: 0.8234) ← 语义关联 3. 红薯的多种吃法 (相似度: 0.6543)测试 2搜索 怎么炒土豆请输入你要搜索的内容怎么炒土豆 搜索结果 1. 酸辣土豆丝的做法 (相似度: 0.9123) 2. 马铃薯种植技术 (相似度: 0.6543) ← 虽然不直接相关但语义上有土豆关联 3. 红烧肉的做法 (相似度: 0.5123)可以看到即使用户搜的是“马铃薯”系统也能把“酸辣土豆丝”推出来——因为它们在向量空间中是“邻居”。五、进阶让检索结果真正增强生成目前的实现只完成了 RAG 中的Retrieval检索。如果想让搜索结果真正变成生成式回答只需要把检索到的内容塞给大模型// 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 协议的关联最后简单聊一下 MCPModel Context Protocol。如果你是做 AI Agent 开发的可能会对这个协议感兴趣。MCP 协议本质上是在解决一个问题如何让 AI 模型安全、标准地访问外部工具和数据。在我们的 RAG 场景中如果把“语义搜索”封装成一个 MCP Tool大模型如 Claude通过 MCP 协议调用我们的搜索工具搜索工具返回检索结果大模型基于结果生成最终回答这样做的好处是搜索能力可以被任何支持 MCP 的 Agent 复用不需要每个 Agent 都重新实现一遍。// 伪代码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 语义搜索。如果觉得有用欢迎点赞收藏也欢迎在评论区交流你的实践经验。

相关新闻