做端侧向量检索这件事,说实话最初并不是为了赶时髦。我是给客户做商品图搜索功能时被逼出来的方案——图片数据敏感,不能出内网,服务器又不想扩容,预算卡得很死。后来调研了一圈,发现浏览器端的能力早就够用了:TensorFlow.js 能跑特征提取模型,Web Worker 能隔离计算压力,1024 维向量的相似度计算在纯前端也能扛住百万级数据。于是我就把整套链路从“上传图片-云端算-云端查”变成了“本地算-本地查”,云端只负责下发静态资源。这篇文章就把我落地这套方案的完整思路、实操步骤和踩过的坑都拆开讲。
先说这个方案适合谁。如果你手里有这些需求中的任意一个,端侧向量检索都值得认真看一眼:以图搜图的个人图库、本地商品库管理、敏感场景下的图片检索、低服务器预算但又想体验智能搜索的应用。只要用户用的是现代浏览器,数据量在百万条以内,响应时间能容忍一两百毫秒到半秒,这套思路就能直接套用。下面我会从整体设计、技术选型、核心实现、性能实测到问题排查,一条线讲清楚。
1. 整体设计拆解:为什么把向量检索全部搬到端侧
1.1 端侧方案要解决的三个核心痛点
我当时的客户场景很简单:产品目录里有五万多张商品图,需要支持“拍照找同款”。最初部署的是传统的云端方案——图片上传到服务器,Python 服务调模型提取特征向量,再丢进向量数据库做相似度检索。功能是能跑,但客户的 IT 部门提了三个要求,直接把这个方案否了。
第一是数据合规。商品图涉及未公开的新品设计图,客户明确要求图片不能离开内部网络。云端方案意味着每次检索都要把图片传到公网服务器,这在合规层面直接不成立。第二是成本控制。五万张图的全量特征提取,如果用 GPU 云主机跑,一次性成本加上持续的服务运维,一个月下来几千块是跑不掉的。第三是延迟体验。图片上传到云端再返回结果,跨地域网络下平均延迟至少三百到五百毫秒,而且高峰期并发一上来,服务器经常超时。
端侧方案正好同时解决这三个问题。浏览器本地跑模型,图片从头到尾不离开用户设备;算力用用户设备的 GPU/CPU,云端没有持续费用;特征提取加检索全在本地完成,没有网络请求,延迟反而更低。当然代价也有——把计算压力从服务器转移到用户设备上,对用户设备的性能有基本要求,这也是后面要重点处理的问题。
1.2 架构分层:主线程、Worker 与索引层各司其职
整套端侧架构我拆成了三层,分工很明确。
第一层是主线程,负责一切与用户交互相关的事情:文件选择、Canvas 绘图、拍照预览、结果展示。这一层的原则是“只传数据不搞计算”,任何 CPU 密集的操作都不允许出现在这里,否则页面卡顿用户马上能感知到。
第二层是 Web Worker 线程,这是真正的“计算中枢”。模型加载、图像预处理、特征向量提取、向量检索全在 Worker 内完成。主线程通过 postMessage 把图像数据丢给 Worker,Worker 算完把结果抛回来,中间不阻塞 UI。
第三层是索引层,我把它放在 Worker 内部维护。这一层管理所有已入库的特征向量,负责插入、删除、检索和周期性重建。索引的数据结构一开始我就没打算用暴力全扫,而是做了签名向量粗筛加精确计算的混合方案,后面会细说。
选择 Web Worker 而不是其他方案,理由也很直接:如果不把推理放到 Worker,TensorFlow.js 的 WebGL 后端跑一次 MobileNet 推理虽然只要二三十毫秒,但在主线程里这个时间足够造成可见的掉帧——尤其在用户连续拖入多张图片、批量提取特征的时候。放到 Worker 后,主线程的任务只剩消息收发,帧率稳定在 60fps 没有问题。
1.3 为什么是 1024 维:精度、内存和速度的三角权衡
特征维度不是拍脑袋定的。常见视觉模型输出的特征维度其实各不相同:MobileNet V2 的全局池化层输出是 1280 维,EfficientNet-Lite0 是 1280 维,ResNet50 是 2048 维。这些原始维度直接拿去建索引,性能开销偏大,而且实验下来我发现高维特征里存在明显冗余——很多维度对区分商品类别毫无贡献。
我的做法是在模型转换时额外接一个全连接层,把输出压到 1024 维。这个维度看起来是个不错的平衡点:单条向量的存储开销是 4KB(Float32),一百万条就是 4GB,有点吃紧;但如果我改用 Float16 存储,1024 维单条只要 2KB,一百万条才 2GB,这在浏览器端是可以接受的。再往下压到 512 维也不是不行,但实测检索 top5 准确率会掉 2% 到 3%,我的场景接受不了。
所以 1024 维是一个典型的精度-内存-速度三角权衡:维度太高,内存和计算时间爆炸;维度太低,特征区分度不够。压缩后的 1024 维特征在我这套商品检索场景下,召回率和原始特征基本持平,但内存开销小了一半,这个性价比是划算的。
2. 核心环节实现:模型转换、Worker 推理与索引检索
2.1 模型导出与转换:Keras 到 TensorFlow.js 的完整链路
我用的是 Keras 训练好的分类模型,导出的处理方式是去掉最后接的分类层,只留特征提取部分,然后接一个自定义的全连接降维层。这里有个必须提前说的坑:TensorFlow.js 转换器对 Keras 的自定义层支持是有限的,我曾经写过的一个 L2 正则层在转换时直接报错。解决方案是把自定义层重构成标准层组合,或者用 tf.function 包装后导出 SavedModel,再转成 TF.js 格式。
转换命令长这样:
tensorflowjs_converter \ --input_format=keras \ --output_format=tfjs_graph_model \ --quantization_bytes=1 \ --output_node_names=feature_vector \ ./model.h5 \ ./tfjs_modelquantization_bytes=1 意思是做 8bit 量化,模型体积直接缩到原来的四分之一。我原来那个 MobileNet V2 基础模型是 14MB,量化后 3.5MB,页面加载负担小了很多。代价是精度轻微下降,但我实测下来 top5 准确率只降了 0.8%,对商品检索场景完全够用。如果是人脸识别这种对精度极其敏感的场景,就要谨慎考虑量化,或者退回 Float16 量化试试。
转换完成后,产物包含一个 model.json 和一组二进制权重文件。注意整个目录要放在静态服务器上,运行时 TensorFlow.js 会按 manifest 记录去拉取分片权重。
2.2 Worker 中的模型加载与推理:代码骨架逐行解析
Worker 内部我维护了一个“模型未就绪时先缓存任务”的机制。因为 TF.js 模型加载是异步的,用户可能在模型还没 ready 时就快速拖入图片,如果直接进入推理流程,模型对象还是 null,会报错。我的处理是任务队列加自锁状态,代码如下:
// feature-worker.js let model = null; let ready = false; const taskQueue = []; async function initModel() { const modelUrl = await getCachedModelUrl(); model = await tf.loadGraphModel(modelUrl); // 预热模型:用一张全零图跑一次推理,避免首次实际预测时产生明显延迟 warmup(); ready = true; while (taskQueue.length) { const task = taskQueue.shift(); await handleTask(task); } } self.onmessage = async (e) => { const { id, data, taskType } = e.data; if (taskType === 'embed') { if (!ready) { taskQueue.push({ id, data, taskType }); return; } const result = await handleTask({ id, data, taskType }); self.postMessage(result); } }; async function handleTask({ id, data, taskType }) { const tensor = tf.browser.fromPixels(data, 4) .resizeBilinear([224, 224]) .expandDims(0); const normalized = tensor.div(127.5).sub(1); const pred = model.predict(normalized); const vec = pred.dataSync(); tensor.dispose(); pred.dispose(); return { id, vec }; }这段代码有几个细节值得单独说明。
细节一:tf.browser.fromPixels 的输入类型。这个方法接收 ImageData、HTMLCanvasElement 或 HTMLVideoElement,但不接收 Blob。也就是说主线程从<input type="file">拿到 File 对象后,不能直接传给 Worker 调用 fromPixels,必须先解码成 Bitmap 或 Canvas 再传。我在主线程用 createImageBitmap(file) 拿到 ImageBitmap,通过 transferable 对象传给 Worker,这样可以避免结构化克隆的性能损耗。
细节二:归一化的写法。tensor.div(127.5).sub(1) 是把像素值从 [0, 255] 映射到 [-1, 1],这是 MobileNet 系列训练时的标准预处理方式。如果训练模型时用的是别的归一化策略,这里要对应调整,否则特征分布会乱,检索精度直接崩。
细节三:dispose 的时机。model.predict 产生的输出 tensor,用完后必须 dispose。TensorFlow.js 的 WebGL 后端如果频繁创建 tensor 而不释放,显存会被耗光,页面就会越来越卡,甚至崩溃。我的习惯是推理函数里所有中间 tensor 都包在 tf.tidy 里:
const pred = tf.tidy(() => { const t = tf.browser.fromPixels(data, 4).resizeBilinear([224, 224]).expandDims(0); const normalized = t.div(127.5).sub(1); return model.predict(normalized); });这样中间结果自动回收,只需要手动 dispose 最终输出。
2.3 索引结构与检索算法:百万级数据量的端侧解法
向量库的规模决定索引策略。我的目标量级是五十万到一百万条,直接暴力全扫在纯 JS 里也是能跑的——50 万条 1024 维 Float16 向量,一次遍历算余弦相似度大约需要 200 到 300 毫秒。这个速度对交互式检索完全够用,所以我一开始并没有急着上复杂索引,而是先做了个最小验证。
但暴力扫的问题不在计算,而在内存分配。如果每次查询都新建一个 Float32Array 来存相似度分数,50 万条会分配 4MB 临时数组,GC 压力不小。我的优化方案是预分配一个 Float32Array 作为结果缓冲区,查询时直接写入对应索引,最后只做一次 top-K 选择。
top-K 的实现用最小堆,避免全排序。JS 里堆的代码很短,我贴一个简化版:
function topKSimilarities(scores, k) { const heap = []; const heapifyUp = (arr, i) => { while (i > 0) { const parent = (i - 1) >> 1; if (arr[i] < arr[parent]) { [arr[i], arr[parent]] = [arr[parent], arr[i]]; i = parent; } else break; } }; const heapifyDown = (arr, i) => { while (true) { let smallest = i; const left = 2 * i + 1; const right = 2 * i + 2; if (left < arr.length && arr[left] < arr[smallest]) smallest = left; if (right < arr.length && arr[right] < arr[smallest]) smallest = right; if (smallest !== i) { [arr[i], arr[smallest]] = [arr[smallest], arr[i]]; i = smallest; } else break; } }; for (let i = 0; i < scores.length; i++) { if (heap.length < k) { heap.push(scores[i]); heapifyUp(heap, heap.length - 1); } else if (scores[i] > heap[0]) { heap[0] = scores[i]; heapifyDown(heap, 0); } } return heap.sort((a, b) => b - a); }这个堆的核心逻辑是维护一个容量为 K 的最小堆,堆顶是当前 K 个最大相似度里的最小值。遍历新分数时,如果比堆顶大,就替换堆顶并下沉调整;否则跳过。这样遍历完所有向量后,堆里就是分数最高的 K 个,排序复杂度从 O(N log N) 降到 O(N log K)。
不过这只是基础版本。真正跑到 100 万条时,暴力全扫的时间会涨到 500 到 600 毫秒,虽然可以接受,但离“流畅”还有差距。后来我加了混合索引:先对每条 1024 维向量做一次 PCA 压缩到 32 维签名向量,检索时先用签名向量做粗筛,把候选集从 100 万缩小到 5 万左右,再在这 5 万里做精确距离计算。实测下来检索耗时可降到 35 到 50 毫秒,排序结果和全量暴力扫几乎一致。
2.4 任务调度与并发控制:Worker 不是万能的
Worker 解决了主线程卡顿,但 Worker 内部还有一个并发问题容易被忽视。TensorFlow.js 的 WebGL 后端依赖 GPU 上下文,多个预测如果同时发起,会导致 GL 上下文频繁切换,反而比串行更慢。所以我在 Worker 里加了个互斥锁,确保任意时刻只有一次 predict 在跑。
并发控制的代码其实就是一个简单的标志位:
let predicting = false; const waitQueue = []; async function runPredict(tensor) { if (predicting) { return await new Promise((resolve) => { waitQueue.push({ resolve, tensor }); }); } predicting = true; try { const result = await model.predict(tensor); return result; } finally { predicting = false; if (waitQueue.length) { const next = waitQueue.shift(); runPredict(next.tensor).then(next.resolve); } } }这个实现保证了 predict 的串行执行,逻辑简单但很有效。批量导入图片时,任务会排队依次执行,吞吐量虽然不会增加,但至少不会出现 GPU 上下文切换导致的性能毛刺。
另一个容易忽略的点是 postMessage 的数据传输方式。主线程向 Worker 传图片时,如果直接传 ImageData,浏览器会做结构化克隆,也就是深拷贝,100 张图就是 100 次大内存拷贝。正确的做法是使用 transferable object,比如把 ArrayBuffer 的所有权转移给 Worker:
const buffer = await imageBitmapToArrayBuffer(bitmap); worker.postMessage({ buf: buffer }, [buffer]);这样数据是零拷贝移交,主线程不再持有这个 ArrayBuffer,性能提升非常明显。
3. 实测性能数据与调优记录
3.1 一组有代表性的数据
我手上的测试环境是 MacBook Pro M1 Pro、Chrome 120,数据集是 55 万张商品图,特征向量 1024 维 Float16。我把关键耗时整理成表格,方便对比:
| 操作 | 耗时 |
|---|---|
| 模型加载(冷启动,含模型下载) | 1.2 秒 |
| 模型加载(IndexedDB 缓存命中) | 120 毫秒 |
| 单张图像特征提取(224x224) | 18 毫秒 |
| 按条批量特征提取(Worker 串行) | 约 140 毫秒/10 条 |
| 50 万向量暴力扫描 + top50 | 260 毫秒 |
| 混合索引粗筛 + 精确检索 | 35 毫秒 |
| 结果回传主线程 | 小于 1 毫秒 |
单看单张 18 毫秒的推理速度,大家可能没概念。换算一下就是:用户一次性拖入 50 张图,批量提取特征大约 1.4 秒,虽然能感觉到进度条在走,但不会让人抓狂。检索端 35 毫秒基本是无感的,用户在搜索框输入或拍照后,结果几乎秒出。
低端设备上的表现会差不少。我在一台 2018 年的 Intel 核显笔记本上测试,模型加载多花 300 毫秒,特征提取每张涨到 45 毫秒,检索仍然在 50 毫秒左右。原因是检索部分的计算量以内存带宽为主,CPU 主频影响没有推理那么大;而推理的 WebGL 后端在弱 GPU 上确实吃力,这是硬件决定的,不算方案缺陷。
3.2 性能调优的顺序:先推理后索引,这是经验之谈
性能调优我踩过几次弯路,总结出的顺序是:先保证推理一致性,再优化内存分配,最后才是索引算法调优。
推理一致性指的是预处理流程必须和训练时一致。我见过很多人拿到模型后直接用原图尺寸丢进网络,结果要么报错要么精度稀烂。统一走 resizeBilinear 到 224x224、div(127.5).sub(1) 是基本操作,这个不改,后面所有优化都没有意义。
内存分配的优化是容易被忽略但收益巨大的部分。刚才提到的预分配相似度结果缓冲区,就是在这一步做的。一个 50 万长度的 Float32Array 预分配后反复使用,比每次查询都 new 一个数组省掉了大量 GC 成本。测过用 Chrome 的任务管理器观察 JS 堆内存,优化前查询时堆内存会周期性飙升,优化后曲线平稳得多。
索引算法的优化是最后一步,因为它的复杂度最高。如果没有明显瓶颈,暴力全扫也能接受的话,就别提前引入聚类和签名向量这些复杂机制,否则调试成本很高。我从暴力扫升级到混合索引,是在数据量涨到 80 万、单次查询超过 400 毫秒之后才动手的。
4. 常见问题与排查技巧实录
4.1 模型加载失败或一直 pending,问题多半在格式和路径
排查模型加载问题,我的顺序是先看 Network 面板确认文件是否都 200,再判断 JSON 格式。这里有个特别容易踩的坑:TensorFlow.js 有 loadGraphModel 和 loadLayersModel 两个加载函数,分别对应 Graph model 和 Layers model。两者的 JSON 结构完全不同——GraphModel 的 JSON 里没有 "layers" 字段,而 LayersModel 的结构里有 layers 数组。搞混的话浏览器会报错,比如 "This document has no layers" 或者提示找不到 "modelTopology"。
我的建议是模型转换时指定 --output_format=tfjs_graph_model,然后统一用 loadGraphModel 加载。GraphModel 对推理更友好,图结构更完整,而且量化支持更好。
另一个是缓存问题。模型静态资源如果放到 CDN,要注意 CDN 的回源策略。我一度直接把模型丢在一个没配置好缓存的对象存储桶上,用户首次加载模型等了 8 秒,体验直接崩盘。现在我的方案是把模型包放到支持 ETag 的静态服务器上,配合 IndexedDB 做版本缓存,加载时间稳定在 1 秒内。
4.2 检索结果不准,按照这三步定位
结果不准是向量检索最让人头疼的问题。我的排查顺序固定为三步。
第一步检查预处理一致性。训练时用的归一化参数和推理时是否一致,输入尺寸是否统一。很多时候用户反馈“搜索结果很差”,我去一看,发现是不同图片走了不同尺寸的缩放,特征分布错位导致的。
第二步检查向量是否归一化。有些模型输出的 embedding 没有做 L2 normalize,如果直接拿来做余弦相似度,结果会被向量长度干扰。我的做法是在推理后统一做一次归一化,把向量长度拉回 1:
function l2Normalize(vec) { let sum = 0; for (let i = 0; i < vec.length; i++) sum += vec[i] * vec[i]; const norm = Math.sqrt(sum) || 1e-8; const out = new Float32Array(vec.length); for (let i = 0; i < vec.length; i++) out[i] = vec[i] / norm; return out; }归一化之后,余弦相似度就等价于点积,检索算法可以用更高效的实现。
第三步回头怀疑模型本身。如果预处理和归一化都没问题,但结果依然不好,那就要思考模型是不是适配当前的业务场景。比如拿人脸识别模型去做商品检索,特征语义完全不匹配,效果无论如何都不可能好。模型选型是检索质量的天花板,后面的一切优化都只是逼近这个天花板。
4.3 Worker 传输效率与内存泄漏,关键在引用管理
上面的坑比较浅,但下面这些就很隐蔽了。我详细讲讲 Worker 传输的细节。
第一点是 transfer list 的使用。postMessage 第二个参数用于指定哪些 ArrayBuffer 要把所有权转移给 Worker。如果忘了传第二个参数,ArrayBuffer 会走结构化克隆的深拷贝路径,大图数据拷贝上百 MB 都是常有的事。不过注意:transferable 一旦转移,主线程就无法再访问这个 buffer,所以只适合一次性使用的数据。
第二种更隐蔽的坑是 worker 线程里的内存泄漏。我遇到过多次检索后台堆内存持续上涨的问题,排查后发现问题并不全在 tensor 没有 dispose——而是 postMessage 回传向量时,如果返回的 ArrayBuffer 在主线程被缓存到集合里且未及时释放,相当于 worker 每次都在无意识地为这些“永久引用”产生新对象。解决方法是主线程处理完向量后主动置空引用,或者复用固定长度的 ArrayBuffer 池。
4.4 兼容性边界:Safari 的 OffscreenCanvas 和 iOS 的纹理尺寸
兼容性是端侧方案绕不开的话题。Web Worker 所有主流浏览器都支持,但 OffscreenCanvas 在 Safari 的支持很差——直到 16.4 版本才基本完整。如果用户群体里有不少 iOS Safari,主线程就别依赖 OffscreenCanvas 的 transferToImageBitmap,稳妥做法是在主线程用 Canvas 处理图片,然后把 ImageData 传给 Worker。
另一件容易被忽略的事是 WebGL 纹理尺寸限制。iOS 上 2D 纹理的最大尺寸通常是 4096 或 8192 像素,如果图像超过这个限制,模型会报 INVALID_VALUE 错误。所以输入图像在传给 Worker 前最好就缩放到 224 到 512 之间,既避免纹理超限,也减少数据传输量。
4.5 增量更新的合并索引策略
向量库不是静态的,用户会不断往库里加图。如果每次新增都全量重建索引,会带来不必要的计算开销。我的方案是维护一个“新增批”和一个“主索引”,各自独立检索后合并结果。
具体流程是:新向量先插入一个缓冲的数组中,检索时同时查主索引和缓冲数组,把两部分结果合并后按相似度排序。当缓冲数组的长度达到阈值(比如 1 万条),后台触发一次合并重建,把缓冲内容并入主索引,重新做聚类划分。合并重建任务我放在 requestIdleCallback 里执行,避开用户操作高峰,不影响交互体验。
5. 踩坑记录与实战心得
整个项目做完,我最想分享的是一条血泪教训:端侧方案看似替云端省了钱,但把问题转移到了静态资源分发和设备兼容上,这两块一点不能省心。
有一次我为了赶进度,把模型 JSON 和权重文件直接放在没配缓存的测试服务器上,结果用户加载模型要 8 秒,直接被客户投诉“页面打不开”。后来我仔细配置了静态服务器:模型文件加 ETag 和 Cache-Control,前端用 IndexedDB 做二次缓存,并实现版本号机制——模型更新时强制重新拉取,避免新旧权重混用。这一套做完,模型加载稳定在 1 秒内,才算是真正能上线。
另一个经验是:不要一开始就追求百万级性能。我建议新手从 1000 张图起步,先跑通端到端流程,确认特征质量达标,再逐步扩容到十万级、百万级。因为 1000 张图时排查问题非常容易,等到百万级才发现特征质量不对,回头换模型、改预处理,代价就大了。
性能优化的顺序也请记牢:先保证推理一致性(预处理统一),再优化内存管理(dispose 和缓冲区复用),最后才是索引算法调优。顺序反了,很容易陷入调参泥潭——一会儿觉得是归一化不对,一会儿觉得是聚类参数有问题,最后发现根因是最基础的 resize 写错了。
如果未来还往这个方向深入,我觉得有几个值得探索的扩展点:一是把 TF.js 的 WebGL 后端替换为 WebGPU 后端,推理速度还能再上一个台阶;二是用 WASM SIMD 优化距离计算的密集循环,把检索耗时的下限进一步压低;三是结合 IndexedDB 做向量库的持久化,让用户关掉浏览器再打开,索引数据依然在,不用重新导入图片。这些方向我部分已经在测试了,有结论再跟大家分享。