文本向量和图片向量能不能直接算相似度,关键看两件事:两类输入编码出来的向量维度是否一致,以及匹配图文对的分数是否稳定高于随机配对。维度不同,余弦相似度在数学上就算不了;维度相同但正负例分数混在一起,说明两个编码分支没有落到同一个可比较的空间,仍然不能直接比。
跨模态能不能直接比相似度,不能只看维度是否相同,还要看匹配对的分数能否稳定高过不相关配对。先打印文本、图片两类输出形状,再用同一张图和它的描述文字算一次余弦相似度做正例,补足五组以上负例看分布是否可分。若正负例分数区间重叠,不要靠调阈值硬撑,退回文本搜文本、图片搜图片的同模态检索,并把这几步固化成回归脚本便于换版本重跑。
分别编码一段文本和一张图片,打印两者输出形状
形状检查是最容易做错的一步:很多人只看“都是向量”,不看维度语义。把两类输入分别编码一次,把 shape、dtype、L2 范数都打出来,先确认它们是不是同一个维度的东西。下面两段骨架用占位接口表示,替换成你本地实际可加载的模型标识和入口名即可。
文本侧编码骨架
import numpy as np
model = load_model("embeddinggemma-2") # 模型标识以你本地能加载的为准
def encode_text(text):
out = model.encode(inputs=[text]) # 有的接口写作 encode(texts=[...])
arr = np.asarray(out["embeddings"] if isinstance(out, dict) else out)
return arr.reshape(1, -1) # 统一成 (N, D)
v_text = encode_text("一只橘猫趴在窗台上的照片")
print("text", v_text.shape, v_text.dtype, float(np.linalg.norm(v_text)))
图像侧编码骨架
from PIL import Image
img = Image.open("./cat.jpg").convert("RGB")
print("image_size", img.size) # 防止解码失败却看不出来
def encode_image(image):
out = model.encode(inputs=[image]) # 图像入口名以你本地接口为准
arr = np.asarray(out["embeddings"] if isinstance(out, dict) else out)
return arr.reshape(1, -1)
v_img = encode_image(img)
print("image", v_img.shape, v_img.dtype, float(np.linalg.norm(v_img)))
两类输出形状对照
| 输入类型 | 输出形状(示意) | 需要确认的点 | 不满足时的处理 |
|---|---|---|---|
| 一段文本 | (1, D_text) | D_text 是否等于模型声明的 embedding 维度 | 记录,作为基准 |
| 一张图片 | (1, D_img) | D_img 是否等于 D_text | 不等则必须先投影到同一维度 |
如果 D_text 与 D_img 不相等,说明两类输入走了不同的编码塔或不同的投影头,此时连点积都做不了,必须先做投影或对齐步骤,才有下一步。如果维度相等,也不要直接下结论,继续做第 2、3 节的分数检查,因为维度相同不等于空间一致。
用同一张图和它的描述文字算跨模态相似度
正例就用同一张图和它自己的描述文字。为了有参照,再把这句描述换成一个无关内容的描述,看两次分数差多少。余弦相似度要求两个向量维度一致,所以这一步本身就依赖第 1 节的形状结论。
def cosine(a, b):
a, b = a.reshape(-1), b.reshape(-1)
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
s_match = cosine(v_img, encode_text("一只橘猫趴在窗台上的照片"))
s_other = cosine(v_img, encode_text("雪山脚下的湖泊"))
print("match", round(s_match, 4), "unmatched", round(s_other, 4))
把两次打印出来的数字填进下表。这里的数值必须来自你自己的一次运行,不同模型、不同预处理(是否归一化、图片是否缩放、文本是否被截断)都会让结果变化很大。
| 配对 | 构成 | 相似度(填本机输出) | 判断形态 |
|---|---|---|---|
| 正例 | 猫图 vs 其描述文字 | 应明显高于同批次的随机配对 | |
| 对照 | 猫图 vs 无关描述文字 | 通常接近 0 或明显低于正例 |
如果正例分数低于若干不相关配对,先排除低级问题:打印 img.size 确认图片真的解码成功、打印分词长度确认文本没被截断、确认编码前做了同样的归一化。这些都排除后仍不理想,基本可以判定两类向量没有对齐,不要靠调阈值硬撑。
构造明显不相关的图文负例做对照
一组正例说服力不够,需要至少五组负例把分布铺开。负例的构造方式:随机换图的描述、换不同主题的图片、把同一个描述配到风格完全不同的图片上。每算一组就记录一行,最后看两个区间有没有重叠。
| 编号 | 配对内容 | 类型 | 相似度(填本机输出) |
|---|---|---|---|
| 1 | 猫图 vs 猫的描述 | 正例 | |
| 2 | 猫图 vs 狗的描述 | 负例 | |
| 3 | 猫图 vs 城市夜景 | 负例 | |
| 4 | 猫图 vs 一份纯文本合同片段 | 负例 | |
| 5 | 猫图 vs 雪山风光图 | 负例 | |
| 6 | 猫图 vs 菜单文字 | 负例 | |
| 7 | 猫图 vs 高度相关的另一张猫图 | 正例 | |
| 8 | 猫图 vs 含义相近的另一种描述 | 正例 |
拿到这张表后按区间判断,而不是看单个数字:若 min(正例) 大于 max(负例),说明这批样本上两个模态可分,可以考虑用阈值做粗筛;若区间有重叠但正例均值明显更高,属于不稳定状态,不建议把跨模态分数作为排序主依据;若两类分数完全交织,说明当前编码分支没有共享空间,直接跳到第 4 节。
若无跨模态对齐手段,退回到同模态检索的落地方式
当形状不一致,或者正负例分数交织在一起,又暂时没有可信的投影层或对齐训练手段时,跨模态硬比就是不可靠的。此时不必卡在混检上,同模态检索一样能交付可用能力,且结果可解释。
文本搜文本
doc_vecs = np.vstack([encode_text(t) for t in corpus]) # 语料统一编码一次
q_vec = encode_text("怎么给猫剪指甲")
scores = doc_vecs @ q_vec.reshape(-1) # 已归一化时可省去除法
topk = np.argsort(-scores)[:5]
for i in topk:
print(round(float(scores[i]), 4), corpus[i])
图片搜图片
img_vecs = np.vstack([encode_image(Image.open(p).convert("RGB")) for p in img_paths])
q_img_vec = encode_image(Image.open("./query.jpg").convert("RGB"))
scores = img_vecs @ q_img_vec.reshape(-1)
topk = np.argsort(-scores)[:5]
for i in topk:
print(round(float(scores[i]), 4), img_paths[i])
两条路径的索引要分开建、分开存,不要把文本向量和图像向量塞进同一张向量表里混查,否则等于把前面测出来的不可分问题带到线上。若业务上仍要做图文混检,比较稳妥的做法是在应用层分别取文本、图片各自的 topk,再做结果合并或重排,而不是直接比较跨模态分数本身。
把上述验证固定成回归脚本
换模型、换版本、换图片预处理都会改变前面的结论,所以把检查动作写成脚本,每次变更后重跑一次。脚本只做三件事:打印形状、打印正负例分数、给出是否可分。下面的骨架省略了 encode 系列函数的定义,沿用第 1 节的实现即可。
# check_embedding_cross_modal.py
import numpy as np
POS = ["一只橘猫趴在窗台上的照片", "一只橘猫趴着休息"]
NEG = ["雪山脚下的湖泊", "城市夜景航拍", "一份纯文本合同片段",
"咖啡店里的菜单", "海边日落"]
def main():
vt = encode_text(POS[0])
vi = encode_image(Image.open("./cat.jpg").convert("RGB"))
print("shape_text", vt.shape, "shape_image", vi.shape)
if vt.shape[1] != vi.shape[1]:
print("result: NEED_PROJECTION")
return
pos = [cosine(vi, encode_text(t)) for t in POS]
neg = [cosine(vi, encode_text(t)) for t in NEG]
for t, s in zip(POS, pos):
print("pos", round(s, 4), t)
for t, s in zip(NEG, neg):
print("neg", round(s, 4), t)
print("min_pos", round(min(pos), 4), "max_neg", round(max(neg), 4))
print("result:", "SEPARABLE" if min(pos) > max(neg) else "OVERLAPPED")
if __name__ == "__main__":
main()
预期输出可以这样读:第一行出现 shape_text 与 shape_image 的维度数值;两者不相等时脚本直接打印 NEED_PROJECTION 并结束,说明必须先加投影;相等时接着逐行打印正例和负例分数,最后一行给出 min_pos 与 max_neg 以及 result。result 为 SEPARABLE 才考虑用跨模态阈值做粗筛,为 OVERLAPPED 就按第 4 节的同模态方案落地,不要把调阈值当成解决办法。