半小时和 Claude Code 做了个 emoji 搜索
用 Claude Code 做了个 emoji 语义检索工具。全静态,模型在浏览器里跑,下面简单记录一下。
1. 需求梳理
- 支持用中英文搜 emoji,粘 emoji 也能反查相似;
- 支持 emoji 中英文释义;
- 支持 emoji 肤色切换;
- 支持调整语义检索参数,包括数量、相似度等;
- 支持点击复制功能,Enter 复制首个结果;
- 全静态的本地工具,隐私安全第一;
- 模型仅在首次打开页面时加载并缓存,后续离线直接可用,首次加载时长不超过 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 里。查询分三条路径:
- 纯关键词走字典匹配;
- 文本语义走模型 embedding + cosine;
- 粘 emoji 反查直接拿存好的向量互算 cosine,不跑模型。
3. 技术要点
3.1 Emoji 反向搜索
工具预期支持粘贴 emoji 后反查相似 emoji(输入 🐼 得到 🦊 🐵 🐻 等)。这里有一个天然的优化:既然 query 本身是 emoji,它的向量构建期已经算过、存在 emoji-vectors.i8.bin 里,运行时直接查索引拿出来用,不用再跑一次模型推理。
具体实现也很简单:input 先剥掉肤色修饰符(U+1F3FB 到 U+1F3FF),用 Intl.Segmenter 切出第一个字符簇(处理 ZWJ 序列如 🤦♂️),查 Map<emoji, index>,拿到 index 后直接对 1914 个已存 int8 向量做点积。
文本搜索每次要跑一次模型(q8 量化下几十毫秒级),emoji 反查完全没有这个开销,纯点积即可,速度更快。
3.2 e5 的分布偏置问题
加完反向搜索之后,相似度滑块拖来拖去没反应。测了一下 🐼 对所有其他 emoji 的 cosine 分布:
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 再重新归一化:
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 即可,保证特征空间一致。改完之后几个对比:
🐼 vs 🐻 0.963 → 0.570
🐼 vs 🍜 0.909 → -0.011
🐼 vs 😀 0.920 → 0.201分布拉开了,同一个阈值管文本搜索和反向搜索都能工作。
All-but-the-Top 论文其实建议做得更彻底,除了减 mean,还要再去掉前 K = D/100 个主成分(D=384 → K≈4)。把这步加上测了一轮:
| K | 🐼 top-1 | 🐼 vs 🐻 | 🐼 vs 🍜 |
|---|---|---|---|
| 0(仅减 mean) | 0.628 | 0.572 | -0.009 |
| 4(论文推荐值) | 0.531 | 0.478 | -0.090 |
| 8 | 0.376 | 0.343 | 0.004 |
但结果有点反常:K 越大,相关 emoji(🐼-🐻)的 cosine 跟着被一起压低,分布不是更宽而是更窄了。猜测原因是原论文原本针对的是 word2vec / GloVe 这类 Zipfian 频率主导的词向量,共享方向比较多;而 e5 是对比学习训练的句向量,训练目标本身就在压缩共享方向上已经做了不少工作,减完 mean 后剩下的方向基本是真语义,再删就是丢失信号了。
最后留下的是 K=0,一阶即可。