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

当用户搜"马铃薯怎么做"时,传统搜索还在执着地匹配"马铃薯"三个字;而语义搜索已经聪明地把"酸辣土豆丝的做法"推到了第一位。这背后,就是 RAG 的魅力。

写在前面

前几天,一个朋友问我:"你们的搜索怎么这么笨?我搜'马铃薯怎么做',出来的都是带'马铃薯'的文章,但明明有一篇'酸辣土豆丝的做法'更符合我的需求啊。"

这个问题,做搜索的兄弟们应该都遇到过。

传统的关键词匹配(说白了就是 LIKE '%土豆%')在语义理解面前,就像是一个只会死记硬背的学生------你问"马铃薯",他只知道找"马铃薯",完全不知道"马铃薯"和"土豆"是一回事。

那怎么让搜索变得更聪明?答案就是:RAG(检索增强生成)

今天,我就带着大家从零开始,用 Node.js 实现一个完整的语义搜索引擎。全文约 6000 字,预计阅读时间 12 分钟,读完你就能在自己的项目里落地语义搜索

搜索的进化史,就是从"匹配字符"到"理解含义"的进化史。

一、RAG 是什么?能当饭吃吗?

RAG 全称 Retrieval-Augmented Generation,即检索增强生成。它由三个核心环节组成:

  1. Retrieval(检索) :从知识库中找到与问题最相关的内容
  2. Augmented(增强) :将检索到的内容作为上下文,增强提示词
  3. 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.createchat.completions 等方法。
  • import dotenv from "dotenv"dotenv.config():让 Node.js 读取项目根目录的 .env 文件,把键值对挂载到 process.env 上。这里我们用它来存放 DASHSCOPE_API_KEY
  • export const client:导出单例客户端,所有模块共用同一个实例,避免重复初始化。
  • apiKeybaseURL:前者是鉴权凭证,后者指向阿里云的兼容网关。注意 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 化了。这里我们用 readFilewriteFile,不用再 util.promisify 或回调。
  • const data = await fs.readFile(...):顶层 await 在 ES2022 中可以直接在模块中使用,无需包裹 async 函数,非常方便。
  • JSON.parse(data):把文件内容(字符串)转为真正的对象数组。
  • client.embeddings.create():向 /embeddings 端点发送 POST 请求,参数 modelinput。返回的 response.data[0].embedding 就是 1536 维的浮点数向量(text-embedding-v4 默认输出 1536 维)。
  • sleep(200):用 setTimeout 实现延时,防止短时间内大量请求被 API 限流(429 错误)。
  • JSON.stringify(postsWithEmbedding, null, 2):第三个参数是缩进空格数,让 JSON 文件可读性更好。

这里有几个关键点:

  1. input 的构造方式很重要标题: ${title}, 分类: ${category} 比单纯传 title 效果更好,因为让模型知道了文本的上下文语义。
  2. 为什么要 sleep(200) ?大部分 API 都有 QPS 限制,不加限流容易 429。
  3. 为什么要存到 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 => expressionparam => { 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 链路:

  1. Retrieval:从知识库中检索相关片段
  2. Augmented:将片段作为上下文增强提示词
  3. Generation:大模型基于上下文生成精准答案

金句:RAG 的本质,就是给大模型配一个"外接大脑",让它不再依赖训练数据中的记忆。

六、优化方向

6.1 当数据量变大时

  • MilvusPgvectorQdrant 替代 JSON 文件存储
  • HNSWIVF 索引加速相似度计算

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 更聪明地获取信息

写在最后

回顾一下我们做了什么:

  1. ✅ 理解了 RAG 的核心流程
  2. ✅ 封装了 OpenAI 兼容的 LLM 客户端(app.service.mjs
  3. ✅ 批量向量化了原始数据(create-embedding.mjs
  4. ✅ 实现了基于余弦相似度的语义搜索(semantic-search.mjs
  5. ✅ 讨论了优化方向和 MCP 协议的关联

核心代码加起来不到 200 行,但已经能解决传统搜索最头疼的"语义鸿沟"问题。

搜索的进化,本质上是从"字面匹配"到"语义理解"的跨越。而 RAG 的出现,让这个跨越变得触手可及。

希望这篇文章能帮你快速上手 RAG 语义搜索。如果觉得有用,欢迎点赞收藏,也欢迎在评论区交流你的实践经验。

相关推荐
小林ixn1 小时前
用 100 行代码手搓一个 MCP Server,让 LLM 直接读你本地文件
面试·llm
睿智的羊2 小时前
Cove API 的 RAG 模块拆解:一套面向 Agent 的可组合知识检索工具体系
人工智能
love530love2 小时前
AI Agent + 本地 ComfyUI 无头模式实战:关闭 IDE 后 AI 独立重启并完成图文生成
ide·人工智能·windows·python·音视频·agent·devops
FriendshipT2 小时前
Ultralytics:解读Attention模块
人工智能·pytorch·python·深度学习·目标检测
生活爱好者!2 小时前
AI加持的笔记工具,比备忘录好用,NAS一键部署blinko
人工智能·笔记
IT_陈寒2 小时前
SpringBoot自动配置没生效?你可能漏了这个注解
前端·人工智能·后端
今日综合2 小时前
2026精选教务管理系统深度分析:功能差异、收费模式全拆解
大数据·人工智能
组合缺一2 小时前
用 ChatModel 构建 LLM 驱动的 Java 应用
java·开发语言·ai·llm·solon·rag
SilentSamsara2 小时前
模型部署方案选型:REST/gRPC/批量推理/边缘部署的场景决策
人工智能·深度学习·算法·机器学习