手搓自然语义搜索:从传统匹配到向量化,理解 RAG 的第一步
先看效果
请输入你要搜索的内容: react相关
搜索结果:
1. 如何在 React 中实现无限滚动 前端开发
2. 如何使用 React Hooks 构建可复用的组件 前端开发
3. 如何在 React 中使用 Tailwind CSS 实现 Material Design 风格 前端开发
没有一行 LIKE '%react%',它是 1536 维向量 + 余弦相似度算出来的。
RAG 是什么
RAG = Retrieval + Augment + Generation
Retrieval(检索)→ 从知识库中搜索相关内容
Augment(增强) → 将检索结果注入 Prompt
Generate(生成) → LLM 基于增强的上下文生成答案
本文只讲第一步:Retrieval,自然语义搜索。
传统搜索为什么不行
SELECT * FROM posts WHERE title LIKE '%vue%'
这是字面匹配。标题里没有 vue 三个字就搜不到。
笔记里这个例子一语道破本质:
搜 "酸辣土豆丝的做法" 搜 "马铃薯怎么做"
两句话一个字都不重叠,人类秒懂,传统搜索永远不懂。
语义搜索的思路:把文字变成数学向量,比较方向是否接近。
"React" → [0.03, 0.05, -0.01, ...] ← 1536维向量
"渐进式框架" → [0.02, 0.06, -0.02, ...] ← 也是1536维
两者方向接近 → 余弦相似度 0.87 → 语义高度相关
项目结构
三个文件,各司其职:
posts-demo/
├── app.service.mjs # 共享客户端
├── create-embedding.mjs # 离线向量化
├── semantic-search.mjs # 在线语义搜索
└── data/
├── posts.json # 原始文章(35 条)
└── posts-embedding.json # 持久化向量数据
第一块:共享客户端 app.service.mjs
完整代码:
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",
});
逐行拆解:
-
import OpenAI from "openai"— 行业标准 SDK,阿里云、DeepSeek 都兼容这套协议。换服务商只改baseURL,SDK 不用动。 -
dotenv.config()— 从.env文件把 API Key 读到process.env。密钥永不在代码里硬编码。 -
export const client— 关键设计。导出这个客户端实例,create-embedding.mjs和semantic-search.mjs都直接import { client }复用。项目大了,公共模块越少,维护成本越低。 -
baseURL— 阿里云 DashScope 的 OpenAI 兼容接口地址。
第二块:预处理引擎 create-embedding.mjs
完整代码:
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);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const postsWithEmbedding = [];
for (const { title, category } of posts) {
console.log(title, category);
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);
}
await fs.writeFile(
outputFilePath,
JSON.stringify(postsWithEmbedding, null, 2)
);
console.log("成功写入文件");
逐行拆解
导入:
import fs from "fs/promises";
import { client } from "./app.service.mjs";
fs/promises 是 Node.js 内置的 Promise 版文件系统。不用回调写法,直接 await fs.readFile(...),异步代码跟同步一样清晰。
{ client } 从公共模块导入,不需要在这里重新 new OpenAI(...)。
路径定义:
const inputFilePath = "./data/posts.json";
const outputFilePath = "./data/posts-embedding.json";
输入是原始文章,输出是"文章 + 向量"的合并数据。起名 posts-embedding 一眼就知道存的是向量。
读取原始数据:
const data = await fs.readFile(inputFilePath, "utf-8");
const posts = JSON.parse(data);
关键认知:fs.readFile 永远返回字符串。即使文件内容是 JSON,读到内存也是 string 类型。
硬盘上的 JSON → readFile → string → JSON.parse → Array
JSON.parse(data) 之后,posts 才变回真正的 JavaScript 数组,才能 for...of 遍历。
限流工具:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
setTimeout 本身不支持 await。用 new Promise 包一层,就能 await sleep(200) 暂停执行。35 篇 × 200ms = 7 秒,远比被封 IP 省时间。
核心循环:逐条向量化
const postsWithEmbedding = [];
for (const { title, category } of posts) {
postsWithEmbedding空数组,等会儿逐条push进去。{ title, category }解构赋值,只取出需要的两个字段。- 为什么用
for...of而不是.map()?API 调用是异步的,用.map()会 35 个请求同时发出,瞬间触发限流。for...of+await保证串行执行。
const response = await client.embeddings.create({
model: "text-embedding-v4",
input: `标题:${title},分类:${category}`,
});
这里的技巧:input 不直接传标题,而是拼接成 "标题:如何使用 React Hooks 构建可复用的组件,分类:前端开发"。Embedding 模型理解的是自然语言段落,给它的上下文越完整,生成的向量越准确。
模型名必须记住:这里选 text-embedding-v4,搜索那边也必须用同一个。模型不同,向量维度可能不一致,余弦相似度会算出一堆乱码。
postsWithEmbedding.push({
title,
category,
embedding: response.data[0].embedding,
});
await sleep(200);
}
response.data[0].embedding就是 1536 个浮点数,代表这篇文章在语义空间中的"坐标"。sleep(200)每次循环末尾暂停,保护 API。
持久化写入:
await fs.writeFile(
outputFilePath,
JSON.stringify(postsWithEmbedding, null, 2)
);
console.log("成功写入文件");
JSON.stringify(data, null, 2)— 第三个参数2是缩进空格数,让输出的 JSON 可读,方便排查问题。- 为什么必须写文件?35 篇 × 1536 维 = 5.4 万个数,API 调用还有成本。存盘后搜索直接读,零延迟。
第三块:实时搜索引擎 semantic-search.mjs
完整代码:
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);
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;
};
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.on("line", 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(item.embedding, embedding),
}))
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
.map((item, i) => `${i + 1}. ${item.title} ${item.category}`)
.join("\n");
console.log(`\n搜索结果:\n${results}`);
console.log("\n请输入你要搜索的内容:");
});
逐行拆解
导入和加载数据:
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);
readline— Node.js 内置的逐行读取模块,专门做命令行交互。- 这里读的不是原始
posts.json,而是posts-embedding.json,因为每条数据已经自带embedding向量。搜索时不需要再调 API 向量化文章。
余弦相似度 — 搜索引擎的心脏:
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;
};
这个函数本质就是高中数学公式:
cos(θ) = (A · B) / (|A| × |B|)
第一行 — 点积:
v1.reduce((acc, curr, i) => acc + curr * v2[i], 0)
展开就是:
acc = 0
→ acc + v1[0] * v2[0]
→ acc + v1[1] * v2[1]
→ ...
→ acc + v1[1535] * v2[1535]
最终 = 两个向量对应位置相乘再全加起来
点积反映两个向量在方向上的"重合度"——对应值都是正的大数,点积就大;一个正一个负,互相抵消,点积就小。
第二、三行 — 向量长度:
v1.reduce((acc, curr) => acc + curr * curr, 0)
展开就是:
v1[0]² + v1[1]² + ... + v1[1535]²
然后 Math.sqrt 开根号
为什么需要长度?比的是方向,不是大小。两个向量可能长短悬殊但指向同一个方向。除以长度就是"归一化",把大小因素去掉。
第四行 — 最终相似度:
const similarity = dotProduct / (lengthV1 * lengthV2);
结果永远在 [-1, 1] 之间:
| cos θ | 含义 |
|---|---|
| 接近 1.0 | 方向一致 → 高度相似 |
| 接近 0 | 方向正交 → 无关 |
| 接近 -1 | 方向相反 → 语义相反 |
命令行交互:
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
process.stdin 是键盘输入流,process.stdout 是终端输出流。createInterface 把两者绑在一起,形成一问一答的交互模式。
搜索主循环拆解:
rl.on("line", async (answer) => {
rl.on("line") 不是 rl.question。区别:rl.question 只触发一次就结束,rl.on("line") 每次用户敲回车都触发,形成持续的问答循环。程序启动后可以一直搜到主动退出。
第一步:用户输入转向量
const response = await client.embeddings.create({
model: "text-embedding-v4",
input: answer,
});
const { embedding } = response.data[0];
这里模型必须是 text-embedding-v4,和 create-embedding.mjs 保持一致。{ embedding } 是解构赋值,等价于 const embedding = response.data[0].embedding。
第二步:计算相似度并排序
const results = posts
.map(item => ({
...item,
similarity: cosineSimilarity(item.embedding, embedding),
}))
.sort((a, b) => b.similarity - a.similarity)
.map() 遍历 35 篇文章,每篇和搜索向量算一个 similarity。...item 展开原始字段(title、category、embedding),再追加一个 similarity。跑完每个元素变成 { title, category, embedding, similarity }。
排序方向是经典踩坑点:
b.similarity - a.similarity → 降序,0.95 排前、0.3 排后 ✓
a.similarity - b.similarity → 升序,0.3 排前、0.95 排后 ✗
升序会把 PyTorch、Insomnia 这些最不相关的排在前面——坑过才记得牢。
第三步:截取 Top 3 并格式化
.slice(0, 3)
.map((item, i) => `${i + 1}. ${item.title} ${item.category}`)
.join("\n");
已经降序排好了,slice(0, 3) 截前三。.map() 把对象转成可读文字。.join("\n") 用换行拼接,终端里显示为多行。
第四步:展示并提示下一轮
console.log(`\n搜索结果:\n${results}`);
console.log("\n请输入你要搜索的内容:");
});
开头 \n 多一个空行,把本次答案和下次输入区隔开。最后一行提示继续输入。
执行顺序
第一步:node create-embedding.mjs → 生成 posts-embedding.json
第二步:node semantic-search.mjs → 进入交互式语义搜索
跑过 create-embedding.mjs 后,只要文章数据不变,不需要再跑。直接用 semantic-search.mjs 搜索就行。
架构设计回顾
| 决策 | 原因 |
|---|---|
app.service.mjs 单独抽离 | 客户端配一次,全局复用 |
| 预处理 + JSON 存盘 | Embedding 调用昂贵,避免每次搜索都调 |
readline 命令行 | 开发期零前端成本,跑起来就能验证 |
for...of + await sleep | 串行调用,保护 API 不被限流 |
排序 b - a | 降序,相似度高的排前面 |
| 每次搜索实时调 API | 用户输入千变万化,无法预计算 |
延伸:为什么大厂还要向量数据库
笔记里提到的 milvus、pgvector:
| 场景 | JSON 文件方案 | 向量数据库方案 |
|---|---|---|
| 35 篇 | O(n) 遍历,毫秒级够用 | 杀鸡用牛刀 |
| 1000 万篇 | O(n) 遍历,直接卡死 | HNSW/IVF 近似索引,毫秒级 |
| 实时增删改 | 全量重写整个文件 | 单条 INSERT / DELETE |
但核心原理不变:存向量 → 算余弦相似度 → 取 TopN。这套代码已经把 RAG 最核心的东西跑通了。
全文总结
"react相关"
│
▼
Embedding API
│
▼
[0.03, 0.05, -0.01, ...] ← 搜索向量(实时计算)
│
▼
与 posts-embedding.json 中 35 篇的向量逐一计算余弦相似度
│
▼
降序排序 → 切片 Top 3 → 输出
RAG 第一步 Retrieval 的本质:不是匹配字符,是匹配语义。掌握了 Embedding 向量化 + 余弦相似度,就拿到了理解 RAG 的钥匙。
每个模块现在都是「完整代码 → 逐行拆解」的结构,读者可以一次性看到全部代码,再跟着讲解逐行理解。需要调整告诉我!