从零实现 RAG 语义搜索——让 AI 听懂你的"弦外之音"

1. 开篇:传统搜索的痛点

你有没有遇到过这种情况?想在项目里找一些 Vue 相关的代码,搜"Vue"结果出来一堆"review"、"value"、"virtual"------因为这些词里都包含"vue"这个子串。或者想找"性能优化"的文章,搜"性能"出来的结果却全是"性能测试"、"性能指标",完全不是你想要的。

这就是传统搜索的痛点:它只能匹配文字,听不懂语义。

就像你跟一个只会查字典的机器人说话,你说"我想吃水果",它给你找出所有包含"水果"的条目------但它不知道你其实想要的是苹果、香蕉、橙子。

有没有一种办法,让搜索能理解你话里的"弦外之音"?

答案是:RAG(Retrieval-Augmented Generation)语义搜索。


2. RAG 是什么?------ AI 的外挂大脑

在讲 RAG 之前,先讲一个小故事。

你玩游戏的时候,有没有用过"外挂"?比如透视挂能看到墙后面的敌人,加速挂能跑得比别人快。RAG 就像是给 AI 装了一个"记忆外挂"------让它能访问外部知识库,而不是只靠自己的"内置知识"。

RAG 的核心思想

RAG 是一种 AI 架构模式,它由三个步骤组成:

  1. Retrieval(检索):从知识库中找到与用户问题最相关的内容
  2. Augmentation(增强):把检索到的内容作为上下文,喂给 AI
  3. Generation(生成):AI 基于增强后的上下文,生成更准确的回答

RAG 架构概览

scss 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        RAG 系统架构                                 │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌──────────────┐        ┌──────────────┐        ┌──────────────┐  │
│  │   原始数据    │        │   向量数据库  │        │    LLM       │  │
│  │  (文章/文档)  │ ──→   │  (Embedding) │        │  (生成回答)   │  │
│  └──────────────┘        └──────────────┘        └──────────────┘  │
│        │                        │                       │          │
│        │ 离线向量化              │                       │          │
│        ▼                        │                       │          │
│  ┌──────────────┐               │                       │          │
│  │ Embedding API│ ──→ 存入向量库                        │          │
│  └──────────────┘               │                       │          │
│                                 │                       │          │
│                                 │ 在线检索               │          │
│                                 │                       │          │
│  用户提问 ──→ 生成查询向量 ──→ 相似度计算 ──→ 检索结果 ──→ 增强上下文│
│                                 │                       │          │
└─────────────────────────────────────────────────────────────────────┘

这个架构的精妙之处在于 解耦

  • 数据准备和查询分离:离线批量向量化,在线实时查询
  • 向量数据库作为中间层:存储和检索向量,支持高效相似度计算
  • LLM 只负责生成:不需要记住所有知识,只需要根据上下文生成回答

本项目的定位

我们今天要拆解的 posts-demo,是一个极简的 RAG 实现------没有使用专门的向量数据库,而是用 JSON 文件存储向量,用 JavaScript 实现余弦相似度计算。它虽小,但完整展示了 RAG 的核心流程。


3. 项目骨架:package.json 和配置文件

一个项目就像一辆汽车,package.json 是汽车的配置表,.env 是油箱里的油。

package.json ------ 项目的配置表

json 复制代码
{
  "name": "posts-demo",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "dotenv": "^17.4.2",
    "openai": "^6.45.0"
  }
}

这里有两个关键依赖:

  1. openai ------ OpenAI 的官方 SDK,用来调用 Embedding API 生成向量
  2. dotenv ------ 用来加载环境变量,把 API Key 从代码中分离出来

注意 "type": "module" 这一行,它告诉 Node.js 使用 ESM 模块系统,所以我们能使用 import 语法。

.env ------ 藏在暗处的秘密

虽然项目里没有 .env 文件(因为它包含敏感信息,不会提交到版本控制),但我们知道它的结构:

ini 复制代码
DASHSCOPE_API_KEY=你的阿里云API密钥
DASHSCOPE_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

为什么用 DashScope?

这个项目使用阿里云的 DashScope API,但通过 OpenAI 兼容模式调用。这样做的好处是:

  • 可以使用 OpenAI 的 SDK,不需要学习新的 API
  • 切换到其他兼容 OpenAI 协议的服务(如智谱 AI)时,代码几乎不需要改动
  • 降低了学习成本,对新手友好

4. 核心组件一:LLM 客户端封装

在开始向量化之前,我们需要先封装一个 LLM 客户端。这个客户端就像是一个"翻译官",把文字翻译成向量。

javascript 复制代码
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();

export const client = new OpenAI({
  apiKey: process.env.DASHSCOPE_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
})

这 6 行代码做了三件事:

第一步:加载环境变量

javascript 复制代码
import dotenv from 'dotenv';
dotenv.config();

dotenv.config() 会读取 .env 文件,把里面的键值对注入到 process.env 中。这样我们就能通过 process.env.DASHSCOPE_API_KEY 访问 API 密钥。

为什么不直接写在代码里?

  • 安全:API Key 是敏感信息,写在代码里会泄露
  • 灵活:不同环境(开发/测试/生产)可以用不同的 Key
  • 合规:很多公司禁止在代码中硬编码密钥

第二步:创建 OpenAI 客户端

javascript 复制代码
export const client = new OpenAI({
  apiKey: process.env.DASHSCOPE_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
})

这里有两个关键参数:

参数 说明
apiKey API 密钥,从环境变量读取
baseURL API 地址,指向阿里云 DashScope 的兼容模式接口

为什么能用 OpenAI SDK 调用阿里云 API?

因为阿里云 DashScope 提供了 OpenAI 兼容模式,它的 API 接口和 OpenAI 完全一致。这意味着:

  • 请求格式一样
  • 响应格式一样
  • SDK 可以直接使用

这种设计非常聪明,它降低了用户的迁移成本------你可以先用 OpenAI 开发,然后无缝切换到阿里云。

第三步:导出客户端

javascript 复制代码
export const client = new OpenAI({...})

使用 export 关键字导出 client,这样其他文件就能通过 import { client } from './app.service.mjs' 来使用它。

小结 :这个文件虽然只有 6 行,但它体现了一个重要的设计原则------关注点分离。把客户端封装在一个单独的文件里,其他模块不需要关心 API 是怎么配置的,只需要知道怎么用。


5. 核心组件二:离线向量生成

现在我们有了客户端,接下来要做的是把文章数据转换成向量。这个过程就像是给每篇文章拍一张"语义快照"。

整体流程

scss 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                    离线向量生成流程                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  posts.json ──→ fs.readFile() ──→ JSON.parse() ──→ 文章数组         │
│                                 │                                   │
│                                 ▼                                   │
│                         for 循环遍历                                 │
│                                 │                                   │
│                                 ▼                                   │
│              ┌─────────────────────────────────┐                    │
│              │ client.embeddings.create()      │                    │
│              │ 生成 Embedding 向量             │                    │
│              └─────────────────────────────────┘                    │
│                                 │                                   │
│                                 ▼                                   │
│                          存入 postsWithEmbedding                    │
│                                 │                                   │
│                                 ▼                                   │
│                   fs.writeFile() ──→ posts-embedding.json           │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

代码拆解

javascript 复制代码
import fs from 'fs/promises';
import { client } from './app.service.mjs';

const inputFilePath = './data/posts.json';
const outputFilePath = './data/posts-embedding.json';

const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);

读取数据

javascript 复制代码
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);

这两行代码做了两件事:

  1. fs.readFile() 读取文件内容,返回字符串
  2. JSON.parse() 把字符串转换成 JavaScript 对象数组

posts.json 的结构很简单,包含 35 篇文章的标题和分类:

json 复制代码
[
  {"title": "Vue.js 入门教程", "category": "前端开发"},
  {"title": "React Hooks 详解", "category": "前端开发"},
  ...
]

定义 sleep 函数

javascript 复制代码
const sleep = (ms) => 
    new Promise(resolve => setTimeout(resolve, ms));

这是一个极简的 sleep 函数,用来控制 API 调用频率。为什么需要这个?

  • 防止限流:大多数 API 都有调用频率限制,如果短时间内发送太多请求,会被拒绝
  • 200ms 的间隔:这个项目用的是 200ms,相当于每秒 5 次请求,比较保守

生成向量

javascript 复制代码
const postsWithEmbedding = [];

for (const { title, category } of posts) {
  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);
}

这段代码是核心,它做了以下事情:

  1. 遍历每篇文章 :使用 for...of 循环遍历 posts 数组
  2. 调用 Embedding APIclient.embeddings.create() 生成向量
  3. 构造输入文本 :把标题和分类拼接成 "标题:xxx,分类:xxx" 的格式
  4. 保存向量 :把标题、分类和向量存入 postsWithEmbedding 数组
  5. 等待 200ms:避免 API 限流

为什么要拼接标题和分类?

如果只给标题生成向量,可能会丢失一些上下文信息。比如"性能优化"这个标题,如果不知道它属于"前端开发"还是"后端开发",向量的语义会不够准确。把标题和分类放在一起,可以让向量更精确地表达文章的主题。

保存结果

javascript 复制代码
await fs.writeFile(
  outputFilePath,
  JSON.stringify(postsWithEmbedding, null, 2)
)

JSON.stringify(postsWithEmbedding, null, 2) 把数组转换成格式化的 JSON 字符串,null 是 replacer 函数(不需要),2 是缩进空格数(让输出更易读)。

生成的 posts-embedding.json 是什么样的?

json 复制代码
[
  {
    "title": "Vue.js 入门教程",
    "category": "前端开发",
    "embedding": [0.0123, -0.0456, 0.0789, ...]
  },
  ...
]

每个 embedding 是一个 768 维的浮点数数组,代表文章的语义向量。


6. 核心组件三:余弦相似度算法

现在我们有了向量,接下来要解决的问题是:如何判断两个向量的相似度?

余弦相似度的原理

想象一下,你在一个高维空间里有两个向量。余弦相似度就是这两个向量之间夹角的余弦值:

  • 如果夹角是 0°(方向相同),余弦值是 1,相似度最高
  • 如果夹角是 90°(方向垂直),余弦值是 0,完全不相似
  • 如果夹角是 180°(方向相反),余弦值是 -1,完全相反

计算公式:

scss 复制代码
cos(θ) = (v1 · v2) / (||v1|| × ||v2||)

其中:

  • v1 · v2 是向量的点积
  • ||v1|| 是向量 v1 的长度(L2 范数)
  • ||v2|| 是向量 v2 的长度

代码实现

javascript 复制代码
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));
  
  const similarity = dotProduct / (lengthV1 * lengthV2);
  
  return similarity;
};

计算点积

javascript 复制代码
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);

reduce 函数遍历向量的每个元素,把 v1[i] * v2[i] 累加起来。这就是向量的点积。

计算向量长度

javascript 复制代码
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));

向量的长度是每个元素平方和的平方根,也就是 L2 范数。

计算相似度

javascript 复制代码
const similarity = dotProduct / (lengthV1 * lengthV2);

把点积除以两个向量长度的乘积,得到余弦相似度。

边界条件讨论

这段代码有一个潜在的问题:除零错误

如果 lengthV1lengthV2 是 0(也就是向量全是 0),那么 lengthV1 * lengthV2 也是 0,会导致除零错误。

修复方案

javascript 复制代码
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));
  
  if (lengthV1 === 0 || lengthV2 === 0) {
    return 0;
  }
  
  const similarity = dotProduct / (lengthV1 * lengthV2);
  
  return similarity;
};

在计算相似度之前,先检查向量长度是否为 0。如果是,直接返回 0(表示完全不相似)。


7. 核心组件四:语义搜索实现

现在我们有了向量和相似度算法,最后一步就是把它们组合起来,实现语义搜索。

整体流程

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                    语义搜索完整流程                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  用户输入 ──→ 生成查询向量 ──→ 计算与所有文章的相似度                 │
│                                   │                                 │
│                                   ▼                                 │
│                            排序(从高到低)                          │
│                                   │                                 │
│                                   ▼                                 │
│                            取前 3 个结果                            │
│                                   │                                 │
│                                   ▼                                 │
│                            格式化输出                               │
│                                   │                                 │
│                                   ▼                                 │
│                            等待下一次输入                            │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

代码拆解

javascript 复制代码
import fs from 'fs/promises';
import { client } from './app.service.mjs';
import readline from 'readline';

const inputFilePath = './data/posts-embedding.json';
const data = await fs.readFile(inputFilePath, 'utf-8');
const posts = JSON.parse(data);

加载向量数据

和向量化阶段一样,先读取 posts-embedding.json 文件,把向量数据加载到内存中。

创建命令行交互

javascript 复制代码
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

readline 是 Node.js 的内置模块,用来创建命令行交互界面。input: process.stdin 表示从标准输入读取(键盘),output: process.stdout 表示输出到标准输出(屏幕)。

处理用户输入

javascript 复制代码
const handleInput = async (answer) => {
  const response = await client.embeddings.create({
    model: 'text-embedding-v4',
    input: answer
  });
  const { embedding } = response.data[0];
  
  const results = posts.map(item => ({
      ...item,
      similarity: cosineSimilarity(embedding, item.embedding)
  })).sort((a, b) => a.similarity - b.similarity)
  .reverse()
  .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);
}

这段代码是核心,我们一步步拆解:

第一步:生成查询向量
javascript 复制代码
const response = await client.embeddings.create({
  model: 'text-embedding-v4',
  input: answer
});
const { embedding } = response.data[0];

把用户输入的文本(answer)转换成向量,和向量化阶段使用的是同一个 API。

第二步:计算相似度并排序
javascript 复制代码
const results = posts.map(item => ({
    ...item,
    similarity: cosineSimilarity(embedding, item.embedding)
})).sort((a, b) => a.similarity - b.similarity)
.reverse()
.slice(0, 3)

这段代码做了以下事情:

  1. map:遍历所有文章,计算每篇文章与查询向量的相似度,返回一个新数组
  2. sort :按相似度从小到大排序(a.similarity - b.similarity
  3. reverse:反转数组,变成从大到小排序
  4. slice(0, 3):取前 3 个结果

简化优化

其实 sort().reverse() 可以简化为直接按降序排序:

javascript 复制代码
.sort((a, b) => b.similarity - a.similarity)

这样更简洁,也避免了额外的反转操作。

第三步:格式化输出
javascript 复制代码
.map((item, index)=>`${index + 1}. ${item.title}. ${item.category}`)
.join('\n');

把结果数组转换成字符串格式,比如:

markdown 复制代码
1. Vue.js 入门教程. 前端开发
2. Vue3 Composition API 实战. 前端开发
3. React vs Vue 对比分析. 前端开发
第四步:输出结果并等待下一次输入
javascript 复制代码
console.log(`\n 搜索结果:\n${results}`)
rl.close();
rl.question("\n请输入你要搜索的内容:", handleInput);

输出搜索结果,然后等待用户输入下一个查询。

潜在问题

这里有一个 bug:rl.close() 关闭了 readline 接口,然后又调用 rl.question()。虽然在实践中可能能工作,但这不是正确的用法。正确的做法是不调用 rl.close(),让接口保持打开状态。

修复方案

javascript 复制代码
console.log(`\n 搜索结果:\n${results}`)
rl.question("\n请输入你要搜索的内容:", handleInput);

去掉 rl.close(),让用户可以连续输入多个查询。

启动交互

javascript 复制代码
rl.question("\n请输入你要搜索的内容:", handleInput);

显示提示信息,等待用户输入。


8. 进阶优化:从玩具到生产

这个项目是一个很好的入门示例,但离生产环境还有一段距离。以下是一些改进方向:

8.1 使用向量数据库

当前实现把所有向量加载到内存中,数据量大时会 OOM。生产环境应该使用专门的向量数据库:

向量数据库 特点
Pinecone 托管服务,开箱即用
Milvus 开源,可自部署
Chroma 轻量级,适合开发
Weaviate 支持 GraphQL 查询

8.2 添加索引

当前实现是线性搜索(O(n)),数据量大时性能很差。向量数据库通常支持:

  • HNSW(Hierarchical Navigable Small World):近似最近邻搜索,O(log n) 复杂度
  • IVF(Inverted File):倒排索引,适合大规模数据

8.3 添加缓存机制

对于频繁查询的内容,可以添加缓存,避免重复计算:

javascript 复制代码
const cache = new Map();
const CACHE_TTL = 60 * 1000; // 1分钟

const getSimilarPosts = async (query) => {
  const cached = cache.get(query);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.results;
  }
  
  // ... 计算逻辑 ...
  
  cache.set(query, { results, timestamp: Date.now() });
  return results;
};

8.4 添加错误处理

当前代码没有错误处理,如果 API 调用失败或文件读取失败,程序会崩溃。应该添加 try-catch:

javascript 复制代码
const handleInput = async (answer) => {
  try {
    // ... 搜索逻辑 ...
  } catch (err) {
    console.error('搜索失败:', err.message);
  } finally {
    rl.question("\n请输入你要搜索的内容:", handleInput);
  }
};

8.5 添加流式输出

对于长篇文章,可以添加流式输出,而不是一次性返回所有内容。


结尾:语义搜索的未来

回到开头那个"字典机器人"的故事。RAG 语义搜索就像是给这个机器人装上了一颗真正的大脑------它不再只会查字典,而是能理解你的意图,找到真正相关的内容。

今天我们用 60 行代码实现了一个简单的语义搜索系统,这只是 RAG 能力的冰山一角。想象一下:

  • 一个能搜索整个文档库的智能助手
  • 一个能理解你的问题并自动查找答案的客服系统
  • 一个能根据你的兴趣推荐内容的个性化推荐引擎

这些场景都离不开语义搜索技术。未来的搜索,不再是关键词匹配,而是语义理解。

最后,留给你一个问题:如果让你为这个项目添加一个新功能,你会选择什么?为什么?

相关推荐
Larcher1 小时前
从零搭建文件读取 MCP 服务——让 AI 拥有读文件的能力
人工智能
西瓜橙1 小时前
别再让 AI 一把梭:我用 Loop Engineering 把 AI编程 拽进可验证闭环
人工智能
艾莉丝努力练剑1 小时前
OpenCode AI 编程:Ubuntu 24.04 环境安装与使用指南
linux·服务器·网络·人工智能·tcp/ip·ubuntu
华山令狐虫2 小时前
DBAPI AI 写 SQL:支持动态 SQL 与参数占位符,自然语言一键生成
数据库·人工智能·sql·dbapi
一次旅行2 小时前
DeepSeek-V4 原厂直供模型即将登陆腾讯云!峰谷定价详解 + 实战调用指南
人工智能·腾讯云·ai编程
阿里云大数据AI技术2 小时前
DataWorks Data Agent的演进与工程化实践
人工智能·agent
HERR_QQ2 小时前
强化学习的数学原理 学习笔记
人工智能·笔记·学习·自动驾驶
IvorySQL2 小时前
PG 技术日报|2026-07-04
数据库·人工智能·postgresql·开源
程序员cxuan2 小时前
Claude Code 为了封禁中国用户,竟然在代码里下毒
人工智能·后端·程序员