1. 向量取值差异的来源拆解
1.1 同一个模型,为什么两次导出的数看着不一样
我最早注意到这个问题,是把一个多模态视觉语言嵌入模型(下文统一简称 VL-Embedding,指的是那类同时吃图像和文本、输出统一维度向量的模型)接进检索服务之后。当时为了省事,预处理写了两套:离线建库那套走的是图像库直接解码,在线查询那套走的是另一条读取链路。上线第一天就发现,同一张图在两边算出来的向量,余弦相似度只有 0.93 左右,而余弦相似度低于 0.95 在我当时的阈值下就等于"两张不同的图"。排查了半天模型、版本、权重校验和,最后定位到的差异是:两条链路对 JPEG 的解码结果差了 1 到 2 个像素值,加上缩放插值算法一个是双线性一个是双三次。就这么点差别,经过十几层视觉编码器放大,最终体现在 2048 维向量上就是每个维度零点零几的偏移。
这件事说明一个基本事实:多模态嵌入的向量取值不是模型权重的确定函数,而是"权重 + 预处理 + 精度 + 池化 + 后处理"这一整条链路的确定函数。链条上任何一环动了,同一个输入出来的数就不一样。很多人第一次接触这类模型,会默认"输入一样、模型一样、输出就该一模一样",然后在相似度对不上时怀疑模型有问题,其实大部分时候问题出在链路而不是模型本身。
更深一层看,嵌入向量的本质是把一个语义对象压到一个高维球面(或高维空间)上的一个点。模型训练时优化的目标是"语义相近的点靠得近",它从不保证"同一个对象的点每次都落在完全相同的坐标"。这种不确定性在单模态文本模型上不明显,因为文本分词是离散的、确定的;但一旦引入图像,解码、缩放、裁剪、色彩空间转换全都是浮点运算,不确定性立刻被引入。所以我现在的习惯是:凡是涉及跨进程、跨服务、跨时间的向量比较,先把预处理固化成一份可校验的流水线,再谈模型效果。
1.2 取值范围、数值精度与归一化三者的关系
想搞清楚"取值有何不同",得先把三个概念分开:取值范围、数值精度、是否归一化。它们经常被混为一谈,但在工程上完全是三件事。
| 维度 | 典型情况 | 对检索的影响 |
|---|---|---|
| 取值范围 | 原始输出大致落在 -1 到 1 或 -10 到 10 之间,具体取决于最后一层是否有激活和归一化 | 影响能否直接用点积代替余弦 |
| 数值精度 | fp32、bf16、fp16、int8 量化 | 影响向量稳定性与存储成本 |
| 是否归一化 | 模长是否为 1 | 决定相似度度量方式是否可互换 |
我实测过一个 2048 维的这类模型,在 fp32 下未归一化时,向量模长大概在 12 到 18 之间浮动,同一张图重复跑十次,模长波动在 0.01 以内;换成 bf16 之后,模长波动放大到 0.1 上下,但归一化之后再算余弦,差异被压回 1e-4 量级。这个现象挺关键:bf16 的尾数位少,绝对误差被放大,但归一化相当于把所有向量投影到单位球面上,方向信息基本保住了。所以如果你的检索只关心方向(余弦相似度或归一化后的内积),bf16 完全够用;如果你要做向量的加减运算、或者需要保留模长作为某种"置信度",那还是老老实实上 fp32。
归一化这件事还有个容易踩的坑:不同实现里的 epsilon 不一样。有的用 1e-12,有的用 1e-6,遇到极端接近零向量的输入(比如一张纯白图配空文本),归一化结果会差很多。我见过一次线上事故,一批几乎全黑的缩略图算出来的向量模长只有 1e-7 量级,两个不同服务的归一化实现给出的方向完全相反,导致这批图在检索里"到处乱匹配"。后来加了一条规则:模长低于某个阈值直接标记为无效向量,不进索引。
1.3 维度选择背后的取舍逻辑
这类模型通常支持 Matryoshka 式的可变维度输出,也就是取前 N 维仍然是一个可用的向量,只是表达力下降。这个设计对工程非常友好,因为你可以用一份空间部署多种粒度的索引:粗排用 256 维,精排用 2048 维。但"取前 N 维"这件事本身就会改变取值分布——前若干维往往承载了更粗粒度的语义(类似主成分),后面的维度承载细节。我做过一组对照,用 128、256、512、1024、2048 五档维度在同一份图文数据集上跑召回,结果大致是:
- 128 维:召回率约为满维的 82%,但索引体积只有 1/16,延迟极低;
- 256 维:约为满维的 90%,是我最常用的粗排档位;
- 512 维:约为满维的 96%;
- 1024 维以上:收益递减,基本和 2048 维持平。
这里有个反直觉的点:降维后的向量必须重新归一化。因为截断前 N 维之后模长不再是 1,如果直接拿去算内积,相似度会整体偏低,和满维向量混在一起比较时会出现系统性的偏置。我第一次做混合索引的时候就忘了这一步,粗排和精排的分数完全没法对齐,调了两天才反应过来是模长没处理。
2. 输入侧与池化侧的取值影响
2.1 图像分辨率与切图策略带来的偏移
图像侧的差异比文本侧大得多,核心原因在于模型看到的不是"一张图",而是一组 patch。现在这类模型普遍支持动态分辨率,也就是按原始宽高比把图切成若干块固定大小的 patch,再送进视觉编码器。同一张 4000×3000 的原图,你直接缩到 448×448 送进去,和按原图比例切成一堆小块再送进去,得到的向量完全是两个东西。前者丢掉了几何细节只保留整体语义,后者保留了局部但可能引入切块边界的断裂感。
我做过一组很朴素的实验,用同一张商品图(白底、居中的鞋子)在四种预处理下取向量,然后和一句描述文本"一双白色的运动鞋"算跨模态相似度:
| 预处理方式 | 与目标文本的相似度 | 与干扰文本("白色运动袜")的相似度 | 差值 |
|---|---|---|---|
| 直接缩放到 448 正方形 | 0.612 | 0.548 | 0.064 |
| 保持比例短边 448,灰边填充 | 0.648 | 0.531 | 0.117 |
| 保持比例短边 768,灰边填充 | 0.671 | 0.529 | 0.142 |
| 动态切块,最多 4 块 | 0.679 | 0.524 | 0.155 |
表格里"差值"那一列才是真正该关注的指标。绝对相似度的数值在不同预处理之间浮动很大,但区分度(目标与干扰的差距)是稳定上升的。这也解释了一个常见困惑:有人发现换个输入尺寸之后所有相似度都变了,以为模型不稳定,其实变的是绝对刻度,排序关系没怎么动。只要你的应用是基于排序的(召回、重排、聚类),预处理变了之后重新标定阈值就行,不必重训模型;但如果你的应用依赖绝对阈值做判定(比如"相似度大于 0.8 判为同一商品"),那预处理绝对不能随便改。
填充策略这里还有个细节:灰边填充的灰色值用 114 还是 127,看起来差别微不足道,但在白底商品图上,灰边会被视觉编码器当成"背景区块"参与注意力计算。我用 0(纯黑)填过一次,结果白底图整体被拉暗,跨模态相似度掉了将近 0.05。后来统一用中性灰,并且在切图前先做一次背景检测,纯色背景的直接裁掉留白,效果比填充稳得多。
2.2 指令前缀把向量推进了不同的子空间
文本侧的差异主要来自指令前缀。这类模型为了支持多种任务,通常会给不同用途的输入加不同的指令模板,比如检索场景用一套、分类场景用一套、图文匹配用另一套。关键在于:加了不同指令的文本,算出来的向量不在同一个子空间里,跨指令比较是没有意义的。
我踩过一次很典型的坑。建库的时候,文本侧统一加的是"为检索生成表示"这类指令;做查询的时候,因为复用了一段早期写的匹配代码,指令是"判断图文是否匹配"。结果就是所有查询的相似度都在 0.5 到 0.6 之间挤成一团,排序几乎是随机的。原因很直白:两批向量被模型映射到了两个不同的区域,它们之间的余弦值不携带任何语义信息。改成统一指令之后,同一批查询的相似度分布立刻拉开到 0.3 到 0.85 的区间。
所以我在任何项目里的第一条规矩是:指令模板必须和模型版本一起写进配置文件,并且带哈希校验。查询侧和索引侧读到不一致的指令哈希,直接报错而不是静默跑下去。这条规矩听着小题大做,但它拦下的问题比任何调参都多。
2.3 池化位置决定向量的"重心"
再往下就是池化。这类多模态模型输出的是一串 token 级隐藏状态,要把它们压成一个向量,常见做法有三种:取最后一个 token、取所有 token 的平均、取一个专门的可学习聚合 token。三种做法出来的向量分布差别相当大。
最后一个 token 的特点是信息集中,它经过注意力机制汇聚了全局信息,但容易受序列末尾内容影响。平均池化的特点是平滑,对噪声鲁棒,但会把关键信息和填充 token 混在一起,如果没做好 mask,填充部分会严重稀释向量。专用聚合 token 是训练时就优化过的,通常效果最好,但也最依赖训练时的对齐质量。
我在一个图文混合数据集上对比过前两种(第三种需要模型本身支持,不是所有版本都有)。用平均池化时,短文本和长文本的向量模长差异明显,长文本因为 token 多、平均后更"平",模长偏小;用最后 token 时模长稳定得多。这直接影响了一个场景:如果库里既有短标题又有长描述,用平均池化会让长描述的向量在归一化前就处于劣势,归一化之后虽然模长拉平了,但方向被大量泛化 token 拉向"平均语义",导致长描述的检索精度下降。我的经验是:描述长度分布不均的数据集,优先用最后 token 或专用聚合 token;长度分布均匀、内容同质的场景,平均池化更稳。
还有一个容易被忽略的点:图像侧的 token 数量和文本侧的 token 数量通常差一个数量级。如果做跨模态的平均池化,把图像 token 和文本 token 放在一起平均,等于让文本被图像淹没。所以跨模态场景下,池化一定要分模态做,各取各的聚合表示,再在公共空间里比较。
3. 后处理环节的取值改造
3.1 归一化、白化与减均值的区别
归一化是把模长拉成 1,白化是把各维度的协方差拉成单位阵,减均值是把分布中心移到原点。这三件事经常被笼统地说成"后处理",但作用完全不同。
归一化只改模长不改方向,是最安全的一步,几乎所有检索场景都该做。白化需要估计整个数据集的均值和协方差,能显著缓解"所有向量都挤在超球面一小块区域"的问题——这个现象在多模态模型上很常见,因为训练数据分布集中,导致向量方向高度聚集,任意两条之间的余弦值都在 0.6 以上,区分度很差。我统计过一次,未做任何后处理时,随机抽取一万对图文向量,余弦相似度的中位数是 0.58,标准差只有 0.09;做过白化(用十万条样本估计统计量)之后,中位数降到 0.12,标准差涨到 0.21,区分度肉眼可见地变好。
但白化有风险:统计量必须和查询分布一致。你用一个品类的数据估出来的均值方差,套到另一个品类上,可能把有用信息也抹掉。所以我的做法是按大类分别估计统计量,或者干脆只在粗排阶段用白化、精排用原始向量。减均值比白化温和,通常和白化一起做,单独用效果有限。
3.2 量化对取值的影响到底有多大
向量库存储是大头。2048 维 fp32,一百万条就是 8GB 左右,加上索引结构轻松上到 20GB。所以量化几乎是必选项。我系统性对比过几种精度下的表现,用的是同一份十万条图文数据、同一套查询集:
| 精度 | 单条字节数 | 召回率相对 fp32 | 备注 |
|---|---|---|---|
| fp32 | 8192 | 100%(基准) | 建库慢,内存吃紧 |
| fp16 | 4096 | 99.7% | 几乎无损,性价比最高 |
| int8 对称量化 | 2048 | 98.5% | 需要按维度算 scale |
| int8 标量量化 | 2048 | 97.2% | 单 scale,实现简单 |
| 二值化 | 256 | 76% | 只能做粗筛,必须配精排 |
结论很清楚:fp16 是无脑选择,int8 按维度量化是性价比拐点,二值化只适合超大规模的第一层粗筛。int8 按维度量化的实现要点是每一维单独统计最大值,因为不同维度的数值范围差别很大,用一个全局 scale 会把小范围的维度全部压成零。我第一版就是用了全局 scale,结果召回率掉到 89%,换成按维度 scale 才回到 98.5%。
还有一个隐蔽的问题:量化后的向量不能再做归一化。int8 归一化需要浮点运算,等于又变回浮点。正确做法是先归一化再量化,检索时用整数点积加 scale 还原,或者直接用查表法算近似余弦。这个顺序搞反了,分数会完全失真。
4. 完整实操流程与关键参数
4.1 环境准备与预处理固化
先把预处理固化下来,这是所有后续对比的前提。我习惯把预处理写成一个独立模块,输入是原始字节流,输出是固定尺寸的张量,中间每一步都可配置、可打印、可校验。
import hashlib import io import numpy as np from PIL import Image PREPROCESS_CONFIG = { "short_side": 768, "max_blocks": 4, "pad_value": 114, "resample": "bicubic", "color_space": "RGB", } def load_and_prepare(raw_bytes, cfg=PREPROCESS_CONFIG): img = Image.open(io.BytesIO(raw_bytes)) if img.mode != cfg["color_space"]: img = img.convert(cfg["color_space"]) w, h = img.size scale = cfg["short_side"] / min(w, h) new_w, new_h = int(round(w * scale)), int(round(h * scale)) img = img.resize((new_w, new_h), resample=Image.BICUBIC) arr = np.asarray(img, dtype=np.float32) / 255.0 return arr这段代码本身没什么特别,重点在于配置字典要跟着向量一起落库。我在每条向量记录里存三个字段:模型版本号、权重文件哈希、预处理配置哈希。后来有一次做 A/B 实验,两套配置混在一个索引里,靠这三个字段才把问题数据筛出来重算,否则根本无从下手。
预处理还要注意一点:不要用两套不同的图像库做同一件事。PIL 和另一套常见图像库在 JPEG 解码和重采样上的结果是有差异的,尤其是缩小操作的滤波核不一样。我现在的做法是全链路只允许一个图像库,如果某个环节非要用别的库,那就在边界处转成无损的 PNG 或原始数组,绝不让压缩格式跨库传递。
4.2 向量提取脚本与关键参数
下面是提取侧的核心逻辑。真实项目里我会把它包成服务,但核心就这几行。注意几个参数:批大小、精度、是否归一化、池化方式。
import numpy as np def l2_normalize(x, eps=1e-8): norm = np.linalg.norm(x, axis=-1, keepdims=True) return x / np.maximum(norm, eps) def extract_embeddings(encoded_inputs, infer_fn, normalize=True, dim=None): raw = infer_fn(encoded_inputs) # 返回 (batch, hidden) raw = np.asarray(raw, dtype=np.float32) if dim is not None: raw = raw[:, :dim] # Matryoshka 截断 if normalize: raw = l2_normalize(raw) return raw def check_health(vectors): norms = np.linalg.norm(vectors, axis=-1) dup_rate = 1.0 - len(np.unique(np.round(vectors, 5), axis=0)) / len(vectors) return { "norm_mean": float(norms.mean()), "norm_std": float(norms.std()), "near_zero": int((norms < 1e-4).sum()), "dup_rate": float(dup_rate), }check_health这个函数看着不起眼,但它救过我两次。一次是空文本被当成有效输入,产生了大量几乎相同的零向量,dup_rate直接飙到 0.4;一次是某批图片全部解码失败返回了纯黑图,norm_mean异常偏低。这两个问题如果只看最终检索效果,表现都是"某些查询召回很差",极难定位。建库前跑一遍健康检查,成本几秒钟,能省掉几天排查。
批大小也值得说一句。批大能提高吞吐,但批内不同长度的序列会被 padding 到最长,padding 部分参与注意力计算(即使有 mask,浮点运算的舍入误差也会累积),导致同一输入在不同批大小下得到的向量有微小差异,量级大概在 1e-3。如果你做严格的重复性校验,最好固定批大小,或者干脆逐条推理。生产环境我一般固定批大小,容忍这点误差。
4.3 相似度矩阵怎么读才不误判
拿到向量之后,第一件事不是接向量库,而是打一张相似度矩阵出来看。这是我判断"链路是否正常"的固定动作,比任何指标都直观。
def similarity_matrix(vectors_a, vectors_b=None): a = l2_normalize(np.asarray(vectors_a, dtype=np.float32)) if vectors_b is None: sim = a @ a.T np.fill_diagonal(sim, np.nan) return sim b = l2_normalize(np.asarray(vectors_b, dtype=np.float32)) return a @ b.T def describe(sim): v = sim[~np.isnan(sim)] return { "min": float(v.min()), "p10": float(np.percentile(v, 10)), "median": float(np.median(v)), "p90": float(np.percentile(v, 90)), "max": float(v.max()), }读这张矩阵,我关注三件事。第一,对角线之外的最大值。如果同一批不同样本之间的相似度普遍超过 0.9,说明向量塌缩了,模型没提供区分度,得考虑白化或者换池化方式。第二,分布的宽度。中位数和 p10 的差距太小(比如只差 0.05),说明所有向量挤在一起,检索时排序会非常不稳定。第三,跨模态块的整体偏移。图文相似度矩阵里,如果所有图文对的分数都比图图对比低 0.2 以上,那是正常现象,跨模态本来就比同模态难;但如果高出一截,那可能是指令或者归一化出了问题。
我遇到过最诡异的一次:图片之间的相似度矩阵看起来很漂亮(0.1 到 0.8 分布均匀),文本之间也正常,但图文交叉那块全部集中在 0.0 到 0.05。查了半天发现是文本侧忘了做归一化,模长是 15 左右,点积之后被数值精度吃掉了。跨模态块异常,优先查两侧的归一化状态是否一致,这是最快的一条排查路径。
4.4 降维与量化的组合实验
最后一步是把维度、精度这两个变量组合起来测,找到符合自己成本约束的档位。我的做法是固定一份小的标注集(几百对正样本、几千对负样本),对每个组合算召回率和延迟,画一张二维表。
| 维度 | fp16 召回 | int8 召回 | 存储(百万条) | 相对延迟 |
|---|---|---|---|---|
| 256 | 86.1% | 85.4% | 0.5GB | 0.2x |
| 512 | 92.7% | 91.9% | 1.0GB | 0.4x |
| 1024 | 96.3% | 95.6% | 2.0GB | 0.7x |
| 2048 | 98.1% | 97.3% | 4.0GB | 1.0x |
实际选型就三句话:数据量百万级以下、对精度敏感,用 1024 维 fp16;数据量千万级、要控成本,用 512 维 int8 做粗排加 2048 维 fp16 做精排的两段式;如果只做聚类或者去重这种对绝对精度要求不高的场景,256 维 int8 完全够。
两段式这里有个细节值得强调:粗排用低维向量,精排用高维向量,两段的分数尺度不同,不能直接相加。我的做法是各自归一化到 [0,1] 区间再加权,权重用标注集调出来。直接相加的后果是粗排分数因为分布更宽而压制了精排分数,等于白做了精排。
5. 常见问题与排查技巧
5.1 相似度全部偏高或全部偏低
这两种现象占了我遇到问题的七成以上,且排查路径完全不同。
全部偏高(大量 0.9 以上),本质是向量塌缩。可能原因按概率排序:池化用了平均且没做好 mask,填充 token 拉平了所有向量;指令前缀缺失或者两侧不一致;白化统计量用错了数据集,反而把区分度抹平了;维度截断得太狠,前几十维恰好是最泛化的方向。排查顺序建议从池化开始,因为改动成本最低。
全部偏低(大量 0.2 以下),本质是空间没对齐。常见原因:两侧归一化状态不一致;两侧指令模板不同;模型版本不同(哪怕是小版本升级);图像侧的通道顺序搞错,RGB 和 BGR 互换会让向量偏移极大。通道顺序这个坑特别阴,因为它不报错,只是效果差。我在预处理模块里强制断言通道语义,加了一行注释写明顺序,从此再没犯过。
还有一种介于两者之间的状态:分布看起来正常,但排序完全不靠谱。这种多半是"同模态内正常、跨模态失效",指向的是模型的公共空间对齐问题,可能是文本侧和图像侧用了不同版本的投影头。检查方法是分别算图图和文文的相似度分布,两个分布的形状差异如果超过预期,基本可以确定。
5.2 常见问题速查表
| 现象 | 最可能的原因 | 快速验证方式 | 处置 |
|---|---|---|---|
| 同一输入两次向量不一致 | 预处理链路不同或精度不同 | 固定输入连跑五次看方差 | 固化预处理,统一精度 |
| 跨模态相似度全部低于 0.1 | 两侧归一化不一致 | 打印模长分布 | 统一归一化,加断言 |
| 召回排序随机 | 指令模板不一致 | 比对两侧指令字符串哈希 | 配置化并校验 |
| 新入库数据检索不到 | 索引未刷新或维度不一致 | 查索引元信息 | 重建索引并校验维度 |
| 向量近零导致误匹配 | 空输入或纯色图 | 健康检查看 near_zero | 过滤无效向量 |
| 量化后精度骤降 | 用了全局 scale | 对比按维度 scale | 改为按维度量化 |
| 长文本检索效果差 | 平均池化被填充稀释 | 按长度分桶看召回 | 换最后 token 池化 |
| 换了预处理后阈值失效 | 绝对刻度漂移 | 重算相似度分位数 | 重新标定阈值 |
5.3 我自己总结的几条硬规矩
第一条:任何向量入库前必须过健康检查。模长、零向量数、重复率这三个指标,几行代码的事,能拦掉绝大部分脏数据。
第二条:向量记录必须带完整的溯源信息。模型版本、权重哈希、预处理配置哈希、指令哈希,四个字段缺一不可。后面做增量更新、灰度切换、问题回溯,全靠它们。我见过太多团队只存了向量和 ID,出问题之后连"这条向量是哪个版本算的"都答不上来。
第三条:阈值不要拍脑袋,用分位数标定。具体做法是拿一批负样本对算出相似度分布,取 95% 或 99% 分位数作为阈值。这样阈值会随数据分布自适应,比固定数字靠谱得多。数据分布漂移的时候,重新跑一遍标定脚本就行。
第四条:改动任何一环都要重算全量。预处理改了、池化改了、精度改了,只要影响向量取值,就不能新旧混用。我现在的做法是索引带版本号,新版本建好之后双写一段时间,用查询结果做对比,确认没问题再切流量。混用新旧向量是最容易出事又最难查的操作方式。
第五条:不要迷信绝对值,只看排序和区分度。多模态嵌入的相似度绝对值受太多因素影响,换个预处理、换个精度都能浮动 0.05 以上。真正稳定的信号是相对排序,以及目标与干扰之间的差值。我判断一个配置好不好,从来不看"平均相似度是多少",只看"正样本排在负样本前面的比例是多少"。
最后分享一个我最近才用上的小技巧:把向量按类别分组之后,分别算组内的相似度分布,然后对比组间分布的重叠度。重叠度高的类别,说明模型对它们区分不开,这类类别往往需要额外的业务规则或者换更细粒度的模型来补。这个分析在建库阶段就能做完,比上线之后靠用户反馈发现问题要早得多。