告别关键词匹配,拥抱向量语义 —— RAG 搜索从零到一

从零搭建一个 RAG 语义搜索引擎 ------ 基于阿里云 DashScope

不用数据库,不用搜索引擎,30 行核心代码,让你的应用"看懂"用户到底在搜什么。


一、什么是 RAG?为什么需要语义搜索?

传统的搜索依赖关键词匹配。用户搜"Vue 状态管理",你只能返回标题里包含"Vue"和"状态管理"的文章。但假如有一篇标题是《如何在 Vue.js 中使用 Vuex 进行状态管理》------它完美匹配用户意图,却因为标题措辞不同而排在结果之外。

这就是 RAG(Retrieval-Augmented Generation,检索增强生成)要解决的问题。RAG 的核心思路是:

  1. 把文本变成向量(Embedding):用大模型将任意文本映射到高维数学空间中的一个点;
  2. 语义相近的文本,向量也相近:"Vue 状态管理"和"Vuex 状态管理"在向量空间中靠得很近;
  3. 用向量相似度做搜索:不再匹配关键词,而是匹配语义。

本文带你用一个 Node.js 项目,从零实现这个流程。


二、项目总览

项目结构非常简洁:

bash 复制代码
posts-demo/
├── index.mjs              # 快速验证:调用 Embedding API
├── app.service.mjs        # 服务层:封装 OpenAI 客户端
├── create-embedding.mjs   # 数据预处理:批量向量化文章
├── semantic-search.mjs    # 核心功能:语义搜索
├── data/
│   ├── posts.json         # 原始文章数据(35 篇)
│   └── posts-embedding.json  # 向量化后的数据(运行后生成)
└── readme.md

四个文件,各司其职,清晰分层。运行顺序是:index.mjs(验证连通) → create-embedding.mjs(预处理数据) → semantic-search.mjs(搜索交互)。下面我们逐一拆解。


三、第一步:接入 Embedding 服务

项目使用**阿里云 DashScope(百炼)**作为 LLM 服务商,选择 text-embedding-v4 模型。由于 DashScope 提供了 OpenAI 兼容的 API 格式,我们可以直接使用 OpenAI 的 Node.js SDK,无需额外的适配层。

app.service.mjs ------ 服务层,复用客户端

javascript 复制代码
// 引入 OpenAI 官方 SDK,兼容任何 OpenAI 格式的 API
import Openai from 'openai';
// dotenv 用于从 .env 文件加载环境变量,避免 API Key 硬编码在源码中
import dotenv from 'dotenv';
dotenv.config();  // 执行后 process.env 中即可读取 .env 文件里定义的变量

// export 导出客户端实例,其他模块 import 即可复用,无需重复 new
export const client = new Openai({
    // 从环境变量读取 Key,安全且便于切换不同环境(开发/生产)
    apiKey: process.env.DASHSCOPE_API_KEY,
    // 关键:将 baseURL 指向阿里云 DashScope 的兼容端点
    // "/compatible-mode/v1" 表示以 OpenAI v1 协议兼容模式通信
    baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
});

逐行解析:

代码 做了什么 为什么这样写
import Openai from 'openai' 引入 OpenAI SDK 阿里云 DashScope 兼容 OpenAI 接口格式,所以可以直接用 OpenAI 的 SDK 调用,无需单独安装阿里云的 SDK
import dotenv from 'dotenv' 引入环境变量管理库 API Key 属于敏感信息,绝不能硬编码在代码里提交到 Git。.env 文件通常被 .gitignore 忽略,保证安全
dotenv.config() 加载 .env 文件 .env 中的 DASHSCOPE_API_KEY=xxx 注入到 process.env,后续通过 process.env.DASHSCOPE_API_KEY 读取
export const client = new Openai({...}) 创建并导出客户端实例 export 使得这是一个 ES Module 的命名导出,其他文件 import {client} from './app.service.mjs' 即可拿到同一个实例,避免重复创建连接
apiKey: process.env.DASHSCOPE_API_KEY 从环境变量读取 Key 安全性:Key 不在代码中;灵活性:不同环境可以配置不同的 Key
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1' 重定向 API 端点 这是整个项目的"开关"------OpenAI SDK 默认请求 api.openai.com,改这一行就能切换到阿里云,无需改任何业务代码

设计要点 :将客户端实例化单独抽成一个模块。这在大项目中称为"服务层"------所有需要调用 LLM 的地方都 import {client} from './app.service.mjs',统一管理 API Key 和 baseURL,避免到处重复配置。以后想切换到 DeepSeek、豆包等任何兼容 OpenAI 协议的服务商,只改这一个文件即可

index.mjs ------ 快速验证连通性

javascript 复制代码
// 从服务层引入已配置好的客户端(不需要再次 new Openai)
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'
});

// 调用 embeddings.create 方法向 DashScope 发起 HTTP 请求
const response = await client.embeddings.create({
    model: "text-embedding-v4",  // 指定 Embedding 模型,v4 是 DashScope 最新版本
    input: "aaa"                  // 待向量化的文本,这里是随便写的测试字符串
});
// response.data 是一个数组,每个元素对应一个 input
// response.data[0].embedding 就是 "aaa" 对应的向量(浮点数数组)
console.log(response.data[0].embedding);
// 输出示例: [0.023, -0.045, 0.112, -0.008, ...] ------ 通常有 1024 或 1536 维

逐行解析:

代码 做了什么 为什么这样写
import Openai from 'openai' 引入 SDK ---
dotenv.config() 加载 .env process.env.DASHSCOPE_API_KEY 有值
new Openai({...}) 创建客户端实例 index.mjs 是独立验证脚本,所以自己 new 了一个客户端。正式项目中其他文件用 app.service.mjs 导出的 client,避免重复配置
model: "text-embedding-v4" 指定模型 DashScope 的 Embedding v4 模型,支持中英文,1024 维向量。v4 相比 v3 在检索精度上有明显提升
input: "aaa" 待向量化的文本 单个字符串;也可以传字符串数组 ["aaa", "bbb"] 批量向量化,但本 demo 逐条调用便于控制频率
response.data[0].embedding 取出向量结果 data 是数组,长度与 input 数量一一对应。embeddingnumber[],即高维浮点数向量
console.log(...) 打印验证 如果能输出一长串 [0.023, -0.045, ...],说明网络通了、Key 对了、模型可用

运行一下,如果能打印出一长串浮点数,说明服务通了。


四、第二步:批量向量化文章

create-embedding.mjs 完成了数据预处理的核心工作。它做的事情很清晰:

javascript 复制代码
读取 JSON 文件 → 逐条调用 Embedding API → 写入新文件
javascript 复制代码
// fs/promises:Node.js 内置模块的 Promise 版本
// 所有文件操作返回 Promise,支持 async/await,告别回调地狱
import fs from 'fs/promises';
// 从服务层引入共享的客户端实例
import { client } from './app.service.mjs';

// 定义输入输出路径为常量,方便修改
const inputFilePath = './data/posts.json';
const outputFilePath = './data/posts-embedding.json';

// readFile 读取文件原始内容,'utf-8' 指定编码得到字符串
const data = await fs.readFile(inputFilePath, 'utf-8');
// JSON.parse 将 JSON 字符串反序列化为 JavaScript 对象数组
const posts = JSON.parse(data);

// sleep 工具函数:返回一个在 ms 毫秒后 resolve 的 Promise
// 配合 await 使用,暂停执行一段时间
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// 存储向量化结果的数组
const postsWithEmbedding = [];
// for...of 遍历数组,解构取出每篇文章的 title 和 category
for (const { title, category } of posts) {
    // 调用 Embedding API,将标题和分类拼接后一起向量化
    const response = await client.embeddings.create({
        model: "text-embedding-v4",
        // 拼接标题+分类形成更丰富的语义输入
        input: `标题:${title},分类:${category}`
    });
    // 将原始数据 + 向量合并存入新数组
    postsWithEmbedding.push({
        title,                                      // 保留原标题
        category,                                   // 保留原分类
        embedding: response.data[0].embedding       // 新增:向量字段
    });
    // 每次请求后暂停 200ms,避免触发 API 频率限制
    await sleep(200);
}

// writeFile 将结果写入新 JSON 文件
// JSON.stringify 的第三个参数 2 表示缩进 2 个空格,方便人类阅读
await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));

逐行解析:

代码 做了什么 为什么这样写
import fs from 'fs/promises' 引入 Promise 版 fs 旧式 fs.readFile(path, callback) 需要回调嵌套。fs/promises 返回 Promise,配合顶层 await 代码像同步一样直观
import { client } from './app.service.mjs' 引入共享客户端 不是 new Openai({...}),而是复用 app.service.mjs 中已经配置好的实例,体现了模块化思想
const data = await fs.readFile(inputFilePath, 'utf-8') 读文件 'utf-8' 参数让 Node.js 直接返回字符串而非 Buffer。在 ES Module 中可以使用顶层 await,代码直接写在模块最外层
const posts = JSON.parse(data) 反序列化 JSON data 是字符串,JSON.parseposts 变成对象数组,每个对象有 titlecategory 属性
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) 延时工具 setTimeout 本身是回调风格,包一层 Promise 使其可以被 await。这是 JS 中常见的"promisify"模式
for (const { title, category } of posts) 遍历 + 解构 { title, category } 是解构赋值,直接从每个 post 对象中提取需要的字段,代码更简洁
input: \标题: title,分类: {title},分类: title,分类:{category}`` 拼接语义输入 模板字符串将两个字段组合成一句完整的中文描述。这比只传 title 提供了更多上下文,向量质量更高
response.data[0].embedding 提取向量 embeddings.create 的返回结果中,data 数组与 input 数组一一对应。这里 input 只有一个字符串,所以取 [0]
postsWithEmbedding.push({...}) 组装结果 保留原始 titlecategory(后续展示搜索结果需要),新增 embedding 字段(后续相似度计算需要)
await sleep(200) 限频等待 DashScope 等云 API 有 QPS(每秒请求数)限制。不加等待连续发请求可能被暂时封禁。200ms 约等于 5 QPS,是比较安全的频率
JSON.stringify(postsWithEmbedding, null, 2) 序列化 JSON 第三个参数 2 是缩进空格数,生成的文件格式化良好。如果传 null 或不传则压缩成一行,不利于查看和调试

数据变化示意

向量化前后数据对比------之前posts.json):

json 复制代码
{ "title": "如何在 Vue.js 中使用 Vuex 进行状态管理", "category": "前端开发" }

之后posts-embedding.json):

json 复制代码
{
  "title": "如何在 Vue.js 中使用 Vuex 进行状态管理",
  "category": "前端开发",
  "embedding": [0.023, -0.045, 0.112, -0.008, ...]
}

每条数据多了一个 1024 维的浮点数数组------这就是"语义指纹"。


五、第三步:语义搜索

semantic-search.mjs 是整个项目的灵魂。它实现了一个命令行交互式的语义搜索引擎:

javascript 复制代码
// fs/promises:读取已向量化的数据文件
import fs from 'fs/promises';
// 复用服务层的 OpenAI 客户端
import { client } from './app.service.mjs';
// readline:Node.js 内置模块,用于在终端中读取用户输入
import readline from 'readline';

// ===== 第一步:加载已向量化的文章数据 =====
// 顶层 await 直接读取文件并解析 JSON
const posts = JSON.parse(await fs.readFile('./data/posts-embedding.json', 'utf-8'));

// ===== 第二步:余弦相似度 ------ 衡量两个向量的"方向一致性" =====
const cosineSimilarity = (v1, v2) => {
    // reduce 遍历向量每一维,累加 product = v1[i] * v2[i]
    // acc 是累加器(初始值 0),curr 是当前元素值,i 是索引
    // 最终 dotProduct = Σ(v1[i] × v2[i]),即向量的点积(内积)
    const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);

    // 向量的模(长度):||v|| = √(Σ(v[i]²))
    const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
    const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));

    // 余弦相似度 = 点积 / (模长之积)
    // 值域 [-1, 1],1 表示方向完全一致(语义最接近)
    const similarity = dotProduct / (lengthV1 * lengthV2);

    return similarity;
};

// ===== 第三步:创建命令行交互界面 =====
// readline.createInterface 创建一个输入输出接口
const rl = readline.createInterface({
    input: process.stdin,   // 绑定进程的标准输入(键盘输入)
    output: process.stdout  // 绑定进程的标准输出(终端显示)
});

// handleInput 是每次用户输入后的处理函数
const handleInput = async (answer) => {
    // 3.1 将用户的搜索文本向量化
    // 解构赋值从深层嵌套中直接取出 embedding 向量
    const { embedding } = (await client.embeddings.create({
        model: 'text-embedding-v4',
        input: answer  // 直接把用户的输入作为待向量化的文本
    })).data[0];

    // 3.2 计算与每篇文章的相似度,排序取 Top 5
    const results = posts
        // map:给每篇文章附加 similarity 字段
        .map(item => ({
            ...item,                                          // 展开原有属性(title, category, embedding)
            similarity: cosineSimilarity(embedding, item.embedding)  // 计算该文章与查询的相似度
        }))
        // sort:按 similarity 从小到大排序
        // a.similarity - b.similarity 返回负数时 a 排在 b 前面
        .sort((a, b) => a.similarity - b.similarity)
        // reverse:反转数组,变成从大到小(相似度高的在前)
        .reverse()
        // slice:只取前 5 条
        .slice(0, 5)
        // map:格式化为带序号的易读字符串
        .map((item, index) => `${index + 1}. ${item.title} | ${item.category}`)
        // join:用换行符拼接成最终输出文本
        .join('\n');

    // 3.3 输出搜索结果
    console.log(`\n搜索结果:\n${results}`);

    // 3.4 递归调用:再次提问,形成"输入→搜索→输出→再输入"的循环
    rl.question("\n请输入你要搜索的内容:", handleInput);
};

// ===== 第四步:启动交互循环 =====
// 第一次提问,用户输入后将触发 handleInput
rl.question("\n请输入你要搜索的内容:", handleInput);

逐行解析:

代码 做了什么 为什么这样写
import readline from 'readline' 引入命令行交互模块 Node.js 内置模块,无需安装。提供 question(query, callback) 方法在终端问答
JSON.parse(await fs.readFile(...)) 加载向量数据 读取 create-embedding.mjs 的输出文件。所有文章已带 embedding 字段,直接可用
v1.reduce((acc, curr, i) => acc + curr * v2[i], 0) 计算向量点积 reduce 是 JS 数组的归并方法。这里遍历 1024 维向量,对每一维做乘法然后累加。0 是初始累加值
Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0)) 计算向量模长 先累加每一维的平方,再开方。curr * currcurr ** 2Math.pow(curr, 2) 更简洁
dotProduct / (lengthV1 * lengthV2) 计算余弦值 除法将点积归一化到 -1, 1 区间,消除了向量长度的影响,只比较方向
readline.createInterface({ input: process.stdin, output: process.stdout }) 创建 CLI 界面 process.stdinprocess.stdout 是 Node.js 进程的标准输入输出流
const { embedding } = (...).data0` 解构提取向量 直接从 response.data[0] 中解构出 embedding,省去了中间变量。注意这里用到了顶层 await 的嵌套解构
.map(item => ({ ...item, similarity: cosineSimilarity(...) })) 附加相似度 展开运算符 ...item 保留原有字段,新增 similarity 字段
.sort((a, b) => a.similarity - b.similarity) 升序排列 JS 的 sort 默认按字符串排序,必须传入比较函数。a - b 返回负数时 a 在前,实现数值升序
.reverse() 反转为降序 升序 + 反转 = 降序(也可以用 .sort((a,b) => b.similarity - a.similarity) 一步到位,两种写法等价)
.slice(0, 5) 取 Top 5 只保留相似度最高的 5 条结果
.map((item, index) => \${index + 1}. ...`)` 格式化序号 index 从 0 开始,加 1 人类习惯从 1 开始编号
.join('\n') 拼接多行文本 每个结果一行,用换行符连接
rl.question("...", handleInput) 递归交互 question 的第二个参数是回调函数,用户按回车后触发。handleInput 内部再次调用 rl.question,形成无限问答循环

核心算法:余弦相似度

这是整个搜索的数学核心。公式很简单:

cosine(A,B)= A⋅B∣∣A∣∣×∣∣B∣∣ \text{cosine}(A, B) = \frac{A \cdot B}{||A|| \times ||B||} cosine(A,B)=∣∣A∣∣×∣∣B∣∣A⋅B

  • 分子:两个向量的点积------对应维度相乘后求和,反映两个向量在方向上的"协同程度";
  • 分母:两个向量模长(欧几里得长度)的乘积------将结果归一化到 -1, 1,消除向量绝对长度的影响;
  • 结果范围-1, 1,越接近 1 表示方向越一致,语义越相近;0 表示正交(无关);-1 表示完全相反。

代码实现对应关系:

javascript 复制代码
// A · B ------ 点积
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);

// ||A|| ------ v1 的模长
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));

// ||B|| ------ v2 的模长
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));

// cos(A, B) = (A · B) / (||A|| × ||B||)
return dotProduct / (lengthV1 * lengthV2);

为什么用余弦相似度而不是欧几里得距离? 因为 Embedding 向量的方向绝对长度更能反映语义。两段语义相近的文本,它们的向量指向大致相同方向,但可能因为文本长度不同导致向量长度差异较大。余弦相似度只看方向不看长度,更适合文本语义比对。

搜索流程

readline 模块实现了命令行对话式搜索:

  1. 用户输入查询 → 2. 调用 Embedding API 向量化 → 3. 与所有文章逐条计算余弦相似度 → 4. 排序取 Top 5 → 5. 展示结果 → 6. 等待下一次输入

用户输入"前端状态管理",结果可能是:

markdown 复制代码
1. 如何在 Vue.js 中使用 Vuex 进行状态管理 | 前端开发
2. 如何使用 React Hooks 构建可复用的组件 | 前端开发
3. 如何在 React 中实现无限滚动 | 前端开发
4. 如何使用 React Router 实现客户端路由 | 前端开发
5. 如何使用 TypeScript 编写高质量的 JavaScript 代码 | 前端开发

注意:没有一篇文章的标题精确包含"状态管理"之外的"前端"二字组合------但语义搜索依然找到了最相关的内容。"前端状态管理"和"Vuex 状态管理"在向量空间中距离很近,因为 Embedding 模型理解它们说的是同一件事。


六、RAG 的完整图景

这个 demo 实现了 RAG 的 R(Retrieval) 部分。完整的 RAG 流程是:

css 复制代码
用户提问 → Embedding 向量化 → 向量相似度检索 → 取 Top-K 相关文档
→ 拼入 Prompt → LLM 基于上下文生成回答

也就是说,你还可以在 semantic-search.mjs 的基础上,把搜出来的 5 篇文章喂给 GPT,让它基于这些文章回答用户问题。比如:

javascript 复制代码
// 将 Top 5 结果拼接成上下文文本
const context = results.map(r => r.title).join('\n');
// 调用 LLM 的 chat.completions 接口,基于检索到的文章生成回答
const answer = await client.chat.completions.create({
    model: 'qwen-plus',  // 阿里云的通义千问模型,用于生成回答
    messages: [
        // system 消息设定 AI 的角色和行为约束
        { role: 'system', content: `基于以下文章回答问题:\n${context}` },
        // user 消息传入用户的原始问题
        { role: 'user', content: answer }
    ]
});

这段代码的 RAG 逻辑:

  • context:从向量搜索中检索到的相关文章标题集合------这是 RAG 的 Retrieval 部分;
  • system 消息:将检索结果作为"背景知识"注入给 LLM,告诉它"你只能基于这些文章回答"------这叫 Augmented
  • user 消息:用户的实际问题------LLM 会综合上下文和问题,生成(Generation) 最终答案。

这就是 ChatGPT 的"知识库问答"、企业文档助手、AI 客服等产品的底层原理。


七、总结

文件 职责 关键点
app.service.mjs 服务层 统一管理 LLM 客户端,模块复用
create-embedding.mjs 数据预处理 批量向量化 + API 限流控制
semantic-search.mjs 语义搜索 余弦相似度 + 命令行交互
index.mjs 快速验证 确保 API 连通

这个不到 100 行代码的项目,完整演示了 RAG 的核心链路:文本 → 向量 → 相似度 → 检索。它没有引入任何向量数据库(如 Pinecone、Milvus),所有数据都存在本地 JSON 文件中------对于小规模 demo 或原型验证来说,这完全够用。

当数据量增长到成千上万条时,你可以把 cosineSimilarity 的暴力遍历替换成向量数据库的 ANN(近似最近邻)索引,但核心思想完全不变。

语义搜索不是魔法,它只是把"理解语义"这件事外包给了 Embedding 模型。 而这,正是 AI 时代应用开发的新范式。


本文基于 cjh_ai/ai/rag/posts-demo 项目源码撰写。

相关推荐
独孤留白1 小时前
从C到Rust:告别 C 的"指针 + 长度"手动模式
前端·rust
掘金安东尼2 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试
天平11 小时前
油猴脚本创建webworker踩坑记录
前端·javascript·typescript
原则猫12 小时前
前端基础大厦
前端
陈随易13 小时前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·后端·程序员
SoaringHeart14 小时前
Flutter进阶:基于 EasyRefresh 的下拉刷新封装 n_easy_refresh_mixin.dart
前端·flutter
IT_陈寒16 小时前
Vite的热更新突然不香了,排查三小时差点砸键盘
前端·人工智能·后端
子兮曰16 小时前
Agency-Agents 深度解析:400+ AI 专家的"梦之队"如何重塑开发工作流
前端·后端·vibecoding