Qwen3-VL-Embedding向量取值差异与图文检索调优

1. 先把"向量取值"这个问题拆开看

第一次看到"Qwen3-VL-Embedding 的向量取值有何不同"这个问题,我第一反应是:问的人到底在问哪一层的不同。因为在实际交流里,这句话至少对应三种完全不同的诉求,混在一起聊就会各说各话。第一种是数值层面:这个模型吐出来的浮点数,范围是多少,有没有归一化,norm 是 1 还是几十,精度是 bf16 还是 fp32;第二种是分布层面:不同模态、不同长度、不同内容的输入,向量的统计特征(均值、方差、极值、能量集中在哪些维度)差在哪;第三种是语义层面:把图文混在一起编码之后,向量空间里的相对位置有没有变化,同一张图的文本描述和图片本身到底靠多近。

这三个问题答案不一样,排查路径也不一样。我见过有人拿着一个范数 60 多的向量去和另一个范数 3 的向量算余弦相似度,然后得出结论"这个模型效果不行",其实他压根没做归一化,中间还混进了 padding。也见过有人把相似度阈值卡在 0.8,结果图文检索召回率低得离谱,因为他不知道高维空间里两个毫不相干的向量,余弦相似度天然就会落在 0 到 0.2 这个区间里。

所以这篇东西我打算从头捋一遍:先讲清楚多模态嵌入模型的向量取值为什么和纯文本模型长得不一样,根源在哪几层结构上;然后给一套可以直接跑的代码,把数值统计打出来看;再聊这些取值差异往下游传导,会在检索、聚类、阈值设定上带来什么具体麻烦;最后把我自己踩过的坑整理成一张速查表。适合已经在用向量库做检索、但发现"图文混检"效果不对劲的同学,也适合刚开始接触多模态 embedding、想搞明白这些数字到底意味着什么的新手。文中涉及的模型规模、维度这类具体参数,不同版本会有出入,请以你手里的模型卡为准,方法是可以直接套用的。

2. 取值差异的根源:视觉 token 是怎么进序列的

2.1 从"一句话一条向量"到"一张图几百个 token"

纯文本嵌入模型的思路很简单:一句话切成若干 token,过完编码器,做一次池化,得到一条 D 维向量。整个过程里序列长度是可预期的,一句话二三十个 token,长文档几百个 token,量级可控。多模态模型就不一样了,图像进来之后要先过视觉编码器,切 patch,再按动态分辨率策略决定保留多少个视觉 token。同一张图,你按原始尺寸喂进去和缩放到一半喂进去,得到的视觉 token 数量可能差好几倍,从几十个到上千个都有可能。

这件事对向量取值的影响非常直接:池化是对整个序列做的,序列里视觉 token 占比越高,最终那条向量的数值形态就越"偏向视觉侧"。一句十个字的文本配一张高清大图,序列里可能有 95% 以上都是视觉 token,这时候你做 mean pooling,本质上是在给视觉特征做平均,文本信号被稀释得很厉害。反过来,纯文本输入里一个视觉 token 都没有,取值形态又回到接近纯文本模型的样子。

我的经验是:如果你要拿这个模型做"以文搜图",query 侧是纯文本,库侧是图片,两边序列构成的差异会直接体现在向量范数和方向上。不做任何处理直接比,天然就有一层系统性偏差。这不是模型的问题,是池化把不同长度的异构序列压成一条向量时必然产生的现象。

2.2 池化方式决定了取值的"形状"

池化策略的选择,比很多人想象的更影响最终取值。常见的三种做法各有各的取值特征。

池化方式取值特征适用场景主要风险
Mean pooling(带 mask)各维取值较平缓,方差偏小,norm 随内容变化不大通用检索、长文本关键 token 信号被平均掉
Last token pooling取值更"尖",个别维度幅值明显偏大生成式模型的配套方案受序列末尾内容影响大,图文混排时不稳定
CLS / 专用池化头分布最规整,通常配套训练过模型原生推荐方式必须严格按模型卡来,不能自己换

mean pooling 最容易被误用的地方是 mask。批次里长短不一的样本会被 padding 到同一长度,如果 padding 位置的 hidden state 也参与了平均,那条向量的范数会明显偏小,方向和真实语义也会有偏移。这个偏移在短样本上尤其明显——一条 8 个 token 的文本被 padding 到 512,如果没做 mask,等于往向量里掺了 98% 的无效信息。我早期的代码里就犯过这个错,当时的表现是"短 query 检索效果特别差,长 query 还行",查了两天才发现是 mask 没乘上。

last token pooling 的坑在另一头。多模态输入里,图片 token 通常排在文本前面或者中间,序列最后一个 token 往往是文本侧的收尾符号,用它做池化,相当于只用文本尾部信息代表整条图文对,图片信息能保留多少全看注意力怎么分配。这种方式在纯文本上表现不错,搬到图文混排上就得重新验证。

2.3 归一化层和指令前缀对取值的拉扯

模型最后一层通常带一个归一化(RMSNorm 或 LayerNorm),这一层决定了每个 token 向量的幅值量级。取完 hidden state 再做池化,得到的原始向量 norm 一般不会是 1,常见落在几十这个量级,具体数值跟模型宽度、训练时的损失函数设计都有关系。这个原始向量直接存进向量库也不是不行,但一定要清楚:此时点积的大小同时包含了"方向相似"和"长度大小"两个因素,两条语义相关但长度差异大的样本,点积可能反而不如两条语义无关但都很长的样本。

指令前缀是另一个容易被忽略的变量。这类模型很多支持在输入前加一段任务描述,比如"为这段内容生成用于检索的表示"。加与不加,向量方向差别相当大。我实测过同一段文本加不加指令前缀的余弦相似度,落在 0.6 到 0.8 之间的居多,也就是说这两个向量在空间里已经偏出去三四十度了。这意味着你的库里如果是用 A 方式编码的,query 用 B 方式编码,检索结果会莫名其妙地差,而且很难从表面上看出原因。

注意:指令前缀必须全链路一致。入库用什么前缀,查询就用什么前缀;入库不加,查询也别加。中途换过一次,最稳妥的做法是全量重刷,别想着混着用。

3. 上手实测:把向量的数值特征打出来

3.1 最小可跑的一段代码

先把环境搭起来。依赖主要就是 transformers 和 torch,处理器负责把文本和图片统一处理成模型输入。下面这段是我常用的骨架,具体类名和参数以模型卡为准,但流程是通用的。

import torch import numpy as np from transformers import AutoModel, AutoProcessor model_id = "your-local-path/Qwen3-VL-Embedding" model = AutoModel.from_pretrained( model_id, trust_remote_code=True, torch_dtype=torch.bfloat16, ).eval().cuda() processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) @torch.no_grad() def embed(texts=None, images=None, instruction=None): if instruction and texts: texts = [f"{instruction}\n{t}" for t in texts] inputs = processor( text=texts, images=images, return_tensors="pt", padding=True, truncation=True, max_length=2048, ).to("cuda") out = model(**inputs) hidden = out.last_hidden_state # [B, L, D] mask = inputs["attention_mask"].unsqueeze(-1).to(hidden.dtype) pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-6) return pooled.float().cpu()

关键点有三个:一是torch_dtype用 bf16 跑推理省显存,但统计数值的时候记得转回 fp32;二是 mask 一定要乘,clamp(min=1e-6)是防止极端情况除零;三是truncation要设,视觉 token 加上长文本很容易超长,超长之后被截掉的是哪一头,会影响最终取值。

3.2 把统计量打出来看

拿到向量之后先别急着算相似度,先看看它长什么样。下面几行能把关键统计量一次打全。

def stat(name, v): v = v.numpy() print(f"--- {name} ---") print(f"dim : {v.shape[-1]}") print(f"norm : {np.linalg.norm(v):.4f}") print(f"mean : {v.mean():.6f}") print(f"std : {v.std():.6f}") print(f"min : {v.min():.4f} max: {v.max():.4f}") print(f"abs>0.1 ratio: {(np.abs(v) > 0.1).mean():.4%}") stat("纯文本-短", embed(["窗台上趴着一只橘猫"])[0]) stat("纯文本-长", embed(["窗台上趴着一只橘猫,阳光从纱窗透进来," "它半眯着眼睛,尾巴尖有一搭没一搭地晃"])[0]) stat("图片", embed(images=[pil_img])[0]) stat("图文对", embed(texts=["一只橘猫趴在窗台上"], images=[pil_img])[0])

跑完之后你会看到几个典型现象。第一,未归一化向量的 norm 基本都是几十这个量级,纯文本短句和图文对的 norm 可能差出一截。第二,mean 非常接近 0,std 在 0.01 到 0.05 之间,min/max 通常在 ±0.3 以内,绝大多数分量的绝对值小于 0.1。第三,abs>0.1 ratio这个指标很能说明问题,它反映能量集中程度,图文对的这个比例往往比纯文本更低,因为视觉 token 多,平均之后更"平"。

这些数字初看会让人觉得"这向量是不是坏了,怎么全是零点零几"。其实这是高维空间的正常现象。d 维单位球面上均匀分布的随机向量,单个坐标的标准差约等于 1/√d。4096 维的话就是 1/64,差不多 0.0156。你观察到的 std 在这个量级,说明向量分布是健康的,没有塌缩也没有爆炸。

3.3 维度能量分布与"死维度"

比全局统计更有意思的是看逐维能量。把一批样本的向量堆成矩阵,按维度算方差,然后画个排序图(这里用打印代替画图,效果一样)。

batch = embed(texts=corpus_texts) # [N, D] var_per_dim = batch.var(dim=0).numpy() idx = np.argsort(-var_per_dim) print("top10 维度方差:", var_per_dim[idx[:10]].round(5)) print("bottom10 维度方差:", var_per_dim[idx[-10:]].round(6)) print("方差中位数:", np.median(var_per_dim).round(6))

健康的情况下,方差前 10 的维度和后 10 的维度能差一到两个数量级,这是正常的——总有一部分维度承载主要区分信息。但如果发现大量维度方差接近 0,比如超过三成的维度方差小于 1e-8,那就要警惕了:可能是池化写错了,可能是 padding 污染,也可能是模型加载时权重没对上。 我遇到过一次"死维度"占了四成,最后查出来是 processor 的 padding 方向和模型的 attention_mask 语义对不上,用错了 padding side,导致 mask 全落在错误的位置。

另一个值得看的指标是跨模态的维度能量差异。把纯文本样本和纯图片样本分别算一遍逐维方差,然后看两者的比值。如果某些维度在图片侧方差极大、文本侧几乎为 0,那这些维度大概率是视觉专属的,做纯文本检索时它们贡献的全是噪声。这个观察对后面调阈值很有用。

3.4 精度与量化对取值的实际影响

bf16 和 fp32 的差异,从统计量上看几乎看不出来,norm 差在小数点后三四位,余弦相似度的差异通常在 1e-4 量级。日常检索完全够用。但有两个场景要注意:一是做向量聚类、需要判断"到底是不是同一个点"的时候,这个量级的抖动可能让边界样本反复横跳;二是做去重,阈值卡得极紧(比如 0.9999)时,精度差异会直接影响结果。

量化到 int8 是另一回事。量化的本质是把连续的取值映射到 256 个桶里,代价是分辨率。对单条向量来说,norm 会有可见偏移,方向也会有轻微旋转。我实测过 int8 量化后余弦相似度的变化,同一条 query 对同一批候选,排序的前十名基本不变,但相似度绝对值会整体偏移一点,第十名到第五十名之间的顺序会有零星互换。所以如果你的业务依赖绝对阈值(比如"相似度大于 0.75 就认为是重复"),换量化方案之后阈值必须重新标定,不能沿用。

还有一个容易忽略的点:有些服务接口返回向量时会做小数位截断,比如只保留六位。这个截断对 norm 的影响微乎其微,但如果你的向量做过归一化,norm 本该严格等于 1,截断之后会变成 0.999999 之类的值。做严格相等判断的代码会在这种地方翻车,记得用容差比较。

4. 取值差异往下游传导:检索环节的真实麻烦

4.1 余弦和点积,到底该用哪个

这个问题看似基础,但在多模态场景里特别容易出错。余弦相似度只看方向,点积同时看方向和长度。如果入库前做了 L2 归一化,两者等价;如果没做,结果会差很远。

我的建议是统一做归一化,理由不是"归一化更好"这种空话,而是因为多模态输入的向量长度天然不一致。纯文本短句的 norm 和一张高分辨率图片的 norm 可能差好几倍,不归一化的话,点积检索会系统性地偏向某一边。归一化之后所有向量都落在单位球面上,比较的就是纯粹的方向差异,跨模态才公平。

归一化之后还有个小细节:向量取值区间变成 [-1, 1],但实际绝大多数分量挤在 ±0.05 以内。这时候你用 float32 存是浪费,用 float16 存完全够,甚至 int8 量化后的精度损失在归一化向量上也更可控,因为动态范围被压缩了。这一点在向量库容量上亿的时候很实在,存储能省一半以上。

4.2 阈值不能拍脑袋,得先测分布

我在很多项目里看到阈值是这么定的:找两条明显相似的样本,算出来 0.87,那就把阈值定成 0.85。这个方法在小规模验证时看起来没问题,上线之后误召回一大堆。原因很简单,你没测过"不相似"的那一端长什么样。

正确做法是采一批负样本对,算相似度分布,取 P99 或者 P99.5 作为初筛阈值,再往上加一点安全边际。下面这段就是干这个的。

def cosine_matrix(a, b): a = torch.nn.functional.normalize(a, dim=-1) b = torch.nn.functional.normalize(b, dim=-1) return a @ b.T neg_sims = cosine_matrix(embed(texts=query_set), embed(texts=unrelated_set)) vals = neg_sims.flatten().numpy() print("负样本 cosine 分布:") for p in [50, 90, 95, 99, 99.9]: print(f" P{p}: {np.percentile(vals, p):.4f}") print("最大值:", vals.max().round(4))

跑出来通常是这样:中位数在 0.02 到 0.08 之间,P99 在 0.2 左右,最大值偶尔能摸到 0.35。也就是说,随便两条不相干的中文短句,余弦相似度也可能到 0.2。你要是把阈值定在 0.15,那基本等于全放行。

这里有个理论解释值得记住:高维空间里,两个独立随机单位向量的内积标准差约等于 1/√d。4096 维时是 0.0156,看起来很小,但嵌入向量不是均匀随机分布的,存在各向异性——所有向量会挤在空间的一个锥形区域里,导致无关对的相似度整体上抬。所以实际观察到的 0.05 到 0.15 的中位数,是这个原因造成的,不是模型有问题。

4.3 跨模态检索的系统性偏差

真正麻烦的地方在于:文本对文本的相似度分布,和文本对图片的相似度分布,不是一个形状。前者通常更"发散",相似的对能到 0.7 以上,不相似的落在 0.1 附近;后者往往整体被压缩,相似的对可能只有 0.5,不相似的在 0.2 附近,分布窄得多。

这就导致一个很实际的问题:如果你用同一个阈值同时处理文文检索和文图检索,必然有一边效果不好。我一般的处理是分模态各标一套阈值,或者更省事的做法——对每一路检索先做分数归一化(比如减去该路的均值再除以标准差),把不同路的分数拉到可比的尺度上,再做融合排序。

还有一个隐蔽的坑:图片的尺寸和长宽比会显著影响向量取值。同一张图,原图直接编码、缩放到 512 编码、中心裁剪后再编码,三种方式得到的向量余弦相似度可能只有 0.85 到 0.95。如果入库阶段和查询阶段的预处理不一致,等于人为制造了 5% 到 15% 的相似度损失。这类问题在离线评估时不容易发现,因为评估集的图片往往已经统一处理过,上线之后线上图片千奇百怪,问题才暴露。

5. 常见问题与排查速查表

5.1 典型现象对照着查

下面这张表是我这几年攒下来的,遇到问题先从左边找现象,再顺着右边往下查。

现象高概率原因排查动作
短 query 检索效果明显差于长 querypadding 参与了池化,norm 被稀释打一条短样本的 norm,和同内容长样本对比,差距超过 30% 就查 mask
图文检索召回极低,文文检索正常两路相似度分布尺度不同,阈值一刀切分别打两路的负样本分布,看 P99 差距
换了指令前缀后效果断崖下跌入库和查询前缀不一致抽查库内向量重编码,对比新旧余弦
相似度普遍偏高,什么都像向量未归一化,长度主导了点积检查 norm 是否接近 1
向量里大量维度方差接近 0池化错位或权重加载异常打印逐维方差,看死维度占比
同一输入两次编码结果不一致精度或 batch 内 padding 长度变化固定 batch 和 max_length 再测
量化后阈值全部失效量化导致相似度整体偏移用同一批样本重新标 P99

5.2 我自己踩过的三个坑

第一个坑是 max_length 设太小。为了省显存我把截断长度设成了 512,结果长文档被砍掉大半,向量只代表了开头那一段。表现是"长文档检索召回很低,但人工看内容明明相关"。后来把长度放到 2048,同时改成按语义分块,问题才解决。教训是:截断长度这个参数,一定要结合你的实际输入长度分布来定,先统计一下语料的 token 长度分位数,P95 是多少就至少设到多少。

第二个坑是批次内 padding 引起的数值抖动。同样的输入,单独编码和放在一个长样本后面一起编码,得到的向量余弦相似度是 0.9998 而不是 1.0。这个差异本身无害,但我当时在做去重,阈值卡在 0.9999,结果同一份内容被判定成两条。后来改成用容差比较,并且去重场景下强制单条编码,问题消失。

第三个坑最隐蔽:图片预处理用了训练时的标准归一化参数,但我的输入图片有些是带透明通道的 PNG,转 RGB 的时候透明区域被填成了黑色,导致同一张图在不同通道模式下编码出来的向量差很多。表现是"部分图片检索不出来"。这个问题的排查成本很高,因为前一百张测试图都是 JPEG,完全正常。后来是抽样打印图片的通道数和尺寸分布才发现的。现在我在预处理环节会强制统一转 RGB 并打日志,图省事的代价太大了。

提示:上线前一定做一次全链路一致性检查——入库和查询用同一个预处理函数、同一套池化参数、同一个指令前缀、同一个精度。这四项里任何一项不一致,都会表现为"效果莫名其妙地差",而且很难从指标上看出来。

5.3 关于维度截断的一点补充

有些模型支持 Matryoshka 式的可变维度输出,前若干维就能保留大部分信息。用这个特性可以大幅降低存储和检索成本,但取值形态会变。截断到 256 维之后,向量 norm 会明显变小(因为丢掉了尾部维度的贡献),如果不重新归一化,相似度会整体偏低。我的做法是截断之后强制再做一次 L2 归一化,然后重新标阈值。实测在 512 维截断时,检索效果和全维度的差距很小,256 维时开始能感觉到召回下降,具体拐点跟你的数据领域强相关,得自己测。

另外提醒一句,截断维度的顺序不能随便打乱。前 N 维之所以能代表整体,是因为训练时就这么设计的,你要是自己按方差排序重排一遍再截断,等于破坏了模型学到的结构,效果会崩得很难看。

6. 工程落地时我实际怎么配

把上面这些串起来,说一下我现在的默认配置,供参考。池化用带 mask 的 mean pooling,归一化在服务端统一做,入库前和查询前都做一遍,双保险。存储用 float16,容量紧张时上 int8 量化,但量化上线前必须重新标阈值。指令前缀全链路统一,写进配置文件而不是散落在代码里。图片预处理固定成转 RGB、按长边缩放、保持长宽比、空白处用边缘像素填充,避免黑边干扰。检索时文本路和图片路各有一套阈值,融合排序前先做分数标准化。

还有一个习惯我觉得挺值:每次换模型版本或者改预处理逻辑,先跑一遍固定的回归集,打印三组数字——负样本相似度的 P99、正样本相似度的 P10、以及两者的间隔。这三个数字能在一分钟内告诉你这次改动是变好了还是变坏了,比看整体召回率直观得多。回归集不用大,两三百对就够,但一定要覆盖你的真实场景分布,别全是精挑细选的干净样本。

最后再分享一个小技巧。排查向量问题时,别一上来就看相似度,先看单条向量的 norm 和 std。这两个数字异常,后面所有相似度计算都没有意义。我现在的调试脚本第一行永远是打印 norm,已经成了肌肉记忆。看起来很笨,但确实省下了大量绕弯路的时间。