Nemotron 3 Embed 出向量,下游检索按同一套归一化对齐

文章导读
同一段文本在不同环境里检索排序对不上,先别急着换模型或调参。最可能的落点是:模型侧输出的向量本来带范数,某个下游写入链路又做了一次 L2 归一化,而另一条链路没做;也可能两边都做了,但做的不是同一种归一化。判断顺序建议是:先抽样看模型输出向量的范数,再确认向量库建索引时的度量类型和归一化开关,然后用同批 query 做一次对照写入,最后把归一化收敛到一层并写进接入约定,用固定 query 集回归。
📋 目录
  1. 一 抽样查模型输出向量的范数分布
  2. 二 查向量库建索引时的度量与归一化开关
  3. 三 构造双份归一化的对照实验
  4. 四 把归一化统一到一层并写进接入约定
  5. 五 用固定 query 集跑一遍回归
A A

同一段文本在不同环境里检索排序对不上,先别急着换模型或调参。最可能的落点是:模型侧输出的向量本来带范数,某个下游写入链路又做了一次 L2 归一化,而另一条链路没做;也可能两边都做了,但做的不是同一种归一化。判断顺序建议是:先抽样看模型输出向量的范数,再确认向量库建索引时的度量类型和归一化开关,然后用同批 query 做一次对照写入,最后把归一化收敛到一层并写进接入约定,用固定 query 集回归。

归一化只应有一层负责。适用场景:同一批文本在离线建库与在线写入、或两套检索环境之间排序不一致。操作动作:抽样打印模型输出向量的 L2 范数分布,核对索引的度量参数与归一化开关,做一次“原样写入 / 预先归一化写入”的对照。验证方式:看两份 top-k 的 doc_id 序列是否一致,而不是只看分数。风险边界:重复的 L2 归一化本身是幂等的,排序不一致往往来自少做了一次、或两边做了不同的归一化(减均值、按维度缩放、量化前缩放),排查要覆盖这些情况。

抽样查模型输出向量的范数分布

目的是判断模型侧吐出来的到底是裸向量还是已经归一化过的向量。做法是从真实业务文本里抽一批样本,覆盖短句、长句、中英混排和带数字的句子,条数建议 200 条量级,太少看不出分布形态。把每条文本送进 embedding 接口,只取向量本身,不经过任何你自己的后处理,然后把范数打出来。

import numpy as np

def norm_report(vectors, tag='raw'):
    arr = np.asarray(vectors, dtype=np.float32)
    norms = np.linalg.norm(arr, axis=1)
    dim = arr.shape[1]
    print(f'{tag}: n={len(norms)} dim={dim}')
    print('min / median / max:',
          round(float(norms.min()), 6),
          round(float(np.median(norms)), 6),
          round(float(norms.max()), 6))
    print('median / sqrt(dim):', round(float(np.median(norms) / np.sqrt(dim)), 6))
    print('std:', round(float(norms.std()), 6))
    print('head 5:', [round(float(x), 6) for x in norms[:5]])
    return norms

# vectors = [resp['embedding'] for resp in batch_results]
# norm_report(vectors, tag='nemotron-embed-sample')

怎么看结果:如果绝大多数范数贴在 1.0 附近、标准差很小,说明模型侧大概率已经做了 L2 归一化;如果范数随文本长度上下浮动、中位数明显偏离 1,说明拿到的是裸向量。要注意一个容易误判的现象:未归一化向量如果各维分量比较均匀,范数会接近 sqrt(dim),此时别只看“接近某个整数”,要同时看范数的离散程度和与 sqrt(dim) 的比值。把每次抽样的 n、dim、min/median/max、std 记进变更记录,换模型版本时重跑一次同样的脚本做对比。

查向量库建索引时的度量与归一化开关

要确认库侧有没有再动一次向量。看三样东西:索引或集合的度量类型(L2 / 内积 IP / 余弦 COSINE)、是否有独立的归一化或预处理参数、写入路径上有没有你自己代码里的归一化函数。查看方式按具体引擎来,通常是 describe index、读集合 schema、或者直接翻建表/建索引脚本;如果这些查不到,就往写入服务里搜 normalize、l2、unit 这类函数名。

  • COSINE:库通常按角度比较,等价于在内部对两侧做归一化,向量原始长度的差异不进入排序。
  • IP(内积):库不做归一化,向量范数直接参与打分,长文本或高范数文本容易占优。
  • L2:库不做归一化,距离对范数敏感,此时模型侧若已归一化,等于隐含了余弦的行为。

把开关状态和对应的行为写成一句话记下来,例如“索引 metric=IP,未开启额外归一化,写入前由业务代码负责 L2 归一化”。这句话是后面排查分歧的基准,缺了很容易两边各说一套。

Nemotron 3 Embed 出向量,下游检索按同一套归一化对齐

构造双份归一化的对照实验

确认了模型输出范数和索引参数后,用同一批向量做两组写入,看排序到底会不会变。方式 A:模型原始输出直接写入,索引用 COSINE。方式 B:先在代码里做 L2 归一化再写入,索引用 IP。两组数据、索引名、分片设置保持一致,只改这两个变量。

import numpy as np

def l2_normalize(vecs):
    arr = np.asarray(vecs, dtype=np.float32)
    norms = np.linalg.norm(arr, axis=1, keepdims=True)
    norms[norms == 0] = 1.0
    return arr / norms

# 方式 A:直接写入 raw_vectors
# 方式 B:写入 l2_normalize(raw_vectors)

然后用同一批 query(建议 20 条以上,复用第 5 节的 query 集更省事)分别查两个索引,每次取 top-5,把 doc_id 和分数一起记录下来,按下面的表填:

query方式 A top-5(doc_id / score)方式 B top-5(doc_id / score)前 3 位是否一致备注
q1待填待填待填记录首个位置变动
q2待填待填待填记录分数差距变化

记录时以 doc_id 序列为准,分数只作参考,因为不同度量下分数量纲本来就不一样。若两组 top-k 完全一致,说明这层重复的 L2 归一化没有改变排序,问题不在双份归一化上,应转向排查文本预处理、分词、模型版本或索引是否重建过。若不一致,挑差异最大的 2 到 3 条 query 单独看:命中的文档长度是否明显不同、是否含大量数字或专有名词,这些通常是范数偏差被放大的地方。

Nemotron 3 Embed 出向量,下游检索按同一套归一化对齐

把归一化统一到一层并写进接入约定

定下来只在一层做。比较稳的选择是:归一化放在写入侧,模型输出保持裸向量,入库前统一做 L2 归一化,索引用 IP;这样离线建库和在线增量写入走同一个函数,谁也不会漏。另一种是依赖库的 COSINE 度量、业务侧不做任何归一化,前提是确认所有写入路径都走这个索引,中间没有别的缓存层或第二套库。

def embed_for_index(text, model_client, dim=None):
    '''接入约定:
    - 模型输出保持裸向量,不在模型封装层做归一化
    - 归一化统一在写入前执行一次(L2)
    - 索引度量固定为 IP,库侧不再开启归一化预处理
    - 换模型或改维度需同步更新 INDEX_VERSION
    '''
    vec = model_client.embed(text)
    if dim is not None:
        assert len(vec) == dim, f'dim mismatch: {len(vec)} != {dim}'
    return l2_normalize([vec])[0]

约定的落点建议放在“模型封装出口之后、写入调用之前”的这一层,而不是塞进模型封装里面,否则以后有人直接调模型封装时又会拿到归一化过的向量。改动后的检查点:写入服务与离线建库脚本是否调用同一个函数;索引参数是否与新约定一致;向量维度和 INDEX_VERSION 是否写进日志;是否还有旧数据留在按旧约定建立的索引里,需要重建或标注版本。

用固定 query 集跑一遍回归

改造前后要能对比。query 集的构造方式:从检索日志里抽样,覆盖短查询、长句查询、中英混排、含专有名词或产品名的查询,规模 30 至 50 条即可;每条标注 1 至 2 个期望命中的 doc_id,标不出来的就只记录排序,不判定对错。把 query 集和期望结果存成文件,跟着代码走。

def run_regression(queries, search_fn, k=10):
    rows = []
    for q in queries:
        hits = search_fn(q['text'], top_k=k)
        ids = [h['doc_id'] for h in hits]
        rows.append({
            'query_id': q['id'],
            'top_k_ids': ids,
            'expected_in_topk': any(x in ids for x in q.get('expected', [])),
            'first_hit_rank': next((i + 1 for i, x in enumerate(ids)
                                    if x in q.get('expected', [])), None),
        })
    return rows

对照方法:改造前后各跑一次,按 query_id 逐条比对 top-10 的 doc_id 序列,重点看三件事——期望文档是否仍在 top-k 内、首次命中的位置有没有后移、排序变动集中在哪类 query 上。结论记录写清楚“哪些 query 排序发生变化、变化方向是否符合预期、是否有期望文档被挤出 top-k”,并把这些记录和新索引版本号、归一化归属层的说明放在一起,作为这次改造的收尾材料。若出现期望文档被挤出 top-k 的情况,先回退到旧索引,再回到第 3 节的对照实验重查一遍,不要在线上直接调参数试。