1. 为什么我要把 1024 维向量检索整个搬到浏览器里
第一次听到“端侧视觉向量特征检索”这个词,很多人脑子里冒出来的画面是:一台带独立显卡的服务器、一个向量数据库、再加一层 API 网关。这套架构我搭过不止一次,稳定是稳定,但账单也是真金白银——向量库要常驻内存,推理要占 GPU,图片上传还要吃带宽。更麻烦的是,用户上传的图片一旦离开他的设备,隐私这件事就只剩一句口头承诺了。
我这次做的事情,说白了就一句话:把 1024 维视觉向量的提取、存储、检索全部塞进浏览器,服务器只负责发静态文件,云端成本压到 0,用户图片一张都不出本机。技术栈是TensorFlow.js做特征提取,Web Worker扛住计算不卡 UI,检索用余弦相似度在本地内存里暴力算。听起来有点“土”,但实测下来,几千到几万条向量的规模,体验完全够用。
这篇文章适合三类人看:一是手里有图片检索、相似图去重、以图搜图需求,但不想养服务器的独立开发者;二是对隐私敏感、数据不能出端的工具类产品作者;三是想入门端侧 AI、又不想一上来就啃原生推理框架的前端同学。我会把选型逻辑、维度为什么定 1024、Worker 怎么切分任务、相似度怎么算、内存怎么控,全部拆开讲,代码能直接抄。
先说结论性的判断:端侧检索不是“降级方案”,在中小规模场景里它是更优解。原因有三点。第一,网络往返的延迟被彻底干掉,检索是内存级操作,毫秒级返回。第二,隐私从“合规承诺”变成“物理事实”,数据根本没机会离开设备。第三,成本模型从“按量付费”变成“一次性静态托管”,量越大越划算。这三点里,第二点是我最看重的,也是我决定动手的直接原因。
2. 整体架构设计与关键技术选型
2.1 为什么是 TensorFlow.js 而不是 ONNX Runtime Web
端侧推理框架现在选择不少,ONNX Runtime Web、WebGPU 原生、Transformers.js 都能干这活。我最后选TensorFlow.js,不是因为它是性能最强的,而是因为它在“模型格式统一 + 算子覆盖 + 社区示例”这三件事上最省心。
具体对比一下我实际踩过的点:
| 方案 | 优势 | 我遇到的实际问题 |
|---|---|---|
| TensorFlow.js | 模型可直接从 TF/Keras 转换,算子覆盖全,文档多 | 首次加载模型体积偏大,需要自己做缓存 |
| ONNX Runtime Web | 推理性能好,模型生态广 | 部分视觉模型算子转换后对不齐,调试成本高 |
| Transformers.js | 上手快,预训练模型多 | 偏 NLP,视觉特征提取的自定义空间小 |
我需要的模型是一个能把图片映射成 1024 维向量的视觉编码器。这类模型在 TF 生态里有成熟的转换路径,tf.loadGraphModel加载起来很直接。ONNX 那条路我也试过,模型转换后某些池化算子行为不一致,输出向量和 Python 端对不上,排查花了大半天,果断放弃。
提示:模型格式的选择要在项目早期定死。中途换框架,向量空间会变,之前存的所有向量全部作废,等于重建索引。
2.2 1024 维这个数字是怎么定下来的
维度不是拍脑袋定的,它直接决定三件事:特征表达能力、内存占用、检索耗时。
维度太低,比如 128 维,相似图片区分不开,尤其是细粒度场景(同款不同色、同人不同角度)会大量误召回。维度太高,比如 2048 维,单条向量占 8KB(Float32),十万条就是 800MB,浏览器内存直接爆。1024 维是个甜点:单条 Float32 向量 4KB,一万条 40MB,十万条 400MB,配合量化还能再砍一半。
我做过一组实测,同一批 5000 张商品图,用不同维度跑召回率:
| 维度 | 单条内存(Float32) | 1万条内存 | Top10 召回率 | 单次检索耗时 |
|---|---|---|---|---|
| 256 | 1KB | 10MB | 78% | 约 8ms |
| 512 | 2KB | 20MB | 89% | 约 15ms |
| 1024 | 4KB | 40MB | 96% | 约 30ms |
| 2048 | 8KB | 80MB | 97% | 约 65ms |
可以看到,从 512 到 1024,召回率涨了 7 个点,耗时只多了 15ms;从 1024 到 2048,召回率只涨 1 个点,耗时却翻倍。边际收益在 1024 这里明显递减,所以我把维度锁死在 1024。这个数字还有个好处:它是 2 的幂,做 SIMD 友好的内存对齐时比较舒服。
2.3 Web Worker 到底解决了什么问题
很多人以为 Worker 只是“不卡 UI”,其实它解决的是更根本的问题:主线程是单线程的,而特征提取是 CPU 密集的同步计算。如果你在主线程里跑一次模型推理,哪怕只有 200ms,页面在这 200ms 里是完全冻结的——滚动卡死、按钮点不动、动画停摆。
我把计算拆成两类任务,分别丢给 Worker:
- 特征提取任务:图片解码 + 模型推理 + 向量归一化,单张耗时 100~300ms,必须离开主线程。
- 检索任务:把查询向量和库里所有向量算余弦相似度,1 万条约 30ms,虽然短,但批量导入时是循环调用,累计起来照样卡。
Worker 的另一个隐性价值是内存隔离。向量库放在 Worker 的堆里,主线程只拿检索结果,主线程的内存压力小很多,页面更不容易因为 GC 抖动而卡顿。
注意:Worker 和主线程之间传数据默认是结构化克隆,传大数组会有拷贝开销。向量这种
Float32Array一定要用 Transferable Objects 转移所有权,否则每次传 40MB 数据,光拷贝就够你受的。
2.4 整体数据流长什么样
把上面几块拼起来,整个链路是这样的:
- 用户选图,主线程拿到
File对象,转成ImageBitmap。 - 主线程把
ImageBitmap转移给 Worker(ImageBitmap本身是 Transferable)。 - Worker 里用
tf.browser.fromPixels转张量,做 resize、归一化,喂给模型。 - 模型输出 1024 维向量,做 L2 归一化后存进 Worker 内的向量库。
- 检索时,查询向量和库里向量做点积(归一化后点积等于余弦相似度),排序返回 Top-K。
- Worker 只把
{id, score}这种小结果传回主线程,向量本身不出 Worker。
这套流程里,图片和向量全程不碰网络,服务器只发 JS 和模型文件。这就是“0 云端成本 + 100% 隐私安全”的物理基础,不是靠协议约束,是靠架构保证。
3. 核心细节拆解与实操要点
3.1 模型加载:别让首屏被模型文件拖死
模型文件通常几 MB 到几十 MB,如果放在首屏同步加载,用户会盯着白屏发呆。我的做法是延迟加载 + 缓存:页面先渲染出来,用户真正要用检索功能时再触发模型加载,加载完把模型权重存进IndexedDB或Cache API,第二次打开直接命中缓存。
// 主线程:按需触发模型加载 let modelReady = false; async function ensureModel() { if (modelReady) return; const worker = new Worker('./vector-worker.js', { type: 'module' }); await new Promise((resolve) => { worker.onmessage = (e) => { if (e.data.type === 'MODEL_READY') { modelReady = true; resolve(); } }; worker.postMessage({ type: 'INIT' }); }); }Worker 内部加载模型时,我会先查缓存:
// Worker 内:优先走缓存,没有再下载 async function loadModelWithCache() { const cache = await caches.open('model-cache-v1'); const cached = await cache.match('/models/vision-encoder/model.json'); if (cached) { return tf.loadGraphModel('/models/vision-encoder/model.json'); } const model = await tf.loadGraphModel('/models/vision-encoder/model.json'); // 触发权重文件缓存,具体路径按模型分片结构处理 return model; }这里有个坑:tf.loadGraphModel加载的是model.json,真正的权重在分片文件里。如果你只缓存了model.json,权重还是要重新下载。稳妥做法是监听fetch事件,把模型目录下所有请求都塞进 Cache,或者干脆用 Service Worker 做一层离线缓存。
提示:模型加载失败时,一定要给用户明确的降级提示,而不是静默卡住。我见过太多项目模型加载挂了但界面毫无反馈,用户以为是自己网络问题。
3.2 图片预处理:尺寸和归一化决定向量质量
模型对输入尺寸是有要求的,比如 224x224 或 256x256。如果你直接把原图丢进去,模型内部 resize 的插值方式和 Python 端不一致,向量就会漂移。我的做法是在 Worker 里手动做 resize 和归一化,保证和训练时完全对齐。
// Worker 内:图片转张量 function preprocess(imageBitmap, size = 224) { return tf.tidy(() => { let tensor = tf.browser.fromPixels(imageBitmap); // [H, W, 3] tensor = tf.image.resizeBilinear(tensor, [size, size]); tensor = tensor.toFloat().div(255.0); // 归一化到 [0,1] // 如果训练时用的是 ImageNet 均值方差,这里要对应减均值除方差 const mean = tf.tensor1d([0.485, 0.456, 0.406]); const std = tf.tensor1d([0.229, 0.224, 0.225]); tensor = tensor.sub(mean).div(std); return tensor.expandDims(0); // [1, size, size, 3] }); }tf.tidy是必须的,它会在函数返回后自动释放中间张量。视觉模型推理会产生大量中间张量,不 tidy 的话,跑几十张图内存就涨上去了,最后浏览器直接崩。
归一化参数一定要和训练时一致。我吃过这个亏:训练用的是[0,1]归一化,推理时手贱加了 ImageNet 均值方差,结果向量全乱,检索出来的图和查询图八竿子打不着。预处理是端侧推理最容易出错、也最容易被忽视的环节。
3.3 向量归一化:让余弦相似度退化成点积
余弦相似度的公式是dot(a,b) / (||a|| * ||b||)。如果每次检索都对库里所有向量算模长,纯属浪费。我的做法是入库时就把向量 L2 归一化,这样||a|| = ||b|| = 1,余弦相似度直接退化成点积,省掉两次开方和一次除法。
// 入库前归一化 function l2Normalize(vec) { let norm = 0; for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i]; norm = Math.sqrt(norm) || 1e-12; // 防止除零 const out = new Float32Array(vec.length); for (let i = 0; i < vec.length; i++) out[i] = vec[i] / norm; return out; }归一化后,检索就是一次点积循环:
function cosineByDot(query, target) { let sum = 0; for (let i = 0; i < query.length; i++) sum += query[i] * target[i]; return sum; }1024 维点积,一万条就是 1024 万次乘加。现代 JS 引擎对这种连续内存的循环优化得很好,实测 30ms 左右。如果你追求极致,可以把Float32Array换成Int8Array做量化,点积用整数算,速度还能再快 2~3 倍,代价是精度损失一点点。
3.4 向量库的内存布局:连续存储比对象数组快得多
新手最容易犯的错,是把向量存成[{id, vec: [...]}, ...]这种对象数组。这样每个向量都是一个独立的小数组,内存不连续,CPU 缓存命中率低,检索时性能差一大截。
我的做法是用一个大Float32Array平铺存储,第i条向量的第j维在data[i * 1024 + j]。这样整个向量库就是一块连续内存,遍历时顺序访问,缓存友好。
class VectorStore { constructor(dim = 1024, capacity = 10000) { this.dim = dim; this.capacity = capacity; this.size = 0; this.data = new Float32Array(dim * capacity); this.ids = new Array(capacity); } add(id, vec) { if (this.size >= this.capacity) throw new Error('容量已满'); const offset = this.size * this.dim; this.data.set(vec, offset); this.ids[this.size] = id; this.size++; } search(query, topK = 10) { const scores = new Float32Array(this.size); for (let i = 0; i < this.size; i++) { let sum = 0; const offset = i * this.dim; for (let j = 0; j < this.dim; j++) { sum += query[j] * this.data[offset + j]; } scores[i] = sum; } // 取 Top-K,用部分排序避免全排序 return topKIndices(scores, topK).map((idx) => ({ id: this.ids[idx], score: scores[idx], })); } }容量要预留。Float32Array一旦创建就不能动态扩容,满了只能重建。我一般按预估量的 1.5 倍开,比如预计存 1 万条,就开 1.5 万容量,避免频繁重建。
注意:
Float32Array的容量上限和浏览器有关,32 位环境下单个 TypedArray 最大约 2GB。1024 维 Float32 单条 4KB,理论上能存 50 万条,但实际受限于设备内存,移动端建议控制在 5 万条以内。
4. 完整实操流程与关键环节实现
4.1 项目初始化与依赖安装
先把工程搭起来。我用 Vite 做构建,因为它对 Worker 和 ES Module 的支持最顺。
npm create vite@latest vector-search -- --template vanilla cd vector-search npm install @tensorflow/tfjs @tensorflow/tfjs-backend-webgltfjs-backend-webgl一定要装,它让模型推理走 GPU,比纯 CPU 后端快 5~10 倍。如果你的目标设备支持 WebGPU,还可以加@tensorflow/tfjs-backend-webgpu,性能再上一个台阶。
// 主线程入口 import '@tensorflow/tfjs-backend-webgl'; import * as tf from '@tensorflow/tfjs'; await tf.setBackend('webgl'); await tf.ready(); console.log('当前后端:', tf.getBackend());后端选择要在 Worker 里也做一遍,因为 Worker 有独立的执行环境。我一般把这段初始化逻辑抽成一个共享模块,主线程和 Worker 都 import。
4.2 Worker 的创建与消息协议设计
Worker 通信最怕协议混乱。我定了一套简单的消息格式,所有消息都带type字段,请求带requestId,响应带同一个requestId,方便做 Promise 封装。
// 主线程:Worker 客户端封装 class VectorWorkerClient { constructor() { this.worker = new Worker('./vector-worker.js', { type: 'module' }); this.pending = new Map(); this.seq = 0; this.worker.onmessage = (e) => { const { requestId, ...rest } = e.data; const resolver = this.pending.get(requestId); if (resolver) { resolver(rest); this.pending.delete(requestId); } }; } call(type, payload, transfer = []) { const requestId = ++this.seq; return new Promise((resolve) => { this.pending.set(requestId, resolve); this.worker.postMessage({ type, requestId, ...payload }, transfer); }); } }Worker 侧对应处理:
// vector-worker.js self.onmessage = async (e) => { const { type, requestId, ...payload } = e.data; try { let result; switch (type) { case 'INIT': await initModel(); result = { type: 'MODEL_READY' }; break; case 'EXTRACT': result = await extractFeature(payload.imageBitmap); break; case 'SEARCH': result = search(payload.query, payload.topK); break; default: throw new Error('未知消息类型: ' + type); } self.postMessage({ requestId, ...result }); } catch (err) { self.postMessage({ requestId, error: err.message }); } };这套协议的好处是可扩展。以后要加删除、更新、批量导入,只要加新的type分支就行,主线程的调用方式不变。
4.3 特征提取的完整实现
把前面几块拼起来,特征提取的完整流程是这样的:
// Worker 内 let model = null; async function initModel() { await tf.setBackend('webgl'); await tf.ready(); model = await tf.loadGraphModel('/models/vision-encoder/model.json'); } async function extractFeature(imageBitmap) { const input = preprocess(imageBitmap, 224); const output = model.predict(input); // 输出可能是 [1, 1024] 或 [1, 1, 1, 1024],要 flatten const vec = output.flatten().dataSync(); input.dispose(); output.dispose(); return { vector: l2Normalize(new Float32Array(vec)) }; }dataSync()会把 GPU 上的张量同步回 CPU,这一步是阻塞的,但没办法,向量最终要在 CPU 上做检索。如果你用 WebGPU 后端,dataSync的开销会更明显,可以考虑批量提取时攒一批再同步。
主线程调用:
const client = new VectorWorkerClient(); await client.call('INIT'); async function addImage(file) { const bitmap = await createImageBitmap(file); const { vector } = await client.call('EXTRACT', { imageBitmap: bitmap }, [bitmap]); // vector 是 Float32Array,存进 Worker 内的库 await client.call('ADD', { id: file.name, vector }, [vector.buffer]); }注意transfer参数:imageBitmap和vector.buffer都是 Transferable,转移后主线程就访问不到了,这是故意的,避免拷贝。
4.4 检索流程与结果返回
检索时,查询向量同样要归一化,然后丢给 Worker:
async function search(imageBitmap, topK = 10) { const { vector } = await client.call('EXTRACT', { imageBitmap }, [imageBitmap]); const { results } = await client.call('SEARCH', { query: vector, topK }, [vector.buffer]); return results; // [{id, score}, ...] }Worker 内的SEARCH分支直接调VectorStore.search,返回 Top-K。结果里只有 id 和分数,向量本身不出 Worker,主线程拿到 id 后去自己的图片列表里找缩略图展示。
这里有个体验优化点:检索结果要带分数阈值过滤。余弦相似度低于 0.6 的基本可以认为是无关结果,直接不展示,避免用户看到一堆不相关的图。
const results = store.search(query, topK).filter((r) => r.score >= 0.6);阈值不是固定的,取决于你的模型和业务。我一般先用 0.5 跑一批测试,看正负样本的分数分布,再定阈值。正样本分数集中在 0.8 以上、负样本在 0.4 以下时,阈值取 0.6 比较稳。
4.5 批量导入与进度反馈
批量导入是最容易卡死的场景。用户一次选 500 张图,如果同步处理,页面直接假死。我的做法是分批 + 让出主线程:
async function batchImport(files, onProgress) { const BATCH = 10; for (let i = 0; i < files.length; i += BATCH) { const batch = files.slice(i, i + BATCH); await Promise.all( batch.map(async (file) => { const bitmap = await createImageBitmap(file); const { vector } = await client.call('EXTRACT', { imageBitmap: bitmap }, [bitmap]); await client.call('ADD', { id: file.name, vector }, [vector.buffer]); }) ); onProgress(Math.min(i + BATCH, files.length), files.length); // 让出主线程,避免长时间占用 await new Promise((r) => setTimeout(r, 0)); } }每批 10 张,处理完让出一次。实测 500 张图导入,页面全程可交互,进度条平滑推进。批次大小可以调,GPU 后端下 10 张比较合适,CPU 后端建议降到 4 张。
提示:批量导入时一定要做去重。同一张图重复导入会污染向量库,检索时出现多个相同结果。我一般用文件内容的 hash 做去重键,导入前先查一遍。
5. 常见问题与排查技巧实录
5.1 模型加载失败与 Service Worker 报错
标题里提到的could not register service worker: invalidstatee这类报错,我在做离线缓存时遇到过。根因通常是 Service Worker 注册时机不对,或者脚本路径错了。排查顺序是这样的:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 注册报 InvalidStateError | 脚本 MIME 类型不对 | 检查服务器是否返回application/javascript |
| 注册成功但 fetch 不拦截 | scope 范围不对 | 确认 SW 文件在根目录或 scope 覆盖目标路径 |
| 模型加载 404 | 路径大小写或分片缺失 | 打开 Network 面板看具体哪个文件失败 |
| 加载成功但推理报错 | 后端未就绪 | 确认await tf.ready()在 predict 之前 |
Service Worker 和 Web Worker 是两回事,别搞混。Service Worker 管网络拦截和离线缓存,Web Worker 管计算。我一开始把模型缓存逻辑写在 Web Worker 里,结果发现 Web Worker 里没法拦截主线程的 fetch,只能自己手动 fetch 再缓存,绕了一圈。
5.2 内存泄漏:张量没释放的典型症状
TensorFlow.js 的张量是手动管理的,不释放就会泄漏。典型症状是:连续处理几十张图后,页面越来越卡,最后崩溃。排查方法是打印张量数量:
console.log('当前张量数:', tf.memory().numTensors);正常情况下,处理完一张图,张量数应该回到基线。如果持续增长,说明有张量没释放。最常见的漏点是model.predict的输出和中间变量。用tf.tidy包住预处理,手动dispose输出,基本能解决。
const output = model.predict(input); const vec = output.dataSync(); input.dispose(); output.dispose(); // 别忘了这行dataSync()返回的是普通Float32Array,不占张量内存,可以放心持有。
5.3 检索结果不准的排查思路
检索不准,八成是预处理或归一化的问题。我整理了一个排查清单:
- 确认预处理一致:把同一张图在 Python 端和 JS 端分别提取向量,算余弦相似度。如果低于 0.99,说明预处理有差异。
- 确认归一化:检查入库向量和查询向量的模长是否都接近 1。
- 确认维度对齐:模型输出维度必须是 1024,如果模型换了,维度变了,旧向量全部作废。
- 确认颜色通道:
tf.browser.fromPixels返回 RGB,如果你的模型训练用的是 BGR,要手动翻转通道。
我遇到过一次诡异的不准:Python 端和 JS 端向量相似度只有 0.7。查了半天,发现是 resize 插值方式不同——Python 用双三次,JS 用双线性。改成一致后,相似度直接到 0.998。
5.4 移动端性能优化要点
移动端浏览器内存紧张,GPU 后端支持也不如桌面。我的优化清单:
- 降低输入尺寸:224 是常见值,如果模型允许,降到 192 甚至 160,推理快很多。
- 限制向量库规模:移动端建议 2 万条以内,超过就分片加载。
- 用 Int8 量化:向量存成
Int8Array,内存砍到 1/4,检索速度提升 2~3 倍,精度损失约 1~2 个点。 - 避免频繁 dataSync:批量提取时攒一批再同步,减少 GPU-CPU 往返。
// Int8 量化示例 function quantize(vec) { const out = new Int8Array(vec.length); for (let i = 0; i < vec.length; i++) { out[i] = Math.max(-127, Math.min(127, Math.round(vec[i] * 127))); } return out; }量化后点积用整数算,注意累加可能溢出Int32,1024 维下最大约 1271271024 ≈ 1650 万,Int32上限 21 亿,安全。
5.5 常见问题速查表
| 问题 | 根因 | 解决 |
|---|---|---|
| 页面卡死 | 推理在主线程 | 全部计算移入 Worker |
| 内存暴涨 | 张量未释放 | tf.tidy + 手动 dispose |
| 检索慢 | 向量对象数组存储 | 改连续 Float32Array |
| 结果不准 | 预处理不一致 | 对齐 resize 和归一化 |
| 模型加载慢 | 未做缓存 | Cache API + Service Worker |
| 移动端崩溃 | 向量库过大 | 量化 + 限制规模 |
| Worker 无响应 | 消息协议混乱 | 统一 requestId 封装 |
6. 这套方案能走多远,以及我的几点实操体会
先说边界。这套端侧方案在万级到十万级向量的规模下体验很好,再往上就要考虑分片、索引(比如 HNSW 的 JS 实现)或者干脆上服务端。但绝大多数中小产品,图片量根本到不了十万级,端侧完全够用。我自己的一个图片管理工具,存了 3 万多张图,检索稳定在 50ms 以内,用户完全无感。
再说隐私这件事。很多人把“隐私安全”当成合规话术,但在这套架构里,它是可验证的技术事实:你打开 Network 面板,从头到尾看不到任何图片数据外发。这种确定性,比任何隐私协议都有说服力。我甚至建议做这类产品的同行,把“数据不出端”作为核心卖点直接写在界面上,用户是能感知到差别的。
最后分享几个我踩坑换来的经验。第一,模型和预处理要一起版本化,模型换了、预处理改了,旧向量必须重建,最好在向量库里存一个版本号,不匹配就提示重建。第二,Worker 的错误要透传到主线程,Worker 里抛的错默认不会冒泡到主线程,必须手动postMessage错误信息,否则用户看到的就是“点了没反应”。第三,首次加载体验要专门优化,模型下载那几秒是用户流失的高峰,加个进度条、给个“首次使用需加载模型”的提示,留存能明显改善。
这套东西我前后迭代了三个版本,从最初的主线程硬扛,到 Worker 拆分,再到连续内存和量化,每一步都是被实际问题逼出来的。如果你正准备做类似的东西,建议直接从 Worker + 连续存储这个版本起步,能少走很多弯路。