从Embedding到Rust:一个远程开发者的性能优化实战手记
上周五晚上十一点,我正瘫在沙发上刷LeetCode——毕竟跳槽季不刷题等于裸奔。突然手机叮咚一声,是前同事发来的消息:“你之前提的那个JS性能优化方案,线上跑稳了,QPS涨了40%!” 我咧嘴一笑,顺手把刚写完的Rust小工具推到GitHub上,心里琢磨着:这事儿得写篇博客,不然白熬这么多夜。
作为一个常年在家办公的独立开发者,我既享受自由安排时间的快感,也偶尔被孤独感击中。特别是深夜debug的时候,连个能喊“救命”的人都没有。不过好处是,我可以任性地折腾新技术,比如最近沉迷Rust,觉得它那套“零成本抽象”简直是对前端出身的我最大的诱惑。
起因:一个慢得离谱的智能搜索功能
事情要从去年底说起。当时接了个外包项目,客户要做一个知识库问答系统。核心逻辑很简单:用户输入问题,系统用Embedding模型把问题向量化,再和预存的知识片段做相似度匹配,最后返回最相关的几条答案。
听起来挺酷?实际一跑就翻车。
前端用的是React + TypeScript,后端是Node.js(别问为什么不用Go或Java,甲方说“你们不是擅长JS吗?”)。Embedding模型用的是开源的text-embedding-ada-002(通过OpenAI API),向量检索用的是FAISS(通过Python子进程调用)——典型的“缝合怪”架构。
上线第一周,客服电话被打爆:
“搜个‘怎么重置密码’要等8秒?”
“页面转圈转到我怀疑人生……”
我抓包一看,好家伙,一次搜索请求平均耗时 6.2秒,其中:
- JS前端组装请求:0.1s
- Node.js调OpenAI Embedding API:1.8s
- 启动Python子进程+加载FAISS索引:2.3s
- 相似度计算+返回结果:1.5s
- 网络延迟和其他杂项:0.5s
产品经理还在群里@我:“能不能优化到2秒内?不然下周迭代会挂掉。”
我当时真的想砸电脑——但转念一想,这不就是跳槽前练手的绝佳机会吗?
第一步:砍掉不必要的开销
1. Embedding缓存:别让同样的问题反复算
很多用户其实问的是高度重复的问题,比如“登录不了怎么办”、“发票怎么开”。于是我在Node.js层加了个Redis缓存:
// embedCache.js
const redis = require('redis');
const client = redis.createClient();
async function getOrComputeEmbedding(text) {
const cacheKey = `emb:${crypto.createHash('md5').update(text).digest('hex')}`;
const cached = await client.get(cacheKey);
if (cached) return JSON.parse(cached);
const embedding = await openai.embeddings.create({
model: "text-embedding-ada-002",
input: text,
});
// 缓存1小时
await client.setex(cacheKey, 3600, JSON.stringify(embedding.data[0].embedding));
return embedding.data[0].embedding;
}
这一招直接干掉了30%的重复Embedding请求。但Python子进程启动还是慢如老牛。
2. 告别Python子进程:用WASM跑FAISS?
我一度想把FAISS编译成WebAssembly,直接在Node.js里跑。查了一圈资料发现:FAISS依赖BLAS/LAPACK,WASM支持极差,社区方案基本停留在“理论上可行”。
放弃。
转机:Rust + WASM + JS 的奇妙组合
既然WASM这条路走不通,不如换个思路——用Rust重写整个向量检索服务,然后通过WASM桥接到前端?
等等,前端?对啊!如果能把轻量级的近似最近邻(ANN)算法直接放在浏览器里跑,不就彻底绕过后端瓶颈了吗?
于是我开始研究Rust生态里的向量库。hnsw、faiss-rs、usearch……最终选定了 usearch,理由很实在:
- 支持WASM编译
- 内存占用低
- API简洁
花了三天时间,我写了个Rust库,导出WASM模块:
// lib.rs
use usearch::Index;
#[wasm_bindgen]
pub struct VectorIndex {
inner: Index,
}
#[wasm_bindgen]
impl VectorIndex {
#[wasm_bindgen(constructor)]
pub fn new(dim: usize) -> Self {
let mut index = Index::new();
index.reserve(1000); // 预分配
index.connect(); // 初始化
Self { inner: index }
}
#[wasm_bindgen]
pub fn add(&mut self, id: u32, vector: &[f32]) {
self.inner.add(id, vector).unwrap();
}
#[wasm_bindgen]
pub fn search(&self, query: &[f32], k: usize) -> JsValue {
let results = self.inner.search(query, k).unwrap();
serde_wasm_bindgen::to_value(&results).unwrap()
}
}
然后用wasm-pack打包,生成JS绑定:
wasm-pack build --target web
最后在React组件里加载:
import init, { VectorIndex } from 'my-vector-wasm';
useEffect(() => {
const loadIndex = async () => {
await init(); // 加载WASM
const index = new VectorIndex(1536); // ada-002的维度
// 从CDN加载预构建的向量数据(JSON格式)
const vectors = await fetch('/vectors.json').then(r => r.json());
vectors.forEach(([id, vec]) => index.add(id, vec));
setLocalIndex(index);
};
loadIndex();
}, []);
效果炸裂:
- 首次加载WASM约800ms(可缓存)
- 后续搜索平均耗时 45ms
- 完全去中心化,不依赖后端!
当然,代价也有:知识库更新时得重新生成vectors.json并推CDN。但对于静态知识库来说,完全可接受。
性能对比:数据说话
我把三种方案做了压测(本地MacBook Pro M1,1000次搜索请求):
| 方案 | 平均延迟 | P95延迟 | 内存峰值 | 是否依赖后端 |
|---|---|---|---|---|
| 原始Node+Python | 6200ms | 8100ms | 420MB | 是 |
| Node+缓存+Python | 4100ms | 5800ms | 380MB | 是 |
| 前端WASM方案 | 45ms | 92ms | 85MB | 否 |
P95降到100ms以内,意味着95%的用户几乎无感等待。产品经理看完数据直接在群里发了个“膜拜”表情包。
踩过的坑与心得
WASM内存管理是魔鬼
Rust的Vec<f32>传给JS时,必须用serde_wasm_bindgen序列化,否则会内存泄漏。第一次测试时浏览器标签页崩了三次。Embedding维度别搞错
OpenAI的ada-002是1536维,但有些开源模型是768维。我一开始混用了,导致相似度全是0——debug到凌晨三点才发现。别在浏览器里跑大模型
虽然WASM很快,但如果知识库超过10万条,vectors.json可能上百MB。我们最终做了分片加载+懒加载,只加载高频问题向量。Rust的学习曲线是真的陡
为了搞定wasm-bindgen和生命周期,我看了整整两天文档。但一旦跑通,那种“掌控一切”的爽感,比喝十杯冰美式还提神。
结语:孤独开发者的价值,在于解决问题
远程办公的好处是,我可以自由选择技术栈,不用被“公司技术债”绑架。坏处是,遇到难题只能自己扛。但正是这种“无人可依”的状态,逼我深入底层,从Embedding原理看到WASM内存模型。
现在这个方案已经稳定运行两个月,客户准备把知识库规模扩大十倍。而我呢?简历上又多了一个“高性能前端向量检索系统”的项目,LeetCode刷到了300题,Rust也从“听说很牛”变成了“真香”。
下个月面试新公司时,我打算聊聊这段经历——毕竟,能把JS、Embedding和Rust串起来做性能优化的人,应该不多吧?
(完)
注:本文所有代码均为简化示例,生产环境需增加错误处理、类型校验、安全防护等。另外,别学我在周五晚上debug,容易猝死。

评论 0