从玩具项目到生产落地:一个奶爸程序员的Rust Embedding实战手记

马洋_架构师
2026-05-01 22:36
阅读 1354

凌晨一点半,老大刚睡,老二还在哼哼唧唧。我蹑手蹑脚摸回书房,打开那台用了三年的MacBook Pro,屏幕幽光照着黑眼圈——没错,就是那个白天在公司写业务逻辑、晚上回家带娃、深夜才偷得两小时学新技术的奶爸程序员。

我在当前这个团队干了快两年,主要负责后端服务和部分AI能力集成。最近半年,团队被产品经理“温柔”地推进了大模型时代:用户希望我们的搜索结果更智能,推荐更精准,还得支持语义理解。这背后绕不开一个词:Embedding

说实话,一开始我对Embedding的理解还停留在“把文本变成向量”的模糊认知。但真正动手之后才发现,这玩意儿远不止调个API那么简单。尤其是在资源有限、性能敏感、还要扛住线上流量的现实场景下,光有理论远远不够。今天这篇,就聊聊我是怎么从踩坑小白,一步步把Embedding能力稳稳落地生产的全过程。


起因:别再让用户搜不到东西了!

事情要从去年双11前说起。我们有个内部知识库系统,用户经常反馈:“明明文档里写了,怎么搜不到?”传统关键词匹配(比如Elasticsearch的BM25)在面对“退款流程”搜“怎么退钱”这种表达差异时,直接歇菜。

产品老大拍板:“上语义搜索!”技术方案很快聚焦到Embedding + 向量检索。思路很清晰:把所有文档用模型编码成向量,用户查询也转成向量,然后算相似度召回。

听起来简单?上线前一周,测试环境一压测,我就想砸键盘。

  • 单次Embedding生成耗时 800ms+
  • 内存占用飙升,Pod频繁OOM
  • 模型文件 1.2GB,部署成本高得离谱

运维同事看着监控图直摇头:“你这服务跑起来比数据库还吃资源。”测试妹子更是每天追着问:“能不能先降级?用户都投诉搜索变慢了。”

那一刻我意识到:技术探索不能只停留在Jupyter Notebook里跑通demo就算完事,必须考虑工程化、资源消耗和稳定性。


技术选型:为什么是Rust?

起初我们用Python+Sentence Transformers方案,开发快、生态好。但性能瓶颈太明显。于是开始寻找替代方案。

选项有三:

方案 开发速度 性能 部署复杂度 内存占用
Python + transformers ⭐⭐⭐⭐ ⭐⭐ 高(~1.2GB)
ONNX Runtime + C++ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Rust + sentence-transformers-rs ⭐⭐ ⭐⭐⭐⭐ 低(~400MB)

我选了Rust。原因有几个:

  1. 内存安全 & 零成本抽象:对我们这种资源受限的服务太友好了。
  2. 编译成二进制:部署只需一个可执行文件,Docker镜像小到离谱。
  3. 异步生态成熟tokio + axum 写HTTP服务丝滑如德芙。
  4. 个人兴趣驱动:被领导“建议”学点新东西,正好拿项目练手。

当然,代价是学习曲线陡峭。第一次写Arc<Mutex<>>的时候,我差点以为自己在解微分方程。但熬过头两周,真香!


实战:Embedding服务的优化三板斧

第一板斧:模型瘦身 —— 从DistilBERT到ONNX量化

我们最初用的是sentence-transformers/all-MiniLM-L6-v2,虽然比BERT小,但在高频调用下依然吃不消。

解决方案:ONNX导出 + INT8量化

# 在Python中先导出ONNX模型(仅一次)
from optimum.onnxruntime import ORTModelForFeatureExtraction
from transformers import AutoTokenizer

model_id = "sentence-transformers/all-MiniLM-L6-v2"
onnx_model = ORTModelForFeatureExtraction.from_pretrained(model_id, from_transformers=True)
tokenizer = AutoTokenizer.from_pretrained(model_id)

onnx_model.save_pretrained("./onnx-model")
tokenizer.save_pretrained("./onnx-model")

然后在Rust中加载:

use ort::{Environment, SessionBuilder};

let env = Environment::builder().with_name("embedding").build()?;
let session = SessionBuilder::new(&env)?
    .with_optimization_level(ort::GraphOptimizationLevel::Level3)?
    .with_intra_threads(2)? // 控制线程数,避免争抢
    .commit_from_file("onnx-model/model.onnx")?;

通过onnxruntime的INT8量化(使用optimum-cli工具),模型体积从98MB压到32MB,推理速度提升2.3倍,CPU占用下降40%。

小贴士:别盲目开多线程!我试过intra_threads=8,结果因为上下文切换开销反而更慢。实测2-4线程最适合我们的负载。

第二板斧:缓存复用 —— 用户查过的别再算

很多查询是重复的,比如“怎么重置密码”。于是加了两级缓存:

  1. 本地LRU缓存(基于lru crate):缓存最近1000个query的embedding
  2. Redis远程缓存:跨实例共享,TTL设为24小时
use lru::LruCache;
use std::sync::Mutex;

lazy_static! {
    static ref LOCAL_CACHE: Mutex<LruCache<String, Vec<f32>>> = 
        Mutex::new(LruCache::new(NonZeroUsize::new(1000).unwrap()));
}

效果立竿见影:缓存命中率约65%,QPS从300飙到900+,P99延迟从320ms降到85ms。

第三板斧:批处理 + 异步流水线

最开始是来一个请求算一个,IO等待浪费严重。后来改造成动态批处理

  • 收到请求先入队
  • 每10ms或队列满16条时,批量送入模型
  • 返回结果通过channel一一对应返还

核心逻辑用tokio::sync::mpsc实现:

// 简化版示意
let (tx, mut rx) = mpsc::channel::<BatchItem>(100);

tokio::spawn(async move {
    let mut batch = Vec::new();
    loop {
        tokio::select! {
            item = rx.recv() => {
                if let Some(i) = item { batch.push(i); }
            }
            _ = sleep(Duration::from_millis(10)) => {
                if !batch.is_empty() {
                    let embeddings = run_inference(batch.clone()).await;
                    // 发回各自的结果...
                    batch.clear();
                }
            }
        }
    }
});

这一招让GPU/ML加速单元利用率从30%提到80%,单位时间吞吐翻倍。


效果:上线后老板笑了,运维也不骂了

经过三周迭代(中间熬了两个通宵,老婆差点把我赶出卧室),新服务终于上线。

关键指标对比:

指标 旧方案(Python) 新方案(Rust+ONNX)
平均延迟 780ms 92ms
P99延迟 1200ms 180ms
内存占用/实例 1.1GB 380MB
CPU使用率 75% 35%
Docker镜像大小 1.8GB 120MB

最爽的是,现在一个4核8G的K8s Pod能扛住5000+ QPS,而之前需要3个实例才能勉强支撑。运维老哥甚至主动问我:“你们这个服务能开源不?”


开发心得:技术探索不能脱离现实约束

作为一个只能在娃睡后学习的奶爸程序员,我深刻体会到:炫技不如稳赢

  • 别迷信“最新”:Rust虽好,但如果团队没人会,维护成本可能更高。我们是在已有Python服务基础上做增量替换,逐步迁移。
  • Embedding不是万能药:对短query、专业术语多的场景,混合关键词+向量的hybrid search效果更好。我们现在用BM25召回+Embedding重排。
  • 监控必须跟上:给Embedding服务加了专属Metrics(缓存命中率、批处理大小分布、推理耗时分位数),否则线上出问题根本没法定位。
  • 文档即代码:我把整个部署流程、压测报告、回滚方案都写进了项目的DEPLOY.md,方便新人接手——毕竟谁也不想半夜被PagerDuty叫醒。

结语:在尿布与代码之间寻找平衡

写这篇文章时,老二又醒了。赶紧保存草稿,冲去换尿布。但心里挺踏实:技术落地了,用户满意了,团队信任度也涨了。

有人说程序员35岁就该转管理,但我觉得,只要还能写出优雅高效的代码,在一线解决问题,就值得骄傲。哪怕每天只有两小时属于自己的时间,也能做出有价值的东西。

如果你也在带娃、加班、学新技术的路上挣扎,记住:每一个深夜敲下的代码,都是给未来自己的一份礼物

共勉。

附:项目已脱敏开源,欢迎Star(不是广告,纯分享)
GitHub: github.com/dad-coder/rust-embedding-service
(名字是我临时起的,别笑)

评论 0

最热最新
暂无评论
马洋_架构师Lv.1
0
影响力
0
文章
0
粉丝