引言:“换个嵌入模型试试”不叫评测

在 RAG 这条链路上,嵌入模型是最容易被凭感觉替换掉的一环。检索不准的时候,太多人第一反应是“模型不行”,于是换一个更大的、更新的、在某个榜单上更高的模型,再看看感觉有没有变好。这种做法的致命缺陷在于:它没有任何机制告诉我们“新模型更好”这个判断是否成立。感觉这个东西,既不能排除噪声,也不能排除偶然,更无法回答下一次模型迭代时我们究竟变好了还是变差了。

更麻烦的是,嵌入模型的质量从来不是一个可以脱离系统单独观看的属性。同一组向量,在不同的分块方式、不同的索引参数、不同的重排模型、不同的查询表述之下,呈现出来的召回表现可以相差很大。榜单上更高的平均分,换到你自己的语料上,可能既不更快也不更准,甚至在你的查询分布上更差。

所以这篇东西要讨论的不是“哪个模型最好”,而是怎么科学地选一个嵌入模型,并拿出证据证明它真的更好。核心主张有三个:第一,选型必须建立在显式的取舍维度上,而不是综合排行榜名次;第二,任何通用基准都只能用来排除明显不合格的候选,不能用来做最终决策;第三,你的业务评测集才是唯一的裁判,而它的质量上限决定了整个评测体系的质量上限。

全文按这个顺序展开:先讲选型维度(维度、语种、文本形态),再讲通用基准能干什么、不能干什么,然后进入任务级评测、评测集构造、重排协同、微调边界、索引参数遮蔽效应,最后收在陷阱清单和一套可以直接照着跑的流水线骨架上。


一、选型的第一个维度:维度、存储与延迟的三角

1.1 维度不是越高越好

直觉上,768 维打不过 1024 维,1024 维打不过 1536 维,这个直觉在早期是成立的,因为当时模型的容量确实和维度绑定。但从 2023 年之后,公开可用的主流嵌入模型在“同参数量下更高维度”这件事上已经不再自动带来更好的检索质量,原因有几个层面的机制解释。

第一,有效秩问题。文本语料的语义流形本身的内在维度远小于模型输出的名义维度。维度增加的部分,往往是各向异性噪声被一同放大了。实践上常见的结果是:加维度之后余弦相似度的分布变得更集中,或者更分散,取决于具体模型,但无论哪种,排序的边际区分度都不会线性提升。

第二,训练信号密度下降。在固定的 batch size 和固定的训练步数下,更高维的输出空间里,每个维度平均分到的梯度信号更少。这会使得高维模型在部分细粒度任务上反而不如训练充分的中维模型。

第三,成本是硬约束。向量存储约等于 向量数 × 维度 × 每元素字节数。从 1024 维 fp32 降到 256 维并转 fp16,存储直接降到约 1/8;而一次查询要扫过百万级候选时,内积计算的 FLOPs 与维度成正比。维度不是纯粹的质量维度,它同时是预算维度。

所以正确的问题不是“多少维最好”,而是:在我能接受的存储和延迟预算下,哪个模型能在我的语料上给出最好的排序质量。这句话里三个变量都不可省。

1.2 Matryoshka:把维度变成可调参数

如果一个模型宣称自己是 Matryoshka 训练的可变维度嵌入,那么维度就从一个模型选择问题,变成了一个运行时旋钮。这类模型在训练时会把前缀维度子空间当作独立嵌入来约束,使得截断到任意维度后仍然保持相对稳定的检索表现。

这件事的工程价值在于:同一批文档只需要编码一次。你可以先用最低维度把大规模候选池粗筛,再用高维重算少量候选做精排,甚至把不同维度的向量分层存放在索引的不同段里,先查低维再按需查高维。这类分层检索结构在 Matryoshka 模型上的收益明显高于传统模型。

但要注意边界:Matryoshka 训练出来的截断质量并不均匀,损失通常集中在最低的那一档维度上。实践上的稳妥做法是,不要在训练或评估时把任意维度都当作等价选项,而是先用 2~3 个候选档位(比如 256 / 768 / 全维)跑通评测,看清楚质量随维度下降的拐点在哪里,再决定生产档位。

DIMENSIONS = [256, 512, 768, 1024]   # 仅在模型支持可变维度时有意义


def sweep_dimension(encoder, corpus, queries, gold, k=10):
    """维度是一个实验因子,而不是模型的固有属性。"""
    rows = []
    for dim in DIMENSIONS:
        vecs = encoder.encode_corpus(corpus, truncate_dim=dim, normalize=True)
        row = evaluate(build_index(vecs, metric='cosine'), queries, gold, k)
        row.update(dim=dim, index_mb=len(vecs) * dim * 2 / 1024 / 1024)
        rows.append(row)
    return rows   # 关注质量曲线的拐点,而不是最高那一点的绝对值

1.3 归一化与距离度量:一个必须前置约定的事

在比较任何两个模型之前,必须先把度量口径固定下来。业内主流做法是训练时使用归一化内积(即 cosine),或者用带 temperature 的归一化点积(对比学习里常见),二者排序结果接近但不完全相同;而索引侧则由向量库决定实际使用的度量。

常见的翻车方式是:模型 A 建议归一化 + cosine,模型 B 建议直接用 L2,索引配置又按 A 的建议设定,结果比较出来的差异其实来自度量口径而不是模型能力。正确的做法是把“模型 + 是否归一化 + 距离度量 + 索引类型 + 索引参数 + 重排模型”整体看作一个待评测的系统配置,而不是把模型单独拎出来比。

还有一个容易被忽略的精度细节:归一化之后的向量在 fp16 下存储会引入相对误差,而这个误差在高维上会被内积累加。当不同候选文档的真实相似度差异本身就在很小的量级时,存储精度足以让 top-k 排序发生翻转。因此评测时必须把“编码精度 + 存储精度 + 计算精度”一起固定并在报告中写明;很多“模型在生产上比离线评测差一点”的现象,根因就在这里,而不是模型本身。

同理,如果索引服务端把 fp16 还原成 fp32 的方式与本地评测的实现不一致,也会出现无法复现的差异。所以配置指纹里除了维度、归一化开关,还应包含 dtype 一项。

1.4 模型规模不是免费的,但也不是唯一的质量来源

更大的基座模型通常带来更好的语义理解,这一点在通用基准上表现得很稳定。但把它放进工程决策时,要同时看三笔账。

第一笔是离线编码成本。文档全量编码是一次性的,但语料会持续增长,重建索引的频率可能是每周甚至每天。基座模型参数量翻倍,编码耗时基本也是翻倍。

第二笔是在线服务成本。查询编码和交叉编码器重排都在关键路径上。大模型的推理延迟和显存占用会直接压缩你留给 efSearch 的预算,而前面说过,efSearch 降下来就是召回率下降。

第三笔是部署复杂度。超过某个规模之后,自建推理服务的显存、并发、量化部署都需要专门工程投入;再往上,延迟收益递减往往已经很明显。

实践中的判断标准不是“越大越好”,而是在固定延迟预算下,剩余的预算还能换回多少召回。如果把模型换大一倍,召回提升不到一个百分点,而延迟预算被吃掉三分之一导致 efSearch 下降、召回反而变差,那么这个升级是负收益。评测体系要能捕捉到这种非单调关系,这也是它必须把延迟和召回一起报告的原因。

二、选型的第二个维度:语种、领域与文本形态

2.1 中英混合语料与跨语言检索

中文企业知识库的一个典型特征是:文档主体是中文,但夹杂大量英文技术术语、代码标识符、产品名和缩写。这时候要区分两种目标。

第一种是同语种检索:中文 query 检索中文文档。这要求模型在中文长尾实体和专业表述上足够鲁棒。第二种是跨语言检索:中文 query 检索英文文档,或者反过来。这要求不同语言的向量落进同一个可比较的语义空间。

很多宣称多语言的模型在跨语言检索上确实表现不错,但这个能力在你们的业务里到底用不用得上,是个前提问题。如果你的语料 95% 是中文、英文文档只是术语点缀,那么为跨语言能力付出的训练容量就是浪费;如果你的用户会用中文去搜英文的技术文档,那么这项能力就是刚需。

还有一个常被忽略的点:中英混合在同一个 chunk 内部的表征质量,通常低于纯中或纯英。原因是同一段文本内部语言切换时,模型需要在一段连续的向量里协调两种语言的语义投影。评测集里必须包含混合语言样本,否则你在上线后看到的第一个 badcase 就会是它。

2.2 术语、OOV 与分词策略

企业知识库里最难的往往不是通用语义,而是私有术语:一款产品的内部代号、一个人名在系统里的多种写法、一个内部缩写。这些词对通用分词器来说大概率被切碎或切错,模型的词表覆盖不到,就要依赖子词切分去兜。

子词切分能保住信息,但会引入两个副作用:一是长术语被拆成多个 token 后,向量表达的质量取决于切分是否语义合理;二是某些高频错误切分会让完全不相关的文档因为共享碎片而相似度虚高,这在技术文档里尤其常见,因为大家都在谈 API、token、embedding 这些词。

评测集设计上有一个直接结论:必须有一组纯术语型 query,它们不包含任何句法线索,只是一个产品名或一个人名加一个模糊意图。这类 query 是区分“通用能力强”与“对你的领域真的适配”的试金石,因为通用基准里几乎不会有这种样本。

2.3 长文本与代码:两个被系统性低估的形态

绝大多数嵌入模型的有效输入长度在几百个 token 量级,且训练语料以句子和短段落为主。这导致一个非常典型的失效模式:当单个 chunk 接近或超过模型窗口时,语义被稀释。模型并不是把长文本“压缩”成一个更全面的摘要,而是让靠后的部分在向量里的权重下降,导致长 chunk 的向量和短 query 的相似度主要由前部内容决定。

应对方式有三条,可以按成本排序:其一,控制 chunk 长度,别指望长文本嵌入模型;其二,用长文本优化的模型做二次编码(把召回候选的长文档重新编码一次);其三,把长文档先做层次化摘要再嵌入,这在有 LLM 可用时性价比很高。

代码是另一个独立问题。通用自然语言模型对代码的语义理解普遍偏弱:它学到的是代码的“自然语言描述”,而不是结构与行为。这会直接导致“报错信息检索相关代码”这类需求失效。对代码检索,要么选专门在代码语料上做过的嵌入模型,要么至少确认该模型的评测里包含了真实代码检索任务而不是只看代码摘要。

2.4 领域分布偏移:比语种更容易被忽略的问题

选型时最容易被认真对待的是语种,因为它可枚举、可描述、容易讨论。但真正影响结果的往往是领域分布的偏移——你的语料和模型训练语料在话题、文体与时效上的距离。

偏移会从几个方向表现出来。其一是文体偏移:训练语料以叙述性文本为主,而你的文档可能大量是表格转储、日志片段、配置清单、工单描述,这些文本的语义密度和句法结构都很不一样,向量自然更难对齐。其二是主题集中度:如果语料高度集中在少数几个主题上,模型习得的“哪些话题该匹配哪些话题”这种通用相关性判断,在这几个窄主题上未必可靠,尤其是当主题内部的区分只能靠细粒度术语完成时。

其三是时效性。近两三年的新概念、新产品形态、新技术栈,在许多模型的训练数据里覆盖很弱。如果评测集主要由历史文档构成,就会系统性低估这类问题。所以评测集应当刻意包含一部分近期新增的内容,而不是简单按时间均匀抽样。

判断偏移程度有个便宜的办法:把模型零样本的 top-10 结果按文档来源人工扫一遍。如果某份文档反复出现在大量毫不相关的 query 结果里,说明这些片段在向量空间里的表达是“泛化”的、缺少区分度的,这通常就是模型对这类文本不适配的直接信号。反过来,如果结果高度集中在某一小撮文档上,说明模型可能存在偏置,也值得追一下原因。

三、通用基准能告诉你什么,以及它刻意不告诉你的

3.1 MTEB 的构造与它的平均值

MTEB 这类基准的价值是明确的:它把十几个检索、重排、分类、聚类任务标准化,给出了一个跨模型可比的坐标系。对于第一次做技术选型、需要一个起点的人来说,它能快速排除掉明显不合格的候选——比如某些模型在多语言检索子集上显著落后,如果你的业务是中文为主,这类信息是有决策价值的。

但它的平均分有四个结构性问题。

  • 平均值掩盖方差。一个模型平均分靠前,可能只是因为在“论辩论点检索”这类数据量极大的英文任务上得分高,而在“金融法规检索”这类中文任务上是中游。平均分把这两件事压成了一个数。
  • 任务规模极不均衡。有些子任务有几十万查询,有些只有几千条。后者上的分数波动本身就很大,却和其他任务等权计入平均。
  • 语料分布与业务分布不同。基准语料是公开的、清洗过的、多语言的,而你的语料是有噪声的、非标准的、内部黑话一堆的。分布不同,最优模型就可能不同。
  • 饱和与污染。随着大量模型在公开数据上训练和蒸馏,基准分数整体上移,区分度下降;同时不排除部分模型训练语料包含了基准数据本身。

3.2 榜单本身在变,而经验不会自动更新

除了上面四条结构性问题,还要注意第五条:评测基准的子集构成、评分口径和参赛模型列表都在持续迭代。去年的经验很容易在今年的版本上失效。看到某个模型“当年很强”的印象时,先确认当年那版基准的子集构成与现在的业务是否匹配,再决定要不要纳入候选。

同样重要的是不要把榜单名次当成人格评价。某个模型在某版基准上排第一,既不意味着它在中文技术场景里最好,也不意味着两年后它仍然是第一名。可迁移的判断只有一类:这个模型所属的技术路线在什么条件下占优、在什么条件下会失效。这类机制性结论才值得写进选型文档,并且值得在一年后重新审视。

榜单上那些中途被撤下的成绩也值得留意。项目组发现数据污染或口径问题时,常常直接把某个模型下架而不是修正分数。看到一个模型从榜单上消失,第一反应不该是“它一定不行”,而应该是去查它是因为能力问题还是因为方法合规问题被剔除的——两者的教训完全不同,后者甚至可能反过来说明这个技术路线值得关注。

3.3 子基准也有它自己的适用边界

有人会退而求其次,去看多语言版本的基准、中文子基准、代码子基准、长文本子基准,这比只看总榜合理得多。但即使是这些,仍然要问一句:这个子基准的语料、任务形态和难度分布,跟你的场景接近吗?

更关键的一个反直觉观察是:专门的中文基准分数,和实际中文检索体验的相关性往往不如想象中强。原因在于这些子基准的文档通常比真实企业文档更干净、更有结构、更短。真实场景里的挑战——长 chunk 稀释、术语碎片、扫描件 OCR 噪声、多版本文档互相污染——这些在基准里基本不出现。

另一个机制值得注意:嵌入模型的榜单优势经常会被重排模型吃掉一部分。如果你的架构里有 reranker,那么一个弱 5 个点的嵌入模型配上强重排,末端差距可能只有 1~2 个点;反过来,一个强 10 个点的嵌入模型,如果你的重排很强,其增益可能体现在极端难例上,而不是平均值上。这直接意味着:在有重排的系统里,只按榜单选嵌入模型,会系统性地高估头部模型的收益。

3.4 什么时候可以只信榜单

不是任何时候都需要自建评测集。下面三种情况可以直接依赖公开基准加少量抽样验证:语料是公开通用语料(比如英文维基、公开论文);任务形态是标准的“短 query 检索短段落”;团队没有标注人力,且当前阶段只需要一个可用的起点。

一旦离开这三种情况,自建评测集就不是可选项。因为榜单无法告诉你三件最关键的事:你的文档切分是否让目标信息落在了同一个 chunk 里;你的查询真实长什么样;你的语料噪声对某个模型的鲁棒性如何。

3.5 通用基准能做的三件正事

把榜单一概否定是另一种形式的偷懒。它在三个地方确实有用,而且这三点用好,能显著降低你自己的实验成本。

  1. 排除明显不合格者。多语言子基准上的巨大落后、中文子集上的明显塌陷、长文本任务上的截断劣势,这类信号足以把某些模型直接从候选里删掉,不需要自己测一遍。
  2. 确定第一轮候选集合。榜单给出一个大致合理的先验,让粗筛不必从零开始,可以把有限的算力集中在三到五个真正有希望的候选上。
  3. 当作流水线的自检基线。当你自己测出一个明显偏低的数字时,第一反应应该是怀疑评测实现——切分、归一化、k 值、过滤逻辑、编码前缀——而不是怀疑模型。一个常见的调试顺序正是:先用公开基准复现已知量级的分数,确认管线无误,再切到自己的数据上。

把这三点记清楚,就不容易走向另一个极端——因为“榜单不可信”而放弃量化比较,退回到纯主观感受。后者才是真正不可辩护的做法:它既无法复现,也无法在半年后回答“我们到底变好了没有”。

还有一条使用纪律:不要在看到结果之后再决定用哪个子基准。如果先看了总分,再挑一个对自己有利的子集来论证,那是选择性报告,和上面的三种正事完全不同。子基准的选择应当在实验开始之前,依据业务分布写下来。


四、任务级评测:把“质量”定义成可测量的中间量

4.1 指标选择:Recall@k、MRR、nDCG 的差别

任务级评测的第一步是把“检索质量”这个模糊说法,落成一组具体的指标定义。这里有一个容易被搞混但很重要的区别。

如果你关心的是“候选里有没有”,用 Recall@k;如果你关心的是“用户第一眼会不会看到”,用 MRR 或 Hit Rate@1;如果你关心的是“前十个结果的相对排序质量”,用 nDCG@k。三者的最优模型可能不同。

对于典型的两阶段 RAG(向量召回 + 交叉编码器重排),正确的做法是在每一阶段用匹配该阶段职责的指标:召回阶段看 Recall@100(候选够不够),重排阶段看 nDCG@10(排得准不准),端到端看最终答案正确率或人工评分。这样一旦线上指标掉了,你能立刻定位到是哪一段退化,而不是只知道“搜索变差了”。

还有一条经验:k 的取值应该由下游容量决定,而不是习惯。如果你的重排只处理 top 20,那 Recall@100 里第 21~100 名是白召回的;如果你打算用 LLM 对 top 5 做二次生成,Recall@5 才是关键指标。

还要留意一个容易被默认掉的细节:不同指标对“正确答案数量”的假设不同。Recall 在相关文档只有一条时和有很多条时含义完全不同——前者接近命中判定,后者变成覆盖率。同一份评测集上,一个模型可能靠多召回一些弱相关文档把 Recall 做上去,精确率却同时下降。所以报告里应当同时给出 Recall 和精确率,并说明标注是否允许多个正确答案。只报告其中一个,都是在隐藏一半信息。

from __future__ import annotations
import math
import random
from typing import Dict, Iterable, List, Sequence


def dcg(gains: Sequence[float]) -> float:
    return sum(g / math.log2(i + 2) for i, g in enumerate(gains))


def ndcg_at_k(ranked_rel: Sequence[int], k: int = 10) -> float:
    """ranked_rel: 该 query 下按检索顺序排列的每篇文档的相关性等级。"""
    gains = [float(r) for r in ranked_rel[:k]]
    ideal = sorted((float(r) for r in ranked_rel), reverse=True)[:k]
    idcg = dcg(ideal)
    return dcg(gains) / idcg if idcg > 0 else 0.0


def recall_at_k(retrieved: Iterable[str], relevant: Iterable[str], k: int = 10) -> float:
    rel = set(relevant)
    if not rel:
        return 1.0
    head = list(retrieved)[:k]
    return sum(1 for d in head if d in rel) / len(rel)


def hit_rate_at_k(retrieved: Iterable[str], relevant: Iterable[str], k: int = 1) -> float:
    rel = set(relevant)
    return 1.0 if any(d in rel for d in list(retrieved)[:k]) else 0.0


def bootstrap_ci(values: List[float], n_boot: int = 2000,
                 alpha: float = 0.05, seed: int = 7) -> tuple:
    """配对比较前先看区间:区间大幅重叠时,单点均值的差异没有意义。"""
    rnd = random.Random(seed)
    n = len(values)
    means = []
    for _ in range(n_boot):
        sample = [values[rnd.randrange(n)] for _ in range(n)]
        means.append(sum(sample) / n)
    means.sort()
    return means[int(alpha / 2 * n_boot)], means[int((1 - alpha / 2) * n_boot)]

4.2 指标的方向性陷阱

Recall 类指标有一个内在偏向:它对“相关文档多且分散”的 query 天然有利,对“只有一个正确答案但藏得很深”的 query 天然不利。这意味着如果你的评测集里宽泛型 query 占比过高,你会选出一个偏向高召回、低精度的模型;反之则相反。

因此指标必须和评测集的构成一起报告。更稳妥的做法是按 query 类型分层报告:导航型(意图明确、答案唯一)、信息型(意图宽、需要多篇)、排除型(用户其实想要否定答案)、以及多轮上下文型(query 依赖历史)。不同类型的 query 在真实流量里的占比差异很大,而且不同模型在不同类型上的强弱经常是反的。

4.3 分段归因:让指标成为一个契约

端到端指标是最诚实的,但也是噪声最大、最难归因的——因为生成环节本身的随机性会淹没检索环节 2~3 个点的差异。所以在实践中应该建立一个指标契约:

阶段主指标辅助指标典型失败信号
向量召回Recall@k延迟 P99、索引大小Recall 上去了但延迟翻倍
过滤/改写过滤前后候选命中率被误删比例规则把正确文档删掉了
交叉编码器重排nDCG@10Top1 变化率重排耗时超过召回本身
上下文组装答案覆盖率冗余率证据被截断或充满噪声
生成答案正确率引用准确率检索对但模型没答对

这张表的价值在于:它把“检索算法好不好”这个模糊问题,切成了五个可以分别测量、分别回归的子问题。只有这样,你在下一次迭代时才知道该动哪一个旋钮。

这张表还有一层用法常被忽略:它同时是回归测试的目录。每一行都对应一组可以独立运行、可以独立断言的小测试,挂在 CI 上。将来谁改动了解析环节,改动就会被这一行的断言抓住,而不必等到端到端指标下滑时才去考古。这种“把指标拆成断言”的做法,是评测体系从报告走向工程基础设施的关键一步。

4.4 显著性:不要被小数点后的差异骗了

评测集通常只有几百条 query。在这种规模下,指标均值的置信区间往往很宽。一个模型 Recall@10 高出另一个模型 1.5 个点,完全可能只是抽样波动。正确的做法是做配对比较:用同一批 query,分别看两个模型在这批 query 上的指标差值,再对差值序列做自助重采样,判断差值的分布是否稳定地大于零。

def compare_runs(run_a: Dict[str, float], run_b: Dict[str, float],
                 n_boot: int = 4000, alpha: float = 0.05) -> dict:
    """配对自助检验。run_* 是 {query_id: 指标值},必须来自同一批 query。"""
    ids = sorted(set(run_a) & set(run_b))
    if not ids:
        raise ValueError('no overlapping queries')
    diffs = [run_a[q] - run_b[q] for q in ids]
    wins = sum(1 for d in diffs if d > 1e-9)
    losses = sum(1 for d in diffs if d < -1e-9)
    observed = sum(diffs) / len(diffs)
    rnd = random.Random(13)
    ge = 0
    for _ in range(n_boot):
        sample = [diffs[rnd.randrange(len(diffs))] for _ in diffs]
        if sum(sample) / len(sample) >= observed - 1e-12:
            ge += 1
    p = ge / n_boot
    return {
        'delta': round(observed, 4),
        'wins': wins,
        'losses': losses,
        'n': len(ids),
        'p_value': p,
        'significant': p < alpha,
    }

注意这里的判定逻辑不是“p 小就换”,而是“p 小才值得继续讨论”。一个不显著的微小差异,正确结论是“在这批数据上无法区分”,而不是“选便宜的那个”。这两句话在实践中经常被混为一谈,导致团队反复为了噪声做决策。

4.5 让评测集保持难度:主动注入干扰

评测集有一个自然衰减过程:随着系统变强,原本区分度高的样本逐渐变成“所有模型都会”,指标趋于饱和,此后怎么跑都拿不到信号。扩充样本当然有用,但更经济的方式是主动注入干扰项——因为干扰项不需要重新标注 query 正确性,只需要判定排序是否正确。

常见的做法有三种,按成本排序。

  • 同义改写干扰:为每个正确答案构造一句语义等价但措辞不同的干扰文档。它专治那些“看起来都相关、但只有一个真正答得对”的场景,是区分通用模型与领域适配模型最有效的手段。
  • 跨版本干扰:把同一份文档的旧版本放进候选集。这对应真实系统里最常见的一类 badcase——检索到了过期答案,而答案在语义上完全正确。
  • 跨文档碎片干扰:从毫不相关的文档里截取一段与 query 共享大量词汇的文本。这专门针对前面提到的词形误匹配问题,在技术文档语料里几乎必然存在。

这三类干扰有一个共同性质:正确答案的存在是确定的,判定成本极低。构建一套带干扰的评测集,往往比再标一批新 query 便宜得多,却能让已经饱和的指标重新拉开差距。

4.6 候选集大小 k 的敏感性分析

前面提到 k 应由下游容量决定,但这条原则还有一个操作层面的含义:k 不只是参数,它是一个需要单独做敏感性分析的变量。

完整的做法是在一组 k 上(典型是 10 / 30 / 100 / 300)记录三条曲线:Recall@k 反映召回天花板,nDCG@k 反映精排后的效果,端到端答案质量反映业务真实收益。三条曲线的饱和点通常不重合,而真正重要的信息是它们的交点区域。

举例说明:Recall@100 已经接近饱和、Recall@300 几乎不再提升,说明召回段的投入已经没有回报;但如果 nDCG@30 与 nDCG@10 差距明显,说明重排还有用武之地,只是需要更大的候选集。这两个判断会导出完全不同的优化方向,而只看单一 k 下的单个数字,是看不出这种结构的。

反过来,如果某条曲线出现了下降——比如某个 k 增大之后答案质量反而变差——那几乎总是有具体的工程原因:上下文变长导致噪声增加、重排超时被截断、或者 token 预算把关键证据挤出去了。这类非单调现象是 k 分析最有价值的产出,因为它直接指向了一个真实存在的线上问题。

4.7 端到端评估的成本,以及它的简化方式

端到端评估是唯一能回答“用户到底满意不满意”的方法,但它有两个现实困难:判定本身带噪声,生成环节的随机性又会把检索侧的差异淹没。前者靠多标注员取一致来缓解,后者只能靠重复采样来稀释。

一个可行的简化路径是把端到端评估从“每次改动都跑”降级为“每个候选版本跑一次”:在检索层面用大规模评测集做日常门禁,只有当某个候选版本通过了检索层的显著性检验,才值得投入人力做端到端评估。这样既保证大改动会被验证,又不至于让端到端评估变成流水线的瓶颈。

另一个简化方式是把自由判断换成封闭题:事先为每条 query 写下“正确答案里必须覆盖哪几个要点”,评估时只检查这些要点是否出现。判定空间从“答案好不好”缩小成“几个点是或否”,噪声显著下降。代价是它测不到表达质量,但表达质量通常不是检索选型关心的东西——如果关心,那说明讨论的已经不是同一个问题了。

五、构造一个真正贴合业务的小评测集

5.1 数据来源:按可信度排序

评测集的数据来源,按成本从低到高大致有四级,可信度逐级上升:

  1. 线上日志抽样。最真实,但需要处理长度偏斜——用户倾向于输入长 query,这会让评测集偏向长尾难例。同时要脱敏,尤其是内部文档场景。
  2. 标注。让标注员对一批抽样 query 判断“哪些文档能回答这个问题”。这是标准做法,成本主要在标注规范设计和一致性校验上。
  3. 点击与反馈信号反推。把有交互的 query 视为正例、无交互的视为负例,成本极低但噪声大,因为无交互可能是因为用户已经知道答案,或者界面问题。
  4. LLM 合成 + 人工校验。适合覆盖那些真实日志里罕见但业务上重要的场景,比如纯术语查询、跨语言查询。必须有人工校验环节,否则合成数据会带上生成模型的系统性偏好,让评测集在结构上偏向某一类模型。

实际做法通常是多来源混合,并记录每条样本的来源标签,这样后面可以报告“按来源分层的表现”,也能在怀疑数据来源偏差时做敏感性分析。

5.2 规模下限:与其要 5000 条,不如要 300 条分层样本

评测集的规模焦虑是普遍存在的,但更大的评测集不等于更好的决策。一个更实用的目标是每个业务分层都有足够支撑结论的样本数,而不是总数很大。

一个可操作的参考是:把 query 分成 4~6 个类型(导航型、宽泛信息型、术语型、跨语言型、上下文依赖型、否定型),每类至少 50 条,整体 300~500 条。在这个规模上,5~10 个点的指标差异通常是可判定的;1~2 个点的差异通常不可判定,应当诚实地说“无法区分”。

与其把精力花在堆量上,不如花在提高标注质量上。一个实用做法是对每个 query 只标注“足以回答该问题”的文档集合,而不是标注所有相关文档——后者的一致性极差,不同标注员对“相关”的边界理解完全不同。

5.3 分层抽样与难度切片

在决定“每类多少条”的时候,有一条经验值得记住:样本量应该按“这一类的结论要多可靠”来分配,而不是按流量占比来分配。流量占比最大的那一类通常最容易加样本,但它的指标也最稳定、差异最小;真正决定选型的是那些样本少、方差大的类目,它们恰恰需要更多样本才能支撑判断。

具体操作上有一个很实用的技巧:先跑基线拿到每个 query 的难度标签,再按“这一类我要区分出 5 个点的差异”反推所需样本量,然后回头检查是否满足。不满足的那一类,要么补样本,要么在报告里明确写“该切片样本不足,结论不可用”。明确写“不可用”,比给一个抖动很大的数字有用得多,因为后者总会被拿去论证。

如果只能从日志里随机抽 300 条,很可能抽到一个过于简单的集合,测出来所有模型都 95 分,完全无法区分。更好的做法是先跑一次基线,然后按基线难度分层采样:

切片定义作用
易基线模型已能排到 top 1~3防止样本过于简单导致饱和;这些题通常决定不了选型
中基线模型能召回但排不进 top 5重排模型的主要战场,也是区分嵌入模型的主要区间
难基线模型召回失败暴露词面不匹配、领域漂移问题;模型差异在这里最明显
对抗存在高度相似但错误的干扰文档考察细粒度区分能力,区分通用模型与领域适配模型

难例的另一个来源是难负样本挖掘:先用当前模型检索,把排在高分位但不相关的文档收集起来,作为干扰项加入。这能显著提升评测集的区分度。但要非常小心循环论证——用模型 A 挖出的难负样本去评测模型 A 的改进版,天然偏向 A。正确的做法是用多个不同来源的模型轮流挖掘负样本,或者至少用与被测模型无关的模型(比如 BM25 或一个不同架构的模型)来构造干扰集。

5.4 防泄漏:评测集必须有自己的生命周期

评测集最大的风险不是规模小,而是被优化过程污染。一旦团队开始反复看某个评测集的分数,并据此调 prompt、调权重、调模型,这个集合就变成了验证集,不再是测试集。分数会持续上涨,而线上不一定有任何改善。

防护措施至少包括三条:其一,维护一个固定的“发布前验收集”,规定它在模型迭代过程中只允许被运行有限次(例如每次候选版本一次),并且不公开逐条明细;其二,评测集按时间切片,用较新的时间片作为验收集;其三,如果评测集从线上日志来,按用户或会话分组切分,而不是按单条 query 随机切分——同一个用户的相似提问如果同时出现在训练调参集和评测集里,会造成严重高估。

import random
from collections import defaultdict
from typing import Dict, Iterable, Iterator, List, Tuple


def group_kfold(records: List[dict], folds: int = 5,
                group_key: str = 'session_id',
                seed: int = 0) -> Iterator[Tuple[List[dict], List[dict]]]:
    """按会话/用户分组切分,避免同一话题的近重复 query 跨越训练与评测两侧。"""
    groups = sorted({r[group_key] for r in records})
    random.Random(seed).shuffle(groups)
    buckets = defaultdict(list)
    for i, g in enumerate(groups):
        buckets[i % folds].append(g)
    for f in range(folds):
        test_groups = set(buckets[f])
        train = [r for r in records if r[group_key] not in test_groups]
        test = [r for r in records if r[group_key] in test_groups]
        yield train, test


def assert_no_overlap(train: Iterable[dict], test: Iterable[dict],
                      group_key: str = 'session_id') -> None:
    a = {r[group_key] for r in train}
    b = {r[group_key] for r in test}
    leak = a & b
    assert not leak, 'group leakage detected: %d' % len(leak)

5.5 标注规范里最该写清楚的三件事

评测集的上限是标注质量,而标注质量的上限是规范。下面三条如果没写清楚,标注一致性一定会崩。

第一条是用二值判定还是相关性分级。二值判定要求标注员回答“这篇能不能回答这个问题”,边界天然模糊;三级标注(直接回答 / 部分回答 / 仅相关)边界清晰得多,但成本更高,且指标选择会受限。实践上二值更常用,关键在于给出足够的判定示例,尤其是边界样本的示例。

第二条是多个文档都相关时怎么处理。要求标出全部相关文档,不同标注员对“还有没有别的答案”的判断差异极大,会引入大量噪声。一个可操作的折中是:允许标注多个相关文档,但要求按相关性排序,并至少明确标出一个最优文档。这样既保留了 recall 的意义,又让 nDCG 可算。

第三条是语料里根本没有答案的 query 怎么办。如果直接丢掉,评测集就失去了考察拒答能力的维度,而这恰恰是线上体验很重要的一类。建议单独维护一个“应当拒答”集合,用来标定相似度阈值——这个集合通常比主评测集小得多,几十条就能把阈值调到比较稳。

这三条写清楚之后,务必做一次双人独立标注并计算一致率。一致率不达标时,优先修规范,而不是先扩大样本量——在规范不清的情况下,标得越多,噪声积累得越快,最后整批数据都要返工。

如果你打算用大模型做自动评判,还需要额外小心两个偏差。一个是位置偏差:评判模型对候选列表里靠前的位置天然给分更高,所以必须打乱顺序,或者做位置交换的对照实验。另一个是长度偏好:更啰嗦的候选往往被判为“更完整”,即使它并没有更准确地回答问题。缓解办法是在提示里明确要求只判断是否包含回答问题所需的信息,并用一个已知的人工标注子集去校准自动评判的准确率。如果校准后的一致性不够,那就老老实实用人工——这不是效率问题,是可信度问题。

5.6 评测集需要自己的更新机制

评测集不是一次性工程,它会过期。语料在增长,历史文档被删除或改版,用户查询的方式也在变化。一个一年前造的评测集,到现在已经不再代表当前系统的真实表现。

可行的更新节奏是:固定一个不可动的核心集用于跨版本比较,另外维护一个每季度轮换的动态集用于发现新问题。核心集小而稳定,专门用来做回归对比,样本轻易不换;动态集从最新日志里抽样,允许被替换,用来捕捉新的失败模式。

还要监控一个容易忽略的信号:线上 query 分布与评测集分布的漂移。一个简单的做法是定期统计新日志中 query 的长度、词汇分布、以及与评测集的相似度分布,如果漂移明显,说明评测集已经不代表线上情况,这时候应该触发一次重建,而不是继续在旧集合上比较模型。

5.7 用 badcase 驱动评测集生长

评测集最健康的增长方式不是定期泛泛地扩充样本,而是把线上发现的 badcase 逐条结构化地沉淀回评测集。这个循环的关键在于“结构化”三个字。

一个 badcase 值得沉淀的前提是能回答清楚三件事:当时用户的 query 是什么、系统返回了哪些文档、正确答案应该是什么。第三件事往往就是难点——如果没人说得清正确答案,它只是一次困惑;如果能定位到它,它就变成了一条高价值样本,因为它天然对应系统的一次真实失效。

实操上可以给每条沉淀样本打三个标签:根因标签(分块问题、召回失败、重排失误、生成幻觉、阈值过高)、形态标签(术语型、长文本、跨语言、上下文依赖)、以及来源标签(线上日志、人工构造、回归发现)。有了这三个标签,评测报告就能按根因统计:某个模型到底修好了哪些失败模式、又打坏了哪些。这比单看平均分的提升有信息量得多,也更容易说服人。

还要防一个副作用:按 badcase 定向构造的评测集会迅速过拟合。团队会不自觉地围绕当前已知的失败模式做优化,而对尚未暴露的问题毫无准备。所以这个集合应当与一份覆盖面更广的稳定集合并行存在,比例也要控制——badcase 集合占三成到四成比较合适,其余由稳定集合兜底。

六、重排模型与嵌入模型:协同而不是单挑

6.1 两阶段的职责边界

嵌入模型和交叉编码器重排模型在能力上是有明确分工的,这个分工决定了选型时该看什么。

嵌入模型负责的是“召回”,也就是在大规模候选里把可能相关的文档缩小到一个可处理的小集合。它的核心能力指标是语义覆盖——能不能在不丢的前提下把答案捞出来。它的训练方式是双塔对比学习,本身看不到 query 与文档之间的细粒度交互,因此它擅长语义级匹配,弱于精确的细粒度对应。

重排模型负责的是“精排”,它把 query 和候选文档拼在一起完整编码,因此能捕捉词级对应关系和逻辑约束。它的核心能力指标是排序精度。它的计算量与候选数成正比,所以只能在召回之后的小集合上运行。

这个分工带来一个重要的选型推论:如果你打算用强重排,嵌入模型只需“召回不漏”,不必“排序很准”。这意味着一个中游嵌入模型 + 强重排,完全可能打败头部嵌入模型 + 弱重排。反过来说,一个嵌入模型的微小优势,如果重排阶段能把两者都排对,就体现不出来。所以正确的评测单元永远是“嵌入 + 重排”的组合。

6.2 联合评测的正确姿势

联合评测时,候选集大小 k 是一个必须显式固定的关键参数。把 k 从 100 提到 500,重排质量通常会提升(因为正确答案被捞进来了),但延迟成本线性上升。所以:

  • 固定 recall_k 这个常量做跨嵌入模型比较(例如都取 top 100 进重排),这样比较的是重排后的最终排序质量。
  • 固定 端到端延迟预算(例如 300ms),让每个嵌入模型自己选择它需要的 k 和 efSearch,比较的是预算内的最终质量。
  • 两种视角都跑一遍。如果两个视角给出的结论不一致,说明系统处于权衡前沿的敏感位置,需要更细的切片分析。

另一个实用技巧是测量重排的边际贡献曲线:对固定的嵌入模型,分别在 top 20 / 50 / 100 / 200 上跑重排,看 nDCG@10 何时饱和。这个拐点告诉你重排阶段真正在处理多少有效候选,直接决定重排的批大小和超时设置。

最后要注意晚期交互(late interaction / 多向量表示)这一类路线的特殊之处:它把细粒度匹配能力做进了嵌入阶段,理论上可以替代重排。但它的索引成本是每个文档存几十上百个向量,通常需要配合压缩。评测这类方案时,要同时报告召回质量与索引规模,否则结论无法与单向量方案比较。

6.3 稀疏、稠密与混合方案的评测视角

在动手评估融合策略之前,还有个更基础的动作常被跳过:给重排阶段建立自己的对照基线。很多团队从未记录过“同一个嵌入模型下,把重排换成另一个会怎样”的结果,于是把重排带来的提升和嵌入模型带来的提升混在一起归因,最终得出“嵌入模型很重要”这种可能完全错误的结论。

测量方法不复杂,但有个前提:需要准备一份不经过重排的排序结果作为固定输入。固定住这份输入,再换不同的重排模型,差异就是重排阶段的纯净贡献。这个前提之所以关键,是因为如果每次重排实验都重跑一遍召回,嵌入侧的随机波动就会混进对比里,结论再次不可信。

分清归因之后再排优先级,顺序通常和直觉相反:一个中等偏上的嵌入模型配一个强重排,可能比一个头部嵌入模型配弱重排更稳;反之,如果你的重排已经接近能力上限、k 增大也不再有收益,那么嵌入侧的召回天花板就会成为真正的限制,此时换嵌入模型才是有回报的投入。

稀疏检索在能力轴上和稠密向量是正交的:它靠词形精确匹配,对专有名词、型号、错误码、罕见缩写这类查询天然有效,而这恰恰是稠密检索最容易失手的区域。它的弱点同样明确——对同义改写、跨语言、口语化表达几乎无能为力。

从评测角度,这意味着两者的收益不能简单相加。如果评测集里几乎没有术语型 query,你会低估稀疏检索的价值;如果全是术语型 query,你会低估稠密检索的价值。所以混合方案的评测必须保证两类样本都有足够比重,并分别报告两路各自的贡献。

一个实用的归因方法是把候选集拆成“只有稀疏命中”“只有稠密命中”“两路都命中”三个桶,分别报告最终排序质量。如果“两路都命中”这一桶的质量明显更高,说明两路在排同一批正确答案,重叠即浪费;如果“两路都命中”的质量反而下降,说明融合策略把好的候选挤出去了。这个拆分只花很少的额外计算,却能直接指导融合参数怎么调。

顺带一个与选型直接相关的判断:如果评测暴露出“术语型 query 是主要失败模式”,正确的结论往往不是换嵌入模型,而是补一路稀疏检索。它比微调便宜得多,也不会带来通用能力塌缩的风险。

6.4 什么时候嵌入选型阶段不该动

有一种常见情况会让整个“选嵌入模型”变得无意义:重排已经很强,而召回段相对充裕。判断方法很简单:把 recall_k 调到 200,看 Recall@k 是否已经接近饱和(比如 >0.95),同时看 nDCG@10 在 recall_k 50 和 200 之间是否差异很小。如果是,那么嵌入模型已经是系统里最弱的一环,正确的投入顺序是先把重排和 chunking 调好,再回头考虑换嵌入模型。

反过来,如果 Recall@k 曲线在 k 增大时还在明显爬升,说明瓶颈在召回,此时换嵌入模型或调索引参数的边际收益就高得多。

七、领域微调的收益边界

7.1 微调真正改变的是什么

领域微调通常的做法是:在你的真实 query-文档对上做对比学习(正样本对拉近,负样本对推远),或在已有嵌入模型上加一个投影层做轻量适配。它真正能改变的东西很具体:

  • 术语与表述的表征对齐。让模型学会“工单挂起”和“订单暂停”在你的业务里可能是同一件事。
  • 难负样本的区分能力。这是收益最大的一项,尤其是在有大量高度相似的近义文档时。
  • 输出空间的判别性。减少无关文档因为共享通用词汇而相似度虚高的情况。

它不能改变的也很明确:分词对私有术语的切分质量、模型对长文本的稀释问题、嵌入与上层生成之间的接口约定。这些属于架构问题,微调解决不了。

顺带说一个常被跳过技术细节:损失函数的选择会直接影响模型在细粒度区分上的表现。基于三元组或成对排序的损失更强调相对次序,适合排序质量导向的场景,对采样噪声更鲁棒;纯对比损失给的上限更高,但更依赖负样本的质量。前者稳、后者高,这个取舍说明了一件常被低估的事——负样本怎么挖,往往比损失怎么选更重要,而这正好也是评测集应该承担的工作。

7.2 收益消失的四类场景

第一种,数据量不足。对比学习对负样本数量很敏感,几千对样本很难带来稳定收益,而且很容易过拟合到那批样本上,表现为评测集上很美、线上不稳。有研究表明,收益的稳定性大致随正样本数量的增加而改善,但这个门槛在不同任务上差别很大,实践上应把“能否稳定复现”作为唯一判据,而不是看单次实验的分数。

第二种,文档覆盖不足。用 A 部门的 query 去做微调,往往让模型在 B 部门上退化。这是最容易被忽略的一种退化,因为它在聚合指标上不明显,只有按业务线切分看才会暴露。

第三种,污染通用能力。在窄领域上训练较久,模型会向该领域塌缩,在跨领域或跨语言检索上明显掉点。如果你的系统里同时有多个域,或者有长尾的新域需要随时加入,那么这种窄化带来的长期维护成本可能超过当期收益。

第四种,上游变更被放大。微调模型与特定的分词方式、特定的最大长度绑定。哪天你要改 chunk 策略,或者换成一个上限更短的基座模型,整个微调资产就作废了。相比之下,用通用模型 + 提示词 + 难负样本重排的组合,替换成本低得多。

7.3 更划算的替代路径

在决定微调之前,有几条成本低一个数量级的路径值得先走完。

第一,用 LLM 做难负样本挖掘 + 重排,不动嵌入模型。这一步的收益常常能覆盖微调的大部分,因为区分能力本质上由重排阶段提供。

第二,混合检索补足术语精确匹配。向量检索擅长语义但弱于精确词形匹配,配合 BM25 或专有名词字典,能显著改善术语型 query 的表现,且不损失任何通用能力。

第三,query 改写与扩展。让 LLM 把口语化 query 改写成含关键术语的检索式,这直接提升了查询与文档分布的一致性,成本远低于微调。

第四,文档侧的表示增强。给每个 chunk 加一层结构化的上下文摘要或关键词,再一起编码进去。这解决的是“术语在文档里出现但被稀释”的问题,绕开了微调。

只有当这四条都做过、并且评测数据明确指向“召回阶段是瓶颈、且失败模式集中在近义文档混淆”时,微调才应该进入选项。

7.4 微调的最小可行实验设计

如果最终决定微调,实验设计本身要比想象中更严格,否则很容易得到“看起来有效”的结论。

第一,基座与对照要配平。至少跑四组:原始通用模型、通用模型加投影层、用你的数据做的全参微调、以及在同等数据量下训练的对照基线。缺少任何一组,都无法说明收益究竟来自领域知识,还是仅仅来自“多训练了一轮”。

第二,评测必须包含完全没见过的领域。从微调数据里留出一整个业务线,完全不参与训练,只用于评测。这一组数字是判断是否发生能力塌缩最直接的证据,也是微调项目里最常被跳过的一步——它的结果往往不好看,但正是它决定了这次微调是不是一个划算的交易。

第三,记录数据规模的收敛曲线。在数据量的几个档位上分别评估,得到的是曲线而不是单点。单点高分可能只是这个规模下的运气;曲线才能告诉你收益是否还在增长、当前投入是否已经接近饱和、以及是否值得再攒数据。

第四,把维护成本计入决策。微调产出的不只是模型文件,还有一整套依赖它的训练数据、基座版本、推理配置和回归测试。如果这些资产的维护成本高于微调带来的那两三个点的收益,那么即使离线指标提升明显,这个决策在工程上也不划算。评测报告不只该有指标表,还该有这条成本判断。

八、索引参数如何遮蔽模型质量

8.1 HNSW 的 M、efConstruction 与 efSearch

这是评测环节最容易被忽略、后果又最严重的一层。同一个嵌入模型,不同索引配置下的 Recall 差距可以达到十几甚至几十个点。如果你在比较模型时没有锁定索引配置,你测到的很可能是配置差异而不是模型差异。

三个参数的语义要分清:

  • M 是每个节点的出边数,直接决定索引的图质量与内存占用。常见起点是 16 或 32;调高它主要改善极高召回区(95% 以上)的表现,对中低召回区收益有限。
  • efConstruction 是建图时候选队列的大小,影响图结构质量,属于离线参数,一次构建定终身。常见起点 200;调高的收益递减明显,而且建索引时间线性上升。
  • efSearch 是查询时候选队列大小,是运行时参数,在延迟预算内可以动态调整。它是召回率与延迟之间最直接的手柄。

关键判断是:如果当前 Recall@10 在 80% 左右,瓶颈通常在模型而不是索引,调 efSearch 会有效;如果已经在 95% 以上,瓶颈在模型,调索引已经榨不出东西了。反过来,如果索引配置很松,即使换了更好的模型也可能看不到提升——因为天花板被 efSearch 卡住了。

8.2 量化与近似:召回天花板的来源

内存墙会逼着几乎所有系统引入压缩,常见路线有标量量化(int8)、乘积量化(PQ/OPQ)、二值化,以及各向量库自己的磁盘索引格式。它们的机制不同,但有一致的代价:量化误差会降低向量可分辨性,导致原本排在第 3 的正确答案掉到第 30。

这里有几个必须单独评估的点:

  • 量化前的分布是否适合。不同嵌入模型输出的向量分布差异很大,有的维度上分布很集中,有的维度方差大。PQ 的码本是在训练集上聚类得到的,如果某个模型的向量分布不是训练分布良好覆盖的,量化损失就会明显更大。这就是为什么同一个量化配置在不同模型上的损伤程度不同。
  • 残差修正的作用。OPQ 类做法先做旋转再做量化,可以显著降低损失。这个旋转通常需要和你的语料一起训练,属于配置的一部分,要跟着一起评测。
  • 二值化的场景边界。纯二值化保留的信息极少,召回损失通常很大,只在超大规模且有强重排的组合里才勉强可行——但即便如此,也必须单独报告量化后的 Recall@k 衰减。

8.3 评测必须锁定并报告索引配置

在动手扫参数之前,还有一个容易被忽略的前提:量化配置的损伤程度依赖于模型输出的向量分布,而不同模型之间差别可能很大。同样一套乘积量化配置,在分布集中的模型上可能几乎无损,在分布分散的模型上可能明显掉点。所以量化方案不能跨模型直接沿用,必须为每个候选模型单独测量它的衰减幅度。

from dataclasses import dataclass
from typing import Iterable, List, Sequence


@dataclass(frozen=True)
class IndexConfig:
    m: int = 16
    ef_construction: int = 200
    ef_search: int = 64
    metric: str = 'cosine'          # 与模型的归一化约定保持一致
    quantize: str = 'none'           # none | scalar | pq | binary
    normalize: bool = True


def sweep_index(corpus, queries, gold: dict,
                configs: Sequence[IndexConfig], k: int = 10) -> List[dict]:
    """固定语料与模型,只扫索引配置,定位“召回天花板”到底在哪里。"""
    rows = []
    for cfg in configs:
        index = build_index(corpus, cfg)
        hits, lat = [], []
        for q in queries:
            res = index.search(q.vector, top_k=k,
                               ef_search=cfg.ef_search)
            hit = 1.0 if (res.ids and gold[q.qid] in res.ids) else 0.0
            hits.append(hit)
            lat.append(res.latency_ms)
        rows.append({
            'm': cfg.m,
            'ef_construction': cfg.ef_construction,
            'ef_search': cfg.ef_search,
            'quantize': cfg.quantize,
            'recall_at_%d' % k: round(sum(hits) / len(hits), 4),
            'p50_ms': round(percentile(lat, 50), 2),
        })
    return rows

使用这张表的正确姿势是:先画出召回率对 efSearch 的曲线,找到当前模型配置下拐点在哪里。如果曲线在很小的 efSearch 就饱和,说明索引不是瓶颈;如果曲线还在陡峭上升,说明索引配置压制了模型的表现,此时任何模型对比结论都是不可信的。

另一个常被忘记的配置是相似度阈值与空结果策略。很多系统在相似度低于阈值时返回空,这个阈值如果在评测时没有暴露出来,你看到的 Recall 会被静默地压低一截。所以报告里必须包含阈值分布和空结果率。

8.4 IVF 类索引与结构化过滤:另一种遮蔽效应

图索引之外,倒排文件类索引的召回行为完全不同:它先粗选出若干个分桶,再只在选中的桶内做精算。因此它的关键旋钮是 probe 数量,而对召回率的影响在小数据量下常常比图索引更剧烈——多探一个桶,检索范围就成倍放大,代价也成倍增加。

由此产生一个容易误判的现象:同一模型在图索引上表现稳定,换到 IVF 上突然掉分。此时正确的结论是索引配置不匹配,而不是模型不行。反过来也一样——模型之间的差距在不同索引结构下可能被放大或缩小,所以“哪个模型更好”这个问题,严格来说只有绑定索引配置之后才有确定答案。

结构化过滤是另一个遮蔽源。真实系统里几乎总要叠加权限、部门、时间范围这类元数据条件。当过滤在检索之后执行时,它会静默地删掉部分正确文档,表现为“召回率说不清为什么掉了”;当过滤被下推到索引内部时,算法需要在过滤后的候选里继续搜索,等效召回随过滤的选择性变化——过滤越严格,等效召回越低。

所以评测配置里必须显式包含两件事:过滤条件的分布与选择率,以及过滤是在检索前、检索中还是检索后执行。一个只在“无过滤”条件下测出来的召回数字,在真实的多租户场景里几乎没有参考价值。

8.5 召回率与延迟的权衡必须在评测里固定

延迟和召回率的关系在多数向量检索接口里是单调可调的:给更多延迟,召回更高。于是“哪个模型更好”这个问题的形式就变成了——在同一条延迟曲线上,谁的位置更优。

这也意味着单独报告某个配置下的“召回 X、延迟 Y”几乎没有信息量,因为换一组配置就能得到另一组同样合法的数字。真正可比的表述只有两种:等延迟下的召回比较,和等召回下的延迟比较。前者对应“用户感知不能变慢”这个约束,后者对应“质量不能退让”这个约束。

选择哪一种取决于业务承诺。如果线上有明确的响应时间 SLA,第一种更贴近实际;如果质量是底线、延迟可以协商,第二种更合适。无论哪种,都必须在报告里写清楚用的是哪一种,以及是在哪个延迟点上比较的,否则两个团队的数字无法对话。

8.6 建索引本身也是不确定的

一个容易被忽略的事实是:即使模型、参数、数据完全相同,两次建出来的索引在查询结果上也可能有差异。来源包括图索引构建时的并发顺序与随机性、分桶算法的实现差异、以及量化码本训练时的采样随机性。

这带来两个具体的实践要求。第一,同一份评测报告里的所有对比,必须来自同一个已建好的索引。如果每个候选模型各自建一次索引再比较,图构建的随机性就会作为噪声混进结果里。第二,如果要重建索引,必须把重建本身当作一个实验变量来报告:同一个配置重建两次,看指标波动有多大——这个波动幅度就是你这套系统最底层的噪声地板。

顺带一句工程建议:把索引构建也做成可复现的产物,固定随机种子、固定并行度、把构建参数和索引版本号一起记录。做不到完全一致时,至少记录构建耗时与各阶段的并发设置,让延迟数字的解释范围有依据。评测体系里的可复现性,一半的功夫其实花在索引这一侧,而不是模型这一侧。

知道了噪声地板,后面所有“X 比 Y 高两个点”的说法才有资格被讨论。这个数字往往比想象中大,尤其是并行建图的大规模索引;它也是为什么“只跑一次就下结论”在评测里格外危险——不是运气好坏的问题,而是根本没有测出系统的固有波动范围。

8.7 索引配置的最小报告项

把前面几节收敛成一张清单:任何一次评测报告至少要包含下面这些项,缺任何一项,就意味着某个结论无法被第三方复核。

类别必须写明的项不写会怎样
数据语料快照版本、分块规则与参数、长度截断策略换一次分块,历史分数全部失效
编码模型名与 revision、维度、归一化、精度类型、查询前缀分不清是模型变了还是配置变了
索引索引类型、M、efConstruction、efSearch 或 nprobe、量化方式结论会被索引参数遮蔽
过滤过滤字段、选择率、执行发生在哪个阶段线上加了权限过滤之后不再可比
重排重排模型、候选数、超时与截断设置归因错位,分不清提升来自哪一段
评测评测集版本、切片构成、标注一致性、置信区间数字无法判断显著性
环境硬件、并发度、缓存冷热状态延迟数字不可复现

这张表也可以直接当成选型文档的骨架。它的价值不在于字段本身,而在于它把“我觉得这个模型更好”翻译成了一份可以逐项打勾的清单——凡是打不了勾的结论,就还不该被写进决策。

九、评测中的九个陷阱

陷阱一:同一批数据既调参又评测

这是最常见也最严重的陷阱。它的典型症状是:每次迭代分数都在稳步上升,但线上效果早已停滞甚至退化。原因很简单——你优化的是在这批数据上的表现,而不是泛化能力。

识别方法很直接:如果你的团队知道每条评测样本的正确答案,并且在讨论具体某条样本“为什么排错了”时能反复回到同一批样本上,那这批数据事实上已经变成训练数据了。解决办法是把调参集和评测集物理分开,并且让报告里带上调参轮次计数;连续多轮在固定评测集上做选择,本身就是一种过拟合。

陷阱二:只用精确匹配判断

“query 里出现了关键词,且文档里有,就判对”这种判定方式看似合理,实际上会系统性地偏向那些关键词堆砌型文档。真正有区分度的判断标准应该是:这篇文档能否回答这个问题,而不是“它有没有这些词”。

在中文技术场景里这个问题更突出,因为同义表达非常多(“起不来”与“无法启动”、“报错”与“抛异常”),精确匹配会把大量正确文档判为不相关。最稳妥的做法是对一小批样本做双人标注并计算标注一致性,用一致率来验证你的判定规则是否可信;如果一致率低于某个阈值,说明判定规则本身需要先修订,而不是急着比较模型。

陷阱三:忽略长文本与代码

前面已经讨论过机制,这里强调它在评测中的表现形态:长 chunk 的召回质量明显低于短 chunk,且降级幅度在模型之间差异很大。用短段落评测集选出的模型,上线到长文档场景可能大幅退化。

代码更特殊。通用嵌入模型对代码的理解接近于自然语言描述,评测集里如果没有真实代码 query,这一缺陷完全不可见。最低成本的补救是:按 chunk 长度分桶报告指标,再单独构造一组“报错信息 → 代码位置”的 query。这两组数据通常就能暴露出排序方向上的问题。

陷阱四:只看平均指标

平均值会把最重要的信息压掉,尤其是当样本量在不同切片之间极不均衡时。前面提到过切片报告,这里补充一点:当两个模型平均分接近时,正确的决策依据是看差异最大的切片。如果 A 在你的主力场景切片上稳定优于 B,哪怕平均分略低,A 才是应该选的那个——因为你的流量不是均匀分布的。

陷阱五:忽略 query 分布的长度偏斜

线上日志里的 query 长度分布,和评测集里的通常不一样。特别是短 query 占绝对多数的场景(比如搜索框里只输入两三个词),这会显著改变优化方向:如果你的评测集里长 query 占比高,选出来的模型会偏向处理复杂语义,而不是偏向精确词形匹配。

正确做法是用线上真实的长度分布对评测集做重采样,或者直接按长度分层报告。这两种方法都不难,但能避免一次代价不小的错误选型。

陷阱六:忽略检索器与生成器之间的不匹配

这是很多人只在端到端评估时才会踩到的坑。检索指标提升了,但答案质量没变,甚至变差了。常见原因有两个:召回更多相关文档导致上下文变长,引入了噪声;或者被召回的文档虽然相关,但关键信息在中间位置,被上下文的“中间遗失”效应削弱。

因此每次嵌入模型变更,都应该同时报告检索指标和一个基于参考答案的人工或自动判定的答案质量指标,哪怕只是一个粗略的 50 条抽样。只看检索指标就上线,是把检索当作目的而非手段的典型错误。

陷阱七:编码参数不一致

文档编码和查询编码在很多框架里走不同的代码路径(不同前缀、不同归一化、不同的最大长度截断)。这类问题在评测阶段尤其隐蔽,因为它通常只影响线上表现,不影响离线批量评测——离线用的是正确的配置,线上某个服务因为缓存了错误配置而一直用错的。

防御方式是把编码配置放进版本管理的配置文件,并且为文档端和查询端分别写一个一致性自检:断言两端的归一化开关、前缀、最大长度在每次发布前一致。

陷阱八:忽略文档新鲜度与版本

在真实系统里,评测集里的 query 对应的“正确答案”在几个月前可能是另一篇文档。如果评测集不记录文档版本,跑出来的分数会随时间漂移,而这部分漂移和模型变更完全无关。解决办法是给每个标注记录一个文档版本或时间戳,并在评测时检查它是否仍然有效;失效样本应当被显式标记,而不是静默地参与平均。

陷阱九:把单次结果当结论

嵌入模型的编码是确定性的,所以“多次运行结果不一致”通常不是随机性问题,而是配置不一致。但即便如此,索引构建顺序、并发度、缓存状态都可能带来可观波动。稳妥做法是每次评测记录完整的配置指纹(模型版本、维度、归一化、索引参数、量化方式、数据集版本),并保留多次运行的报告。如果两次运行的结论不同,那说明差异处在噪声量级内,此时不应该做任何决策。

与之配套的是一条记录纪律:报告里要写下这次运行的噪声地板是多少(来自索引重建实验和自助重采样),并把所有小于它的差异标注为“不可区分”。这条纪律看起来只是格式要求,实际效果很强——它把“这个模型好一点”从主观判断降级为一个有明确门槛的命题,也顺带消灭了大量“差 1.5 个点就换模型”式的来回摇摆。

def config_fingerprint(**fields) -> str:
    import hashlib
    payload = '|'.join('%s=%s' % kv for kv in sorted(fields.items()))
    return hashlib.sha256(payload.encode('utf-8')).hexdigest()[:12]


fp = config_fingerprint(model=name, revision=rev, dim=dim, max_len=max_len,
                       normalize=True, metric=metric, m=m, ef_c=efc,
                       ef_s=efs, quantize='none', dataset=dataset_version)
assert fp_run_a == fp_run_b or noted, 'config changed between runs'  # 两次对比必须来自同一次运行

陷阱补充:把推荐系统的经验直接搬过来

一个高频的来源错误,是把推荐系统的评测直觉套到检索上。两者确实有共同点——都是候选排序——但关键差异更大:推荐的候选池是高度个性化、从行为日志里长出来的,而检索的候选池是全局共享、来自知识库的。

由此产生三个具体偏差。其一,推荐里“曝光多”不等于“相关”,但在检索里“没有交互”通常只是一个中性信号;所以从点击日志反推负样本的做法直接迁移到检索上会引入系统性噪声——这正是难负样本挖掘需要格外小心的原因。其二,推荐优化的是长期留存,检索要的是当下答对,评价函数根本不同。其三,推荐里的多样性、打散等约束在检索场景通常不成立,硬套会让离线指标偏离真实目标。

值得迁移的是其中与业务无关的方法论部分:分层切片、置信区间、配对比较、版本化回归。这些是评测科学本身的共性,与具体业务无关,应当放心借用。

陷阱补充:指标口径在不同工具之间悄悄不一致

最后一个容易被忽略的陷阱发生在工具链层面:同一个叫“Recall@10”的指标,在不同向量库的示例代码、不同评测框架里,定义可能并不相同——有的用余弦相似度排序后统计,有的在归一化与未归一化下各算一次再平均,有的把过滤之后的结果算进去,有的把过滤之前的算进去。

这类差异不会报错,只会让两个数字失去可比性。防御方式是:所有指标只允许由项目内的一份参考实现计算,其他工具的输出只作为中间量(比如原始排名列表)而不是最终指标。报告里同时给出该次运行的若干关键中间量,例如 top-10 命中数、原始距离分数,这样即使口径将来需要修正,历史数据也能重算。

十、一套可以照着跑的评测流水线

10.1 数据层

数据层要固化的东西比想象中多:一个带版本号的文档集合(记录分块规则、分块参数、最大长度截断策略),一个带来源标签和分层标签的 query 集合,一份标注规范和标注一致性报告,以及一个固定划分的调参集与验收集。

文档集合也需要版本化,因为分块策略调整会让所有标注失效。合理的做法是:分块规则变更时,自动触发一次标注影响分析,列出哪些标注的答案文档在新的分块下已不存在或被切碎。

10.2 执行层

执行层的核心是一个配置驱动的实验运行器:输入是候选模型列表 × 配置矩阵,输出是按切片分组的指标表 + 延迟/内存表 + 配置指纹。运行器必须保证同一次运行里所有配置共享同一份文档编码与同一份 query 顺序,否则配对比较不成立。

实验矩阵不要一次爆炸。合理的顺序是:先用固定的默认索引配置做一轮粗筛(通常 3~5 个候选就够),把明显落后的排除;再对留下的候选做维度扫描和 efSearch 扫描,找到各自的质量-延迟曲线;最后才在等延迟预算下做最终比较。这个顺序能把实验次数控制在一个可接受的量级内。

10.3 报告层

一份合格的评测报告至少要包含六部分:配置指纹与数据集版本;按切片分组的指标表(含置信区间);配对比较的显著性与胜负统计;质量-延迟-内存的三维权衡图;关键 badcase 清单及其归因;以及本轮结论明确不能回答哪些问题。

from collections import defaultdict
from typing import Dict, List


def slice_report(runs: Dict[str, float], meta: Dict[str, dict],
                 k: int = 10) -> List[dict]:
    """按来源 / 难度 / 语言 / chunk 长度分层汇总,禁止只报总体均值。"""
    buckets = defaultdict(list)
    for qid, value in runs.items():
        m = meta[qid]
        buckets[('source', m['source'])].append(value)
        buckets[('difficulty', m['difficulty'])].append(value)
        buckets[('lang', m['lang'])].append(value)
        buckets[('chunk_len', m['chunk_len_bucket'])].append(value)
    rows = []
    for (dim, value), vals in sorted(buckets.items()):
        lo, hi = bootstrap_ci(vals)
        rows.append({
            'dimension': dim,
            'slice': value,
            'n': len(vals),
            'recall_at_%d' % k: round(sum(vals) / len(vals), 4),
            'ci95': [round(lo, 4), round(hi, 4)],
        })
    return rows

这套流水线的价值不在于代码量,而在于它让每次结论都可复现、可回溯、可回归。评测体系真正的产出不是那份分数表,而是一个能在半年后回答“我们这次改动到底有没有变好”的机制。

10.4 回归门禁:让评测成为流水线的一部分

评测体系真正发挥价值的那一刻,是它开始阻止坏变更上线的时候。这就要求把评测接进 CI 或发布流程,做成硬门禁,而不是一份仅供参考的报告。

门禁设计要避免一个常见误区:把所有指标都设成自动拦截的阈值。评测集只有几百条,指标天然有波动,过于严格的阈值会让流水线频繁误报,最终大家开始习惯性忽略它——这比没有门禁更糟。合理的做法是双层门禁:对关键指标设绝对下限(通常是较宽松的硬阈值),对相对基线的变化设软告警(下降超过一定幅度时要求人工确认并附上 badcase),再用前面讲过的配对显著性检验来判断这个告警是否值得响应。

门禁还必须携带数据指纹。当语料版本、分块规则或评测集本身发生变化时,指标与历史不可直接比较,此时应当自动跳过对比或切换到新基线,而不是报告一个“大幅下降”的假警报。否则第一次误报之后,大家就会开始习惯性忽略门禁输出,门禁也就失效了。

10.5 一份可以照抄的执行顺序

把上面的内容压成一条可执行路径,大致是这样:先固定一个系统配置基线(模型 + 归一化 + 度量 + 索引 + 重排)并记录指纹;然后用公开基准做一次管线自检,确认实现无误;接着造 300~500 条分层评测集,标注并做一致性校验;再用两三个候选模型跑第一轮粗筛,按切片报告而不是只报均值;然后对留下的候选做维度和延迟预算扫描,画出各自的权衡曲线;在等延迟约束下做最终比较,并用配对检验确认差异显著;最后锁定配置,把这套评测接成回归门禁,并约定验收集的使用次数上限。

这条路径里最容易被跳过的两步,恰好是最值钱的两步:分层切片报告避免了一次错误选型,验收集的次数限制避免了整个体系在半年内失效。它们都不产生立刻可见的数字,却决定了后面所有数字是否可信。

10.6 一个反复出现的误区:把评测当成一次性的项目

很多团队的评测体系是在某次专项优化中搭起来的,做完那一轮就停在了那里。三个月后再有人问“换模型有没有收益”,发现没有可用的评测集,于是又回到凭感觉。这正是前面那些陷阱反复出现的根源——它们不是某个人不小心,而是体系缺失时的必然表现。

要让它活下来,只需要守住两条:一是评测的运行成本足够低,低到可以在每次检索相关改动后顺手跑一遍;二是结果被真的用来做决策,也就是每次选型都写出报告,包括“这一次我们决定不换模型”这种结论。把不换也记录下来尤其重要,它让评测从“推动换模型”变成“判断该不该换”,才是可信的度量。

结语:把选型变成一次可证伪的实验

回头看整篇文章,其实只有一句话:嵌入模型的选型不是一个知识问题,而是一个实验设计问题。通用基准能帮你缩小候选范围,但不能替代决策;榜单名次不能替代在你语料上的测量;单次跑分不能替代配对比较;孤立的模型指标不能替代“嵌入 + 重排 + 索引”的系统指标。

如果只让我留三条可执行建议:其一,在做任何比较之前,先把“模型 + 归一化 + 距离度量 + 索引参数 + 量化 + 重排”固定成一个系统配置,比较的是配置而不是模型;其二,评测集按切片报告,宁可结论保守到“无法区分”,也不要基于噪声做决策;其三,让瓶颈诊断决定投入顺序——Recall 曲线还在爬升就去改索引和重排,Recall 已经饱和但 nDCG 上不去就去改重排和分块,两边都饱和才考虑换嵌入模型。

最后一点提醒:评测体系本身是需要维护的资产。语料在变、query 在变、上游模型在变、标注会过期。只有把评测集版本化、把回归检查做成 CI 的一部分,它才真正能保护你——而不是在某一天变成一个没人敢动的、只能选择性相信的历史文档。

  • 上线前检查清单:配置指纹已记录;评测集按切片报告而非只报均值;做过配对显著性检验;索引参数已锁定并单独扫描过;量化/压缩的召回衰减已量化;端到端答案质量抽样验证通过;编码配置文档端与查询端一致性自检通过;标注失效样本已标记并剔除。