WZH

Back

用 Claude Code 做了个 emoji 语义检索工具。全静态,模型在浏览器里跑,下面简单记录一下。

demo

1. 需求梳理

  1. 支持用中英文搜 emoji,粘 emoji 也能反查相似;
  2. 支持 emoji 中英文释义;
  3. 支持 emoji 肤色切换;
  4. 支持调整语义检索参数,包括数量、相似度等;
  5. 支持点击复制功能,Enter 复制首个结果;
  6. 全静态的本地工具,隐私安全第一;
  7. 模型仅在首次打开页面时加载并缓存,后续离线直接可用,首次加载时长不超过 30s。

2. 整体方案设计

模型采用 Xenova/multilingual-e5-small:多语言、384 维、int8 量化后约 30 MB,刚好塞进浏览器 IndexedDB。e5-base 质量更好但浏览器里跑不划算。

1914 个 emoji 均配上中英文名和 CLDR 关键词(国旗部分基于 cldr-annotations-derived-full)。构建期在 Node 里跑一次 embedding 并中心化量化,产物共约 1.1 MB。

运行时主线程只管 UI,模型推理和向量计算全在 Web Worker 里。查询分三条路径:

  1. 纯关键词走字典匹配;
  2. 文本语义走模型 embedding + cosine;
  3. 粘 emoji 反查直接拿存好的向量互算 cosine,不跑模型。
BUILD · NODE 14 s · 手动触发 emoji + CLDR1914 × 中英 e5-smallembed 1914×384 center + L2减 mean 归一化 int8 quantizescale = 1/127 vec.bin + data.json718 KB + 400 KB RUNTIME · BROWSER WORKER fetch + modelIndexedDB 缓存 route querykw · text · emoji cosine top-Kint8 · int8 · scale² render + 复制按当前肤色渲染
Fig. 1 · 构建期产物(上)直接被运行时 Worker 加载(下)

3. 技术要点

3.1 Emoji 反向搜索

工具预期支持粘贴 emoji 后反查相似 emoji(输入 🐼 得到 🦊 🐵 🐻 等)。这里有一个天然的优化:既然 query 本身是 emoji,它的向量构建期已经算过、存在 emoji-vectors.i8.bin 里,运行时直接查索引拿出来用,不用再跑一次模型推理。

具体实现也很简单:input 先剥掉肤色修饰符(U+1F3FBU+1F3FF),用 Intl.Segmenter 切出第一个字符簇(处理 ZWJ 序列如 🤦‍♂️),查 Map<emoji, index>,拿到 index 后直接对 1914 个已存 int8 向量做点积。

文本搜索每次要跑一次模型(q8 量化下几十毫秒级),emoji 反查完全没有这个开销,纯点积即可,速度更快。

3.2 e5 的分布偏置问题

加完反向搜索之后,相似度滑块拖来拖去没反应。测了一下 🐼 对所有其他 emoji 的 cosine 分布:

plaintext
top-1   0.972  (🦊)
top-10  0.955  (🐭)
p50     0.901
p90     0.886  ← 90% 的 emoji 都在这之上

阈值设 0.84 基本等于放行了全部 1913 个 emoji。分析了一下原因估计是 e5 训练时给 passage 加了 passage: 前缀,所有 doc 向量被这个共享的上下文拉向了同一方向。算一下 doc 向量的均值,||mean|| 约 0.7,不可忽略。

这不是新问题。查了一下早有解法:All-but-the-Top (Mu & Viswanath, ICLR 2018) 里讨论了 word embedding 里少数公共方向主导距离的现象。修复思路是减掉 mean 再重新归一化:

JavaScript
for (let i = 0; i < n; i++) {
  let n2 = 0;
  for (let j = 0; j < dim; j++) {
    vec[i * dim + j] -= mean[j];
    n2 += vec[i * dim + j] ** 2;
  }
  const nrm = Math.sqrt(n2);
  for (let j = 0; j < dim; j++) vec[i * dim + j] /= nrm;
}

构建期减 mean 存进向量,运行时 query embedding 也减同一个 mean 即可,保证特征空间一致。改完之后几个对比:

plaintext
🐼 vs 🐻     0.963  →   0.570
🐼 vs 🍜     0.909  →  -0.011
🐼 vs 😀     0.920  →   0.201

分布拉开了,同一个阈值管文本搜索和反向搜索都能工作。

中心化前 中心化后 −1−0.500.51
Fig. 2 · 🐼 对其他 1913 个 emoji 的 cosine 分布,共享 [−1, 1] x 轴。细实线 = cosine 0(正交 / 无关),虚线 = 默认阈值 0.20。中心化前全部堆在右端 0.85–0.95,阈值 0.20 根本切不到;中心化后分布展开到 [−0.24, 0.62],阈值才真正有区分力

All-but-the-Top 论文其实建议做得更彻底,除了减 mean,还要再去掉前 K = D/100 个主成分(D=384 → K≈4)。把这步加上测了一轮:

K🐼 top-1🐼 vs 🐻🐼 vs 🍜
0(仅减 mean)0.6280.572-0.009
4(论文推荐值)0.5310.478-0.090
80.3760.3430.004

但结果有点反常:K 越大,相关 emoji(🐼-🐻)的 cosine 跟着被一起压低,分布不是更宽而是更窄了。猜测原因是原论文原本针对的是 word2vec / GloVe 这类 Zipfian 频率主导的词向量,共享方向比较多;而 e5 是对比学习训练的句向量,训练目标本身就在压缩共享方向上已经做了不少工作,减完 mean 后剩下的方向基本是真语义,再删就是丢失信号了。

最后留下的是 K=0,一阶即可。