Nemotron 3 Embed 做中文语义检索,向量维度与相似度阈值先定下来

文章导读
拿到一个中文 embedding 模型,先别急着建索引。Nemotron 3 Embed 这类模型的输出维度、是否已做归一化、相似度分数落在什么区间算命中,这三个参数决定后面所有检索结果是否可复现。建议顺序是:用一条真实中文 query 送进模型,打印返回向量的 shape 与 dtype;用 L2 范数判断输出是否已经归一化;再用同一批中文正负样本对把阈值标出来;最后固定截断策略,把维度、归一化
📋 目录
  1. A 把一条中文 query 送进模型,打印输出向量的形状
  2. B 用范数判断输出是否已归一化
  3. C 用同一批中文样本标定相似度阈值
  4. D 固定截断策略后重跑一遍看分数是否稳定
  5. E 把维度、归一化、阈值写进同一份检索配置
A A

拿到一个中文 embedding 模型,先别急着建索引。Nemotron 3 Embed 这类模型的输出维度、是否已做归一化、相似度分数落在什么区间算命中,这三个参数决定后面所有检索结果是否可复现。建议顺序是:用一条真实中文 query 送进模型,打印返回向量的 shape 与 dtype;用 L2 范数判断输出是否已经归一化;再用同一批中文正负样本对把阈值标出来;最后固定截断策略,把维度、归一化开关、阈值写进同一份配置,让索引侧和查询侧读同一份设置。凡是打印结果与模型说明不一致的,以实际输出为准。

中文语义检索的参数应当先固化再谈召回:用一条中文 query 打印输出向量的 shape 与 dtype 确认维度,用 L2 范数判断是否需要手动归一化,用一批自建中文正负样本对统计分数分布后再取阈值,最后固定截断长度并把维度、归一化、阈值写进同一份检索配置。适用场景是本地或私有化部署的向量检索;边界是这些参数与具体权重、语言分布、文本长度强相关,换模型或换语料需要重新标定,不能直接沿用旧阈值。

把一条中文 query 送进模型,打印输出向量的形状

不同封装返回的东西差别很大:有的直接给 numpy 数组,有的包一层 dict,有的走 HTTP 返回 JSON。所以第一步不是相信文档上的维度,而是把返回值原样打出来。下面是一段通用接入骨架,替换掉加载模型和推理那一行即可,跑在本地脚本或 notebook 里都行。

import numpy as np

# model 已按你手里的方式加载好,这里只关心它的返回值
def embed(texts):
    out = model.encode(texts)   # 也可能是 model.embed(texts) / 调用 HTTP 接口
    return out

vecs = embed(["报销流程怎么走"])
arr = np.asarray(vecs)
print("raw type:", type(vecs))
print("shape:", arr.shape)
print("dtype:", arr.dtype)
print("head:", arr.reshape(-1)[:5])

要记录的有三项:shape 的最后一维就是实际向量维度,单条输入时通常是 (1, dim) 或 (dim,);dtype 常见是 float32,若打印出 float16 或 bfloat16,建索引前建议转成 float32,否则距离计算容易在累加时掉精度;前几个数值用来确认不是全零或全 NaN。把这三项连同模型名、权重版本、输入文本一起记进配置或日志,后面维度对不上时可以先翻这条记录,而不是回头猜。

用范数判断输出是否已归一化

很多检索分数忽高忽低,真正的原因不是阈值没调好,而是重复归一化:模型输出已经是单位向量,你又除了一次范数,或者反过来,模型没归一化而你直接拿点积当余弦相似度用。判断方法很简单,算 L2 范数看是否接近 1。

norms = np.linalg.norm(arr, axis=-1)
print("l2 norm:", norms)

NORM_EPS = 1e-3
is_normalized = np.all(np.abs(norms - 1.0) < NORM_EPS)
print("already normalized:", is_normalized)

判定口径建议:对所有样本的范数与 1 的偏差都小于 1e-3,就认为输出已归一化。两个分支处理不同。若已归一化,检索时直接用点积或余弦公式都可以,不要再手动除范数,索引侧也不要做二次归一化。若未归一化,索引写入前统一做一次归一化并落库保存归一化后的向量,查询侧对 query 做同样处理,两边必须对称。分不清时最稳的做法是:无论模型是否归一化,都在自己的管道里显式归一化一次并把这个开关写进配置,避免依赖模型默认行为。

Nemotron 3 Embed 做中文语义检索,向量维度与相似度阈值先定下来

用同一批中文样本标定相似度阈值

阈值不该拍脑袋定 0.7 或 0.8。做法是从自己的语料里构造正负样本对:正例是同一问题的不同问法,比如「报销流程怎么走」对「费用报销要走哪些步骤」;负例是字面接近但语义无关的句子,比如「报销流程怎么走」对「年假申请流程怎么走」。中文短句尤其容易在负例上拿到不低的分数,所以负例里要刻意放同领域、同句式的干扰项。

def cos(a, b):
    a = a / (np.linalg.norm(a) + 1e-12)
    b = b / (np.linalg.norm(b) + 1e-12)
    return float(a @ b)

pairs = [(q, p) for q, p in positive_pairs] + [(q, n) for q, n in negative_pairs]
vecs = embed([t for pair in pairs for t in pair]).reshape(len(pairs), 2, -1)
scores = [cos(v[0], v[1]) for v in vecs]
print("min/median/max:", np.min(scores), np.median(scores), np.max(scores))

统计完按样本类型分桶看分布,模板如下,数值一列留空由你自己的脚本填。

样本类型对数最小分中位分最大分备注
短句正例(≤15 字)同义改写
长句正例(≥60 字)含背景描述
短句负例同领域干扰项
长短混合负例长度差异大

取阈值的依据是正负分布之间有没有间隔:如果正例的低分位明显高于负例的高分位,就在这段间隔里取偏保守的一侧;如果两个分布叠在一起,说明这个阈值区分不了你的语料,应当先补负例、检查截断,再考虑换模型,而不是继续调数字。长短句分数尺度不同是常态,必要时给短句和长句各配一个阈值。

Nemotron 3 Embed 做中文语义检索,向量维度与相似度阈值先定下来

固定截断策略后重跑一遍看分数是否稳定

分数抖动还有一个常见来源:同一个 query 在不同调用里被截断到了不同长度,或者长文档在入库时被截断、查询时没截断。先把截断方式写死——按 token 数截断而不是按字符数,保留开头,超出部分丢弃或分段入库后取最大值,这两种策略选一种固定下来。

MAX_TOKENS = 256

def truncate(text, tokenizer, max_tokens=MAX_TOKENS):
    ids = tokenizer(text, truncation=True, max_length=max_tokens)["input_ids"]
    return tokenizer.decode(ids, skip_special_tokens=True)

long_text = "...一段超过截断长度的中文正文..."
q = "报销流程怎么走"
s_short = cos(embed([q]).reshape(-1), embed([long_text]).reshape(-1))
s_trunc = cos(embed([q]).reshape(-1), embed([truncate(long_text, tokenizer)]).reshape(-1))
print("before/after truncate:", s_short, s_trunc)

把截断前后的分数记在一起,同一 query 连跑两次做对照。如果两次结果差在小数点后若干位以内,说明流程稳定;如果同一条输入两次分数就不一致,先查输入是否被随机截断、是否混入了特殊 token,再查是否有 padding 参与了池化。这些都属于可通过日志复现的问题,不需要靠调阈值掩盖。

把维度、归一化、阈值写进同一份检索配置

索引侧和查询侧各写一套参数,是后续最难排查的一类问题。建议用一份配置承载全部参数,两侧都从这份配置读。

{
  "embed_model": "nemotron-3-embed",
  "dim": 1024,               // 以第一步打印的 shape 为准填写
  "dtype": "float32",
  "normalize_output": false, // 按第二步的范数判定结果填写
  "truncate_max_tokens": 256,
  "similarity": "cosine",
  "threshold": 0.0,          // 按第三步的分布标定后填写
  "threshold_short": 0.0
}

加载顺序建议固定为:先读配置,再加载模型,用配置里的 dim 和 dtype 校验模型实际输出,不一致直接报错退出,而不是静默继续;然后按 normalize_output 决定是否在写入前归一化;最后检索时读 threshold。验证方式是同一 query 连查两次,比对返回的 id 列表和分数:两次完全一致说明链路没有随机因素,分数有微小差异属于浮点运算允许范围,但如果排序发生变化,就要回看是否某处仍在做隐式归一化或动态截断。配置变更时保留旧值并写明改了什么,下次分数异常时有据可查。