把词向量“贴”在地球仪上:用球面视角看 Embedding 的真实几何

关键词:Embedding、词向量、余弦相似度、测地线距离、PCA、Plotly、jina-embeddings-v4

我们常说 "两个词的向量很接近",但这个 "接近" 到底长什么样?大多数教程会用 PCA 或 t-SNE 把向量压成二维散点图,然后说:你看,猫和狗靠得很近。

这种画法有一个容易被忽略的问题:主流 Embedding 模型输出的向量是归一化的,它们本来就住在一个超球面上,而不是整个空间里。 用平面散点图去看球面上的点,几何关系天然会被扭曲。

这篇文章用不到 200 行 Python,把一组词向量投影到一个 3D 地球仪上,并用大圆弧(great-circle arc)把它们连起来。你可以用鼠标转动这个 "词语地球仪",直接看到哪些词是邻居,哪些词相隔甚远。

完整代码就一个文件:[embedding_spherical.py](https://github.com/liu-xiao-guo/elastic_multimodal_search/blob/main/embedding_spherical.py "embedding_spherical.py")。


一、为什么是球面?

1. 归一化向量住在球面上

调用 Embedding 模型时,如果指定 normalize_embeddings=True,每个向量的 L2 范数都是 1:

ini 复制代码
`embeddings = model.encode(WORDS, task="retrieval", normalize_embeddings=True)` AI代码解释

单位长度的向量,终点都落在单位超球面上。此时向量的"长度"不再携带信息,唯一有意义的是方向。

2. 余弦相似度 = 夹角

对单位向量来说,余弦相似度就是点积,而点积的反余弦正好是两个向量的夹角,也就是它们在球面上的测地线距离:

ini 复制代码
`

1.  cos_sim = np.clip(embeddings @ embeddings.T, -1.0, 1.0)
2.  ang_dist = np.arccos(cos_sim)  # 弧度

`AI代码解释

这里的 np.clip 是为了防止浮点误差让数值略微超出 -1, 1,导致 arccos 返回 nan。

所以,"相似度高"与"球面上距离近"是同一件事的两种说法。既然如此,直接在球面上画图,比在平面上画图要诚实得多。


二、整体思路

整个脚本只做五件事:

  1. 用 sentence-transformers 加载模型,得到每个词的高维向量;
  2. 在原始高维空间中计算两两之间的夹角(这是"真值");
  3. 用 PCA 把向量降到 3 维;
  4. 把 3 维坐标再次归一化,推到单位球面上;
  5. 用 Plotly 画出球体、经纬网格、词语标记和大圆弧,输出一个可交互的 HTML。

下面逐步拆解。


三、准备数据:词表与分 类 着色

ini 复制代码
`

1.  WORDS = ["cat", "dog", "kitten", "Puppy", "king", "queen",
2.           "猫", "狗", "table", "chair", "pizza", "pasta", "asymptomatic"]
3.  MODEL_NAME = "jinaai/jina-embeddings-v4"

`AI代码解释

词表是刻意设计的,包含几类"对照组":

类别 词语 想验证什么
动物 cat、dog、kitten、Puppy、猫、狗 同义词、幼崽、跨语言能否聚在一起
皇室 king、queen 语义相关但不属于动物
家具 table、chair 同类名词
食物 pizza、pasta 同类名词
医学 asymptomatic 作为"离群点",看它会落在哪里

我选用 jina-embeddings-v4,因为它是多 语言模型 ,可以顺便观察"猫"和 "cat" 是否真的靠在一起。

颜色由类别决定:

ini 复制代码
`COLORS = [CATEGORY_COLORS[WORD_CATEGORIES.get(w, "other")] for w in WORDS]` AI代码解释

没有登记类别的词会自动落到 "other"(灰色),所以以后往词表里加词不会报错。


四、降维:PCA 到 3D,再推回球面

ini 复制代码
`

1.  pca3 = PCA(n_components=3)
2.  coords3 = pca3.fit_transform(embeddings)
3.  exp3 = pca3.explained_variance_ratio_ * 100

5.  norms = np.linalg.norm(coords3, axis=1, keepdims=True)
6.  sphere = coords3 / np.where(norms == 0, 1, norms)

`AI代码解释

这里有两个细节值得说一下。

第一,PCA 会先做中心化。 原始向量往往挤在球面的某一小块区域里(同一个模型产出的向量有共同的"偏置方向"),中心化之后,点会围绕原点散开,再归一化就能均匀地铺在球面上,画面更好看,也更容易区分。

第二,降到 3 维必然损失信息。 脚本会把前三个主成分保留的方差比例打印出来,并显示在图标题里。13 个词的向量维度很高,3 个主成分只能解释其中一部分,所以这张图是一个示意,不是原空间的完整复制。

为了让大家知道"图上看到的"和"真实的"差多少,脚本同时保存了两套距离:

ini 复制代码
`

1.  # 原始高维空间的真实夹角
2.  ang_dist = np.arccos(cos_sim)

4.  # 降到 3D 球面之后,你实际看到的夹角
5.  shown_dist = np.arccos(np.clip(sphere @ sphere.T, -1.0, 1.0))

`AI代码解释

鼠标悬停到任意一条弧线上,这两个数会同时显示,你可以直接对比降维带来的失真。


五、核心:用球面线性插值画大圆弧

球面上两点之间的最短路径是大圆弧,而不是直线。要在 3D 里把它画出来,需要球面线性插值(slerp):

python 复制代码
`

1.  def slerp(a, b, n=40):
2.      """Points along the great-circle arc (shortest path) from a to b."""
3.      omega = np.arccos(np.clip(np.dot(a, b), -1.0, 1.0))
4.      t = np.linspace(0, 1, n)[:, None]
5.      if omega < 1e-8:
6.          return np.repeat(a[None, :], n, axis=0)
7.      return (np.sin((1 - t) * omega) * a + np.sin(t * omega) * b) / np.sin(omega)

`AI代码解释

公式是:

slerp(a,b,t)=sin⁡((1−t)ω)sin⁡ω a+sin⁡(tω)sin⁡ω b,ω=arccos⁡(a⋅b)slerp(a,b,t)=sinωsin((1−t)ω)​a+sinωsin(tω)​b,ω=arccos(a⋅b)

当 t 从 0 走到 1,插值点就沿着球面从 a 滑到 b,并且始终保持在单位球面上。代码里 omega < 1e-8 的分支处理的是两点几乎重合的退化情况,避免除以接近 0 的 sin(omega)。


六、画一个 "半透明地球仪"

1. 球体与经纬网格

球体用 go.Surface 画得非常淡(opacity=0.12),再叠一层经纬线,立刻有了地球仪的感觉:

ini 复制代码
`

1.  fig.add_trace(go.Surface(
2.      x=sx, y=sy, z=sz, showscale=False, opacity=0.12,
3.      colorscale=[[0, "#cfd8e6"], [1, "#cfd8e6"]],
4.      hoverinfo="skip", showlegend=False,
5.      lighting=dict(ambient=1.0, diffuse=0.0),
6.  ))

`AI代码解释

lighting=dict(ambient=1.0, diffuse=0.0) 去掉了明暗变化,让球体像一层均匀的薄雾,不会抢走词语的注意力。

经纬线则合并成一条 折线 trace,用 None 分隔不同线段:

scss 复制代码
`xs += list(r * np.cos(t)) + [None]` AI代码解释

这是 Plotly 里的一个小技巧:几十条线只占一个 trace,渲染更快、图例也不会被撑爆。

2. 词语标记

每个词是一个独立的 Scatter3d trace,这样图例里可以单独点击显示或隐藏某个词,悬停时还能看到它的 (x, y, z) 坐标。

3. 弧线要"浮"在球面上

css 复制代码
`arc = slerp(sphere[i], sphere[j]) * 1.004` AI代码解释

注意那个 1.004。弧线如果恰好贴在半径为 1 的球面上,会被半透明球体"吃掉"一部分,出现断断续续的视觉问题。把弧线半径放大 0.4%,它就微微浮在球面上方,肉眼看不出偏差,却再也不会被遮挡。后面高亮线用的是 1.006,让它压在普通弧线之上。


七、最近邻高亮:让重点自己跳出来

13 个词两两连线有 78 条弧,全画出来会很乱。所以脚本为每个词找到它的最近邻,用深色粗线单独高亮,并在弧线中点标出角度:

scss 复制代码
`

1.  nn_pairs = set()
2.  for i in range(len(WORDS)):
3.      d = ang_dist[i].copy()
4.      d[i] = np.inf          # 排除自己
5.      j = int(np.argmin(d))
6.      nn_pairs.add((min(i, j), max(i, j)))

`AI代码解释

用 (min(i, j), max(i, j)) 作为键放进集合,可以自动去重:如果 A 的最近邻是 B,B 的最近邻也是 A,只会画一条线。

注意这里用的是 ang_dist,也就是高维空间里的真实距离,而不是 3D 投影后的距离。这样高亮出来的最近邻是"真"的最近邻,不会被降维误导。


八、交互按钮

图左上角有五个按钮,通过 Plotly 的 restyle 批量修改 trace 属性:

按钮 作用
Show / Hide Labels 切换词语文字标签
All Arcs 显示所有弧线
Nearest Only 只保留最近邻高亮
Hide Arcs 隐藏全部弧线

实现上只需要事先记录每类 trace 的下标(label_traces、arc_traces、nn_traces),按钮就知道该改谁:

ini 复制代码
`

1.  dict(label="Nearest Only", method="restyle",
2.       args=[{"visible": False}, arc_idx])

`AI代码解释

这是纯前端交互,输出的 HTML 文件不依赖 Python 运行环境,可以直接发给别人打开。


九、运行与输出

安装依赖:

go 复制代码
`pip install numpy scikit-learn plotly sentence-transformers` AI代码解释

运行:

go 复制代码
`python embedding_spherical.py` AI代码解释

脚本会:

  • 在终端打印 Embedding 形状、前三个主成分的方差占比,以及每个词到其最近邻的角度;
  • 把交互图保存为临时目录下的 word_embeddings_spherical.html;
  • 自动用浏览器打开。

提示:jina-embeddings-v4 体积较大,首次运行需要下载模型,请耐心等待;加载时需要 trust_remote_code=True。


十、怎么读这张图

建议你转动球体,重点观察几件事:

  1. 同类词是否聚成团? 比如动物类(cat、dog、kitten、Puppy)是否彼此相邻;
  2. 跨语言是否对齐? "猫"与 cat、"狗"与 dog 的弧线是否很短;
  3. 离群点在哪里? asymptomatic 这类医学词汇与其他词的距离;
  4. 失真有多大? 悬停在弧线上,对比"embedding 测地线"和"球面弧长"两个角度,差距越大,说明 3D 投影丢失的信息越多。

具体的结果取决于你本机运行的模型和版本,我建议以你自己看到的图和终端输出为准。


十一、几个值得注意的坑

  • 图是示意,不是真值。 3 维只保留了部分方差,任何结论都应以高维空间里的夹角(ang_dist)为准。
  • "距离近"不一定"语义同义"。 Embedding 捕捉的是共现和用法上的相似,反义词、同一话题下的词也可能靠得很近。
  • task 参数会影响结果。 jina-embeddings-v4 对不同任务(如 retrieval)有不同的适配,换任务类型,球面上的布局可能随之变化。
  • 词太多会显得拥挤。 弧线数量按 N² 增长,词表超过二三十个时,建议默认只显示最近邻。

十二、可以继续玩的方向

  • 换成别的模型(BGE、E5、OpenAI 等),比较不同模型的 "词语地球仪";
  • 把词换成句子,看主题如何在球面上聚类;
  • 把 PCA 换成 UMAP,对比哪种降维更能保持原空间的邻近关系;
  • 增加一个量化指标,例如统计 3D 最近邻与高维最近邻一致的比例,用数字衡量失真。

十三、展示

"猫" 和 "狗" 很近,"king" 和 "queen" 很近。"table" 和 "chair" 很近。

总结

  • 归一化的 Embedding 本来就在球面上,用球面视角看它比平面散点图更自然;
  • 余弦相似度与球面测地线距离是一回事,arccos(cos_sim) 就是夹角;
  • slerp 可以画出球面上的最短路径,弧线略微放大半径可避免被球体遮挡;
  • 同时保留"高维真值"和"3D 所见"两套距离,才能知道这张图有多可信。

希望这个小工具能让你对"向量相近"有更直观的体会。如果你有更好玩的词表,欢迎留言分享。

相关推荐
Elastic 中国社区官方博客6 小时前
基于 Elasticsearch 与 Jina AI 的全模态智能搜索实践:技术全解析
大数据·人工智能·elasticsearch·搜索引擎·ai·信息可视化·jina
全栈陈序员8 小时前
【Git】从入门到精通(三)—— 创建第一个Git仓库
大数据·git·elasticsearch
Elasticsearch8 小时前
我们的 agent 技能如何调研、构建和测试完整的 Elastic 集成
elasticsearch
Elasticsearch9 小时前
AgentHack Shanghai|基于阿里云AI搜索 Elasticsearch x Qoder 构建 AI Agent 应用
elasticsearch
全栈陈序员9 小时前
【Git】从入门到精通(四)—— Git三大区域:工作区、暂存区与版本库
大数据·git·elasticsearch
Sayai9 小时前
Elasticsearch ingest pipeline 被禁用之后:性能代价、安全治理与数据入库时间的四种替代方案
大数据·运维·安全·elasticsearch·搜索引擎·性能优化
YIN_尹9 小时前
【git原理与使用】git基本操作及底层原理(一)
java·git·elasticsearch
Elasticsearch1 天前
基于 Elasticsearch 与 Jina AI 的全模态智能搜索实践:技术全解析
elasticsearch
北风toto1 天前
Elasticsearch 8.2.3 快照迁移实战:从备份到恢复的完整指南
大数据·elasticsearch·jenkins