判断要不要换嵌入模型,先别只看模型名和公开跑分。Nemotron 3 Embed 这类面向检索训练的模型,与通用嵌入模型的差别通常落在三处能自己验证的地方:可处理的文本长度上限、向量维度带来的存储与检索开销、以及同一批业务 query 下的命中排序。比较顺序建议是:先用自己语料的长度分布筛掉不满足上限的选项,再比较同一批 query 的分数分布,最后用人工判定口径比较命中。三张表拿不出来,就不建议直接切换。
能不能换,取决于三件事:语料长度是否长期贴着当前模型上限、同一批 query 下两个模型的命中排序谁更贴近人工判定、新维度带来的存储与检索开销能否接受。建议先在只读环境用同一份语料和同一批 query 各跑一遍,拿到长度分布、分数分布、命中对照三张表再决定;任何一项缺失,或者向量库不支持新旧索引并存,都先不要动线上索引。
统计手头语料的长度分布,落在哪几档
先量清楚自己真实要处理的文本长度,不要拿模型卡上的最大长度反推。统计对象取线上真实入库的那批文本,比如切块后的 chunk 或标题拼接后的正文,不要用清洗前的原始网页。字符数只能做粗筛,正式统计建议用候选模型自带的 tokenizer 算 token 长度:
# stat_len.py:用模型自己的 tokenizer 统计 token 长度
import json, statistics
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("你的模型路径或名称")
lens = []
with open("corpus.jsonl", encoding="utf-8") as f:
for line in f:
text = json.loads(line)["text"]
lens.append(len(tok.encode(text, add_special_tokens=True)))
lens.sort()
n = len(lens)
print("count", n)
print("p50", lens[n // 2], "p90", lens[int(n * 0.9)], "p99", lens[int(n * 0.99)], "max", lens[-1])
把结果按档位汇总,并把候选模型的 max length 填进最后一列,超限部分就是将来会被截断的部分:
| 区间(token) | 条数 | 占比 | 累计占比 | 超出当前模型上限 | 超出候选模型上限 |
|---|---|---|---|---|---|
| 0–128 | 否 | 否 | |||
| 129–256 | |||||
| 257–512 | |||||
| 513–1024 | |||||
| 1024 以上 |
超长文本占比是这一节的关键数字。占比很低时,对超长部分做切块或抽取关键字段就能解决,长度不构成换模型的理由;占比不低、且被截掉的恰好是表格行或结论句时,上限更高的模型才有实际意义。截断发生在头部还是尾部,按 tokenizer 的 truncation 配置确认,别凭印象。
同一批 query 下两套模型的分数分布对照
不同模型训练目标不同,相似度分数的绝对值和分布尺度往往不可直接比较。可行的做法是在同一批 query 上分别取 top-k,导出分数,看区间和离散度,而不是比谁的分更高。统计方式按 query 维度算 p10、p50、p90 和极差,再看整个集合的分数是否挤在一个很窄的区间里。
# 导出分数后按 query 汇总
import statistics
for qid, scores in result_by_query.items():
scores.sort()
p50 = statistics.median(scores)
spread = scores[-1] - scores[0]
print(qid, round(scores[0], 4), round(p50, 4), round(scores[-1], 4), round(spread, 4))
排序一致性的观察方法更直接:对每条 query 取两个模型的 top-10 文档 id 列表,统计重合条数,并记录顺序是否一致。重合条数高说明两套模型对这批 query 的排序判断接近,换模型的收益主要来自其他方面;重合条数低且分歧集中在特定类型的 query 上,才是需要逐条看的信号。
| query id | 模型 A p50/p90 | 模型 B p50/p90 | 模型 A top5 id | 模型 B top5 id | top5 重合数 | 备注 |
|---|---|---|---|---|---|---|
| q001 | ||||||
| q002 |
看向量维度对存储与检索耗时的影响
维度变化最直接的影响落在存储和索引体积上。原始向量体积按下面的公式估算,字节数按实际使用的数据类型换算:
raw_bytes = N * dim * bytes_per_dim # fp32 取 4,fp16 取 2
index_bytes ≈ raw_bytes * k # k 由索引类型和参数决定,按引擎侧实测
reserve = (raw_bytes + index_bytes) / 1024**3 # 单位 GB
耗时记录要在固定条件下做:固定 top_k、固定索引搜索参数(如 ef_search、nprobe)、固定进程冷热状态,跑若干次取 p50 和 p95,不要只报平均值。
import time
def bench(fn, n=200):
ts = []
for _ in range(n):
t = time.perf_counter(); fn(); ts.append(time.perf_counter() - t)
ts.sort()
return round(ts[len(ts)//2], 4), round(ts[int(len(ts)*0.95)], 4)
两组数据并列写在一张表里,切换成本才看得见:
| 项 | 模型 A | 模型 B |
|---|---|---|
| 向量维度 | ||
| 向量条数 | ||
| 原始向量体积 | ||
| 索引+元数据体积 | ||
| 检索 p50 / p95 | ||
| 全量重建耗时 |
重建耗时容易被忽略:切换模型意味着全量重新编码加重建索引,这段窗口内线上只能继续用旧索引。可行做法是新旧索引并存、按 embedding 版本号分流,验证通过后再切流量;并存期间的资源占用要提前按上面的公式算好。
用真实 query 做命中对照
跑分和业务口径经常对不上,所以要用线上真实 query 加人工标注做对照。判定命中口径要在标注之前定好并写下来,比如“文档中任意一段能直接回答问题即算命中”,明确是文档级还是段落级、取 top-5 还是 top-10。口径中途变更,前面的标注就作废。
| query 原文 | 期望文档 id | 模型 A 排名 | 模型 B 排名 | 命中@5(A/B) | 分歧类型 |
|---|---|---|---|---|---|
分歧样例要逐条记录,不只记数量:query 是长句还是短关键词、是否中英混排、是否含编号或专有名词、期望文档是否超过长度上限被截断。攒到十几条之后,两类模型各自的倾向就会显出来,这比一个总分更有用。
把选择条件收敛成三条判断
三条条件互相独立,任何一条不满足,都不要仅凭另一条的优势做决定。
- 按文本长度:如果 p95 长度已经接近或超过当前模型上限、且截断切掉了关键内容,优先考虑上限更高的模型,并用切块方案做对照实验;如果九成以上文本都在当前上限的一半以内,长度不构成更换理由。
- 按语种与文本形态:先统计中文单语、中英混排、含代码或表格各占多少,再看命中对照的分歧是否集中在这几类上。分歧集中在中文长句或混排场景时,这类样本的命中差异才是决策依据;分歧分散且两模型表现接近,换模型的收益不明确。
- 按部署位置:向量库跑在内存受限的单机或容器里时,维度翻倍会直接压缩可缓存的向量条数,维度就是硬约束;已有独立检索集群、资源有余量时,维度差异通常不是首要因素,可以回到前两条判断。
决定切换前,把 embedding 模型名和版本号写进索引元数据,保留旧索引一段时间可回滚;这属于止损安排,不能当成效果提升的理由。