RAG 实战:从关键词匹配到语义搜索,手把手教你用 Node.js 搭建 AI 检索引擎

RAG = Retrieval(检索) + Augmented(增强) + Generation(生成)

在大模型时代,RAG 是解决幻觉、接入私有知识的核心方案。而这一切的起点,都在于那个最关键的"R"------检索。今天我们就抛开复杂的框架,用最纯粹的 Node.js 代码,彻底搞懂语义搜索的底层原理。

🤔 为什么传统搜索在 AI 时代"失灵"了?

假设你的 data/posts.json 里有一万篇技术文章,用户问:"有哪些 Vue 相关的内容? "

传统搜索的困境

  • 正则 / LIKE '%vue%' :只能精确匹配字符。如果文章写的是"渐进式前端框架",哪怕内容全是 Vue,也搜不出来。
  • 文字匹配:"酸辣土豆丝的做法" vs "马铃薯怎么做?" 字面完全不同,但语义 100% 相同。传统搜索引擎对此束手无策。

语义搜索的破局之道

人类理解语言靠的是"意思",而不是"字形"。要让机器也能做到这一点,我们需要一个翻译官:Embedding 模型。它能把任何文本变成一串高维浮点数数组(向量),在这个数学空间里,"意思相近"就等于"距离相近"。


🛠️ 第一步:封装 LLM 服务客户端

在大型项目中,API Key 和客户端实例不应该散落在业务代码里。我们遵循工程化规范,将服务层独立抽离:

javascript

编辑

javascript 复制代码
1// app.service.mjs - 应用服务层,体现大型项目的风骨
2import OpenAI from 'openai';
3import dotenv from 'dotenv';
4dotenv.config();
5
6// 模块化输出 client,供所有业务模块复用
7export const client = new OpenAI({
8  apiKey: process.env.DASHSCOPE_API_KEY,
9  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
10});

💡 设计思想app.service.mjs 作为统一的服务出口,未来无论是更换模型提供商、添加重试机制还是接入缓存,只需修改这一个文件,业务代码零感知。

我们可以快速验证一下 Embedding 的效果:

javascript

编辑

javascript 复制代码
1const response = await client.embeddings.create({
2  model: "text-embedding-v4",
3  input: "红绳同学大三哦", // 任意自然语言
4});
5// 输出一串高维向量,这就是这句话在 AI 眼中的"数字指纹"
6console.log(response.data[0].embedding); 

🔄 第二步:构建离线向量知识库

语义搜索的前提是:知识库里的所有内容必须提前向量化。这是一个一次性的离线 ETL 过程。

核心流程

  1. 用 Node.js 内置 fs/promises 读取原始 JSON(ES2015+ 支持 Promise 化 I/O)
  2. 逐条调用 Embedding API,将标题+分类转为向量
  3. 写入新文件,实现向量的长期持久化存储

javascript

编辑

javascript 复制代码
1// vectorize.mjs - 离线向量化脚本
2import fs from 'fs/promises';
3import { client } from './app.service.mjs';
4
5const inputFilePath = './posts.json';
6const outputFilePath = './posts-embedding.json';
7
8// Node.js 新版支持顶层 await,直接从硬盘读取到内存
9const data = await fs.readFile(inputFilePath, 'utf-8');
10const posts = JSON.parse(data);
11
12// 简单的限流函数,避免触发 API Rate Limit
13const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
14
15const postsWithEmbedding = [];
16
17for (const { title, category } of posts) {
18  const response = await client.embeddings.create({
19    model: 'text-embedding-v4',
20    // 🔑 关键技巧:拼接结构化提示词,让语义表达更精准
21    input: `标题:${title},分类:${category}`
22  });
23
24  postsWithEmbedding.push({
25    title,
26    category,
27    embedding: response.data[0].embedding
28  });
29
30  // 保持代码可读性,同时保护 API 配额
31  await sleep(200);
32}
33
34console.log('✅ 向量化完成,共处理', postsWithEmbedding.length, '条数据');
35
36// 格式化写入,方便人工检查调试
37await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));

⚠️ 避坑指南:永远不要在生产环境对百万级数据做这种串行全量向量化!这里是为了教学演示原理。工业界会使用消息队列 + 批量 API + 向量数据库来完成这一步。


🔍 第三步:实现交互式语义搜索(核心)

现在有了向量知识库,我们来实现真正的 RAG 检索引擎。这段代码是整个实战的灵魂:

javascript

编辑

javascript 复制代码
1// search.mjs - 语义搜索主程序
2import fs from 'fs/promises';
3import { client } from './app.service.mjs';
4import readline from 'readline';
5
6// 1. 加载向量知识库到内存
7const data = await fs.readFile('./data/posts-embedding.json', 'utf-8');
8const posts = JSON.parse(data);
9
10// 2. 余弦相似度:衡量两个向量"意思有多像"的数学公式
11const cosineSimilarity = (v1, v2) => {
12  // 点积:衡量方向重合度
13  const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
14  // 模长:归一化,消除文本长度差异
15  const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
16  const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
17  
18  return dotProduct / (lengthV1 * lengthV2);
19};
20
21// 3. 创建命令行交互界面
22const rl = readline.createInterface({
23  input: process.stdin,
24  output: process.stdout
25});
26
27// 4. 处理用户查询的核心逻辑
28const handleInput = async (answer) => {
29  if (!answer.trim()) return rl.close();
30
31  // 将用户问题实时向量化
32  const response = await client.embeddings.create({
33    model: 'text-embedding-v4',
34    input: answer
35  });
36  const { embedding } = response.data[0];
37
38  // 🔥 语义检索核心:全量比对 → 排序 → 截取 Top-K
39  const results = posts
40    .map(item => ({
41      ...item,
42      similarity: cosineSimilarity(embedding, item.embedding)
43    }))
44    .sort((a, b) => b.similarity - a.similarity) // 从高到低排序
45    .slice(0, 3) // 只取最相关的 Top 3
46    .map((item, index) => `${index + 1}. [${item.category}] ${item.title} (相似度: ${item.similarity.toFixed(4)})`)
47    .join('\n');
48
49  console.log(`\n🎯 语义搜索结果:\n${results}\n`);
50  rl.close();
51};
52
53rl.question("\n🔍 请输入你要搜索的内容: ", handleInput);

🔬 深度拆解:用户按下回车后发生了什么?

表格

步骤 动作 本质
client.embeddings.create({ input: answer }) 把人类语言翻译成机器能懂的坐标点
posts.map(...cosineSimilarity...) 在高维空间中计算问题与每篇文章的几何距离
.sort().slice(0, 3) 捞出最近的 3 个邻居,过滤噪音
console.log(results) 将数学结果还原为人类可读的搜索结果

🎓 为什么学 RAG 必须手写一遍这段代码?

很多初学者直接上手 LangChain / LlamaIndex,却连余弦相似度都没写过。老师强调手写的原因在于:

  1. 祛魅 :AI 不懂中文,它只做数学运算。理解了 cosineSimilarity,你就理解了所有语义搜索产品的底层真相。
  2. 建立质量意识 :RAG 的回答质量 = 检索质量 × 生成质量。如果检索阶段找错了文章,后面接 GPT-5 也会一本正经地胡说八道。Garbage In, Garbage Out
  3. 暴露性能瓶颈 :当你亲手写出 posts.map() 的全量扫描后,你会立刻意识到:100 万条数据每次搜索都要算 100 万次点积,这根本不可用。这会自然而然地驱动你去学习 向量数据库(Milvus/Qdrant)ANN 近似最近邻索引,这才是工业级 RAG 的正确打开方式。

📌 总结

LIKE '%vue%'cosineSimilarity,我们跨越的不是一个 API,而是一种全新的信息检索范式。

这篇实战代码虽然朴素,但它完整覆盖了 RAG 中 Retrieval 的全链路:服务封装 → 离线向量化 → 实时语义检索。当你真正跑通这个 Demo,再去看那些复杂的 RAG 框架时,你会发现它们不过是在这套基础逻辑上叠加了分块、重排序、混合检索等优化手段而已。

下一步进阶建议 :尝试将 posts.map 替换为 Qdrant/Milvus 的向量检索 API,体验从 O(N) 暴力扫描到 O(logN) 索引检索的性能飞跃。那才是 RAG 走向生产环境的真正起点。

相关推荐
wear工程师1 小时前
Redis 分布式锁到底靠不靠谱:从 SETNX 到 Redlock,我踩过的坑和业内的争议
redis·面试
飞天狗1 小时前
TypeScript类型系统其实是个图灵完备的语言
面试·typescript
掘金安东尼2 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试
用户8524950718421 小时前
解密 JavaScript 中的 this:谁才是真正的调用者?
javascript·面试
Heo21 小时前
Vite进阶用法详解
前端·javascript·面试
洛卡卡了21 小时前
Claude Code rules 要怎么用,团队协作时如何统一代码规范呢?
面试·agent·claude
不好听6131 天前
JavaScript 的 this 到底指向谁?
javascript·面试
烬羽1 天前
面试官:聊聊 LocalStorage 和 this 指向?看这篇就够了
面试·程序员