EmbeddingGemma 2 发布:多模态本地检索,先验证内存口径与召回

7.4 亿参数和共享多模态向量空间值得关注,但权重活动 RAM 不等于应用峰值内存。先用固定样本检查召回,再决定维度与接入路线。

1. 发布的是检索模型,落地要看完整链路

Google 于 2026 年 10 月 6 日发布 EmbeddingGemma 2。根据官方发布说明,它基于 Gemma 4,约 7.4 亿参数,面向把文字、代码、图片、音频与视频映射到共享向量空间的任务。本站在 10 月 7 日核对资料;这是文章整理日期,不是模型发布日期。

向量可以用于找相近内容,但它本身不会把搜索结果组织成可靠回答。做一个本地资料助手,仍需要素材读取、分段、索引更新、权限过滤、结果展示,以及必要时另接生成模型。这里值得关注的是多种素材有机会共用一套相似度检索流程,而不是“一个小模型就完成了整个知识库产品”。

本文是一篇发布解读与验证指南。没有下载模型权重,也没有在手机或电脑上运行模型;下文的脚本只检查人为合成向量。官方能力、本文提出的实验方法与实际做过的离线检查分开说明,避免把接入准备误读为性能评测。

2. 191 MB 不能拿来估算下载量

官方发布稿给出的低占用示例有严格限定:在 Pixel 11 Pro 上,量化后的文本权重活动 RAM 约 191 MB,完整多模态权重约 567 MB。它描述的是指定设备、指定处理后的权重占用,不是安装包大小,更不是整个应用的峰值内存。

作为另一个口径,核对时 Hugging Face 文件列表显示主权重文件约 1.49 GB、目录合计约 1.53 GB。这是页面上的文件清单信息,本站没有下载验证。运行库、缓存重复副本以及实际网络传输开销不能从这个数字直接推出来。

下载字节、权重活动 RAM、应用峰值内存是三个不同指标
下载字节、权重活动 RAM、应用峰值内存是三个不同指标。原创资料示意,不是产品截图或实测结果。

准备上线时,可以把测量拆成四份记录。第一次下载记录真正传输的字节和磁盘增量;模型加载记录设备、精度、启用的模态与加载耗时;索引构建记录语料数量、媒体处理及峰值占用;用户搜索记录从输入完成到结果可见的总延迟。四个数字回答四个问题,不应合并成一句“只占几百 MB,搜索很快”。

尤其不要把后台建库速度当作前台搜索速度。前者可能包含读取上千份文件和生成大量向量,后者可能只编码一个短查询。缓存命中与否也要写清:重复同一句查询得到的热启动延迟,无法代表应用刚打开时第一次搜索的等待。

3. 多模态输入先切片,再谈检索

模型卡列出共享的 8,192 token 上下文。默认预算下,单模态约可容纳 29 张图、58 帧视频或 327 秒音频;混合输入共同消耗额度,不能把这些上限相加。视频默认按每秒一帧抽样,音频应为 16 kHz 单声道。

共享预算与分段定位:先决定搜索单位,再配置媒体处理
共享预算与分段定位:先决定搜索单位,再配置媒体处理。原创资料示意,不是产品截图或实测结果。

如果目标是寻找视频里某个操作步骤,索引单位应该能指回这个步骤所在的时间段。将整段长视频只存成一条记录,即使检索到了正确文件,也未必能把用户送到想看的几秒钟。为每个片段保存源文件标识、起止时间和预处理版本,比只留一个向量更有用。

图片检索也有类似取舍:找“蓝色自行车”和读取图片中一行小字不是同一种需求。先用少量可公开的样本分别验证场景级与细节级问题,记录失败点,再决定是否提高视觉处理预算。不要在还没有样本结果时,把跨模态支持直接写成任何媒体都能准确识别。

官方的Sentence Transformers 多模态指南提供图片、音频和视频的输入方式。真实实验应以其中当前的处理接口为准,同时记录依赖版本;本文不附自动联网安装或下载权重的命令。

4. 维度是实验变量,不是越小越好

模型卡支持 768、512、256、128 维输出;截断后需要重新做 L2 归一化,查询与文档必须使用相同维度。128 维会明显损失多模态质量,不能只按存储节省决定采用它。推理精度则应使用 BF16 或 FP32,避免 FP16 造成 NaN 或隐蔽的质量下降。

十万条 float32 向量从 768 维缩到 128 维,原始数组为六分之一
十万条 float32 向量从 768 维缩到 128 维,原始数组为六分之一。原创资料示意,不是产品截图或实测结果。

图中的减量是算术,不是数据库压测。实际索引可能保存连接关系、文档标识、文本副本和缓存;缩短向量不会让所有部分同步缩小。即使数组恰好减少六倍,最终索引文件和峰值内存也不会自动按六倍下降。

比较维度时,固定同一份语料、同一组查询、相同提示格式与相同检索方法。先留 768 维基线,再分别测 512、256、128 维,检查每次变化是否把重要结果挤出前三名。只有在质量满足你的任务要求时,较小维度节约的资源才有实际意义。

5. 一组可复用的中文检索小样本

为了让第一次验证有明确目标,下面提供六份虚构产品说明和六个查询。全部由本文编写,不含私人资料。文档 d1 与 d2 刻意都涉及导出,但范围相反;q5 加入排除条件;q6 在语料中没有答案。这些设计可以帮助暴露关键词相近、语义却不同的误匹配。

样本很小,只适合接通流程后的冒烟检查,不是公开基准,也不代表中文、代码或多模态整体能力。relevant 是预先标注的人工期望,并非模型跑出的成绩。不要先看输出再修改答案;如果业务理解发生变化,应保存新数据版本和理由。

corpus.json · 完整内容
[
  {
    "id": "d1",
    "title": "导出筛选结果",
    "text": "导出 CSV 时只包含当前筛选后的记录,保留界面排序。"
  },
  {
    "id": "d2",
    "title": "导出完整备份",
    "text": "完整备份包含所有记录,与当前筛选和排序无关。"
  },
  {
    "id": "d3",
    "title": "删除恢复",
    "text": "误删记录可在回收站保留期内恢复;清空后不能恢复。"
  },
  {
    "id": "d4",
    "title": "离线搜索",
    "text": "已建立本地索引的文档可在断网后搜索;新文件需先完成索引。"
  },
  {
    "id": "d5",
    "title": "切换外观",
    "text": "深色和浅色主题可以手动切换,跟随系统是另一选项。"
  },
  {
    "id": "d6",
    "title": "附件限制",
    "text": "上传附件之前检查大小与文件格式;上传失败不代表原文件已删除。"
  }
]
queries.json · 完整内容
[
  {
    "id": "q1",
    "text": "我只想下载现在看到的几条记录",
    "relevant": [
      "d1"
    ]
  },
  {
    "id": "q2",
    "text": "筛选以后做全量备份会漏数据吗",
    "relevant": [
      "d2"
    ]
  },
  {
    "id": "q3",
    "text": "刚刚误删的内容还能找回来吗",
    "relevant": [
      "d3"
    ]
  },
  {
    "id": "q4",
    "text": "没有网络还能查已经收录的文件吗",
    "relevant": [
      "d4"
    ]
  },
  {
    "id": "q5",
    "text": "下载当前筛选结果,但不是完整备份",
    "relevant": [
      "d1"
    ]
  },
  {
    "id": "q6",
    "text": "如何开启多人视频会议",
    "relevant": []
  }
]

接入文本检索时,按模型卡区分查询与文档的提示格式。查询使用 SearchQuery;无标题文档可用 Document,有标题文档按 title: {title} | text: {content} 格式准备,避免再重复添加同样的前缀。切换任务时记录实际采用的格式,不要把文本任务前缀原样塞进图片或音频输入。

真实运行后,对 q1 至 q5 各记前 3 个文档 ID、正确项名次和错误原因。可以计算正例查询的 Hit@3:前 3 项包含任一相关文档的查询数,除以这五个正例查询数。这个小分母只用于发现回归,不能支持“达到行业领先”之类结论。

q6 应单独观察。最近邻检索即使没有相关文档也会返回一个最接近的候选;向量格式正确并不代表候选足以回答问题。无答案时如何提示、是否要求证据以及阈值如何选择,都需要额外的业务样本校准。不能拿这一条负例定一个通用于所有应用的相似度阈值。

6. 把运行条件和空白结果一起保留下来

这份记录模板故意保留 null 和空数组,表示尚未测量。不要把它们换成 0,也不要把官方数字填进自己的实测字段。下载量、磁盘权重、进程内存、加速器内存分别填写,备注采样工具及其统计边界。统一使用字节和毫秒,展示时再换算 MB 或 MiB。

run-record.json · 完整内容
{
  "state": "NOT_RUN",
  "model_id": "google/embeddinggemma-2",
  "model_revision": null,
  "runtime_versions": {},
  "device": null,
  "os": null,
  "precision": null,
  "active_modalities": [],
  "dimension": null,
  "normalized": null,
  "query_prompt": null,
  "document_format": null,
  "corpus_sha256": null,
  "queries_sha256": null,
  "actual_download_bytes": null,
  "weight_files_bytes": null,
  "process_peak_memory_bytes": null,
  "accelerator_peak_memory_bytes": null,
  "cold_start_ms": null,
  "query_e2e_ms": [],
  "index_build_ms": null,
  "top3_by_query": {},
  "errors": [],
  "notes": "所有 null 均未测,不是 0;请记录采样工具、边界、批量和缓存状态。"
}

测延迟时,先定义计时边界。建议把查询编码、索引搜索和界面结果准备都纳入 query_e2e_ms,并另记首次启动时间。保留多次原始样本,而不是只挑最快的一次;标注预热次数、批量大小、是否并发、是否开启缓存和媒体预处理是否在计时范围内。

本地处理也不自动等于整个产品绝不联网。素材下载、错误上报、同步和日志上传需要分别检查;使用私人文件前,先确认应用的数据流与权限。检索结果的访问权限应沿用原文件边界,不能因为进入统一索引就向所有使用者展示。

7. 离线先验收向量格式,不能用它替代模型测试

下面脚本只用 Python 标准库,检查查询与文档向量是否为数字、维度是否一致、是否全部有限、L2 范数是否接近 1。它不会加载 EmbeddingGemma 2,不会联网,也不会计算检索成绩。布尔值不会被当作有效数字;零向量和非单位向量会失败。

verify_vectors.py · 完整内容
"""Standard-library validation only. Does not load or evaluate any model."""
import argparse
import json
import math
from pathlib import Path


def validate(data, dim, tolerance=1e-4):
    if not isinstance(data, dict):
        raise ValueError("root must be an object")
    count = 0
    for name in ("query_vectors", "document_vectors"):
        rows = data.get(name)
        if not isinstance(rows, list) or not rows:
            raise ValueError(name + " must be a non-empty array")
        for i, row in enumerate(rows):
            label = f"{name}[{i}]"
            if not isinstance(row, list) or len(row) != dim:
                raise ValueError(label + " has wrong dimension")
            if any(type(x) not in (int, float) for x in row):
                raise ValueError(label + " contains non-numeric values")
            try:
                finite = all(math.isfinite(x) for x in row)
                norm = math.hypot(*row)
            except (OverflowError, TypeError):
                raise ValueError(label + " has unsupported numeric range")
            if not finite or not math.isfinite(norm):
                raise ValueError(label + " contains non-finite values")
            if abs(norm - 1.0) > tolerance:
                raise ValueError(label + " is not L2 normalized")
            count += 1
    return count


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("file", type=Path)
    parser.add_argument("--dim", required=True, type=int)
    parser.add_argument("--synthetic", action="store_true")
    args = parser.parse_args()
    if args.dim <= 0:
        parser.error("dimension must be positive")
    data = json.loads(args.file.read_text(encoding="utf-8"))
    if args.synthetic:
        if data.get("kind") != "synthetic":
            parser.error("synthetic input must have kind=synthetic")
    elif args.dim not in (128, 256, 512, 768):
        parser.error("model output requires 128/256/512/768 dimensions")
    try:
        count = validate(data, args.dim)
    except ValueError as exc:
        parser.exit(1, "FAIL: " + str(exc) + "\n")
    print(f"PASS: {count} vectors; dim={args.dim}; numeric/finite/unit-norm")
    print("NOT TESTED: model inference, retrieval quality, memory, latency")
synthetic.json · 完整内容
{"kind":"synthetic","query_vectors":[[1,0,0,0]],"document_vectors":[[0,1,0,0],[0.6,0.8,0,0]]}
离线运行 · 人为四维输入
python3 verify_vectors.py synthetic.json --dim 4 --synthetic

四维仅为检查程序的合成夹具,故意与模型支持的输出维度区分。真实模型导出后,请去掉 --synthetic,并将 --dim 设为实际使用的 768、512、256 或 128。输入仍需包含 query_vectors 与 document_vectors 两个非空二维数组。

本次实际运行上面的合成输入,输出如下:

PASS: 3 vectors; dim=4; numeric/finite/unit-norm
NOT TESTED: model inference, retrieval quality, memory, latency

PASS 只表示这三个手写向量满足格式约定。即使把同一个单位向量复制给所有文档,程序也可能通过,而检索完全没有区分能力。因此真实接入时,需要先做这个结构检查,再看预先标注样本的结果;二者不能互相代替。

8. 接入前还要核对可用性与使用边界

Google AI Edge 的接入说明把 Android ML Kit 支持描述为未来几周提供,不能据此声称所有 Android 开发者已经可以通过该入口直接上线。已有演示、模型文件可获取与某个生产接口全面可用,是不同状态。

官方将模型标为 Apache 2.0;模型卡同时要求部署遵循 Gemma Prohibited Use Policy。选择商业项目接入前,应阅读当前模型卡与其中链接的条款,不能把许可证名称概括成“任何用途都无限制”。

对独立开发者,一个稳妥的起点是先选一条明确查询链路:例如中文问题找本地说明文档。确认结果、资源预算和更新流程后,再加图片或音频。共享向量空间减少了跨素材检索的一部分工程负担,但不会替你决定索引粒度、错误处理、隐私权限和产品验收标准。

9. 补充实验:格式全通过,前三名为什么仍会变

前面的检查器回答向量能否进入计算,接下来还要检查计算的含义。假设应用想把 768 维索引缩到 128 维,并用较短向量选候选,再用完整向量重排。这里有两个独立风险:切片之后直接算点积,会把前缀长度带进分数;即使正确归一化,截断也可能改变候选名单,后面的重排无法看见已经被排除的文档。

本补充于 2026-10-14 实际运行,使用 Python 3.12.14 标准库。所有向量都由本文手写,只是把三个手选分量放进 768 个位置、其余补零的稀疏算术夹具;这种补零是教学装置,不模拟真实嵌入的分布,没有调用模型或下载权重。它证明计算分支、失败条件与恢复方法,不衡量 EmbeddingGemma 2 的语义质量,也不推断真实语料会发生同样的排序变化。

实验有一个查询和三个文档,只有第 1、2、129 个分量可能非零。JSON 中位置从零开始,所以写成 0、1、128。扩展时其余 765 个位置补零。保留前 128 维正好删掉第三个有效分量;保留前 256 维则包含它。这种构造让读者不需要看模型输出就能独立算出答案。

  • 查询 q 的三个值是 (0.6, 0, 0.8)
  • d-front 是 (0.8, 0.6, 0),它的全部长度都留在前缀
  • d-tail 是 (0.48, 0.64, 0.6),第三个有效分量对完整得分有正贡献
  • d-decoy 是 (0.6, 0, -0.8),第三个有效分量与查询方向相反

四个完整向量都是单位向量。例如 d-tail 的长度平方是 0.48² + 0.64² + 0.6² = 1。名称只是帮助阅读算式,不代表某份真实文档的内容或相关性标签。

10. 先手算三个榜单,再运行程序

完整向量的余弦相似度可以直接用点积计算。d-tail 得 0.6 × 0.48 + 0.8 × 0.6 = 0.768;d-front 得 0.48;d-decoy 得 0.36 − 0.64 = −0.28。因此完整榜单依次为 d-tail、d-front、d-decoy。

截到 128 维后,查询前缀长度为 0.6,三个文档的前缀长度分别为 1、0.8、0.6。重新归一化后,查询变为 (1, 0),d-front 仍是 (0.8, 0.6),d-tail 变为 (0.6, 0.8),d-decoy 变为 (1, 0)。正确的短向量分数是 1、0.8、0.6,名次变成 d-decoy、d-front、d-tail。归一化使计算符合余弦定义,但没有把被删掉的信息补回来。

故意漏掉重归一化,直接对切片求点积,则 d-front 得 0.48,d-decoy 得 0.36,d-tail 得 0.288。数字有限,顺序也看似合理,却是另一个榜单。它既不等于完整向量榜单,也不等于短向量的余弦榜单。程序把这个分支明确标成 wrong_raw_128,并作为负控保留。

对固定查询而言,是否将查询乘上一个正常数不会改变点积排名;这里造成文档相对排序变化的关键,是各文档前缀的长度不同。不过查询归一化仍影响分数本身,不能据此让阈值、跨查询分数比较或余弦合同沿用未归一化值。Faiss 官方度量说明解释了内积、余弦与向量长度的区别,并说明单位向量的平方 L2 距离与内积如何换算。本实验没有安装或调用 Faiss。

expected.json 按以上算式独立写定,不由实验输出生成。检查器还用 Fraction 精确分数独立复核三个得分字典,并以绝对误差 10⁻¹²、相对误差 0 对照未舍入实际得分;这部分不调用待测的归一化、点积或排序函数。检查器还用 Fraction 精确分数独立复核三个得分字典;这部分不调用待测的归一化、点积或排序函数。报告保留六位小数,排序使用未四舍五入的分数;完全同分时按文档 ID 升序。相近但不同的分数不会被人为并成同名次。

完整余弦、短向量余弦和漏归一化得到三种排序;Top-2 遗漏完整第一名 d-tail
图:归一化错误、合法的截断排序变化、候选预算遗漏必须分别诊断。绿色行始终是同一个合成文档 d-tail。

11. 重排只能修正名单内的顺序

现在固定正确的 128 维榜单,分别只交给完整向量重排器 1、2、3 个候选。只交 1 个时,结果仍是 d-decoy,完整分数 −0.28。交 2 个时,重排会把 d-front 放到 d-decoy 前面,但完整向量第一名 d-tail 仍不在名单中。交 3 个才恢复完整榜单。

这就是本例的可观察恢复路径:扩大第一阶段候选预算,使被排除的 d-tail 重新可见,再用同一份原始完整向量评分。另一条路径是改用保留第 129 个分量的 256 维向量;本夹具在 256 和 512 维下的排序都与完整向量相同。这是手写数据的性质,不表示这些维度在任意模型或语料上无损。

Sentence Transformers 的 Matryoshka 说明介绍了短向量筛选、完整向量重排的两阶段思路。本例把候选丢失单独暴露出来:更换重排器、延长生成回答或修改展示顺序,都不能让未送入重排器的文档自动回来。

这里的“恢复”仅指与完整向量精确搜索结果一致,不能把完整向量第一名当成人工正确答案。真实系统应分别记录候选相对完整搜索的覆盖率,以及候选对人工相关文档的覆盖情况。两者回答不同问题。本文不另造相关性标注,也不把三个合成向量算成模型召回成绩。

12. 复制五个文件,复跑排序与失败分支

将下列五个文件保存到同一个新目录。fixture.json 用紧凑形式写出全部有效分量,rank_lab.py 负责展开、校验、归一化和评分。它要求原始向量长度为 768 且为单位向量(范数允许 10⁻⁹ 的浮点误差),不会替任意量级的输入做通用稳健归一化(范数允许 10⁻⁹ 的浮点误差),不会替任意量级的输入做通用稳健归一化,目标维度属于 128、256、512、768;布尔值、非有限值和重复文档 ID 都拒绝。原始完整向量不被原地修改,三种候选预算都从同一份输入重算。

准备阶段先验证整个候选批次,再返回排序;发现坏向量不会静默丢掉对应文档。prepare 是本实验的受限入口,不是完整的通用向量文件导入器。JSON 重复键、巨大文件的内存上限和不可信输入来源不在这次实验的覆盖范围。

fixture.json · 完整文件
{
  "kind": "synthetic-not-model-output",
  "source_dim": 768,
  "positions": [0, 1, 128],
  "query": [0.6, 0, 0.8],
  "documents": [
    {"id": "d-front", "values": [0.8, 0.6, 0]},
    {"id": "d-tail", "values": [0.48, 0.64, 0.6]},
    {"id": "d-decoy", "values": [0.6, 0, -0.8]}
  ]
}
rank_lab.py · 完整文件
"""Offline synthetic arithmetic. No model, network or approximate index."""
import json
import math
from pathlib import Path

DIMS = (128, 256, 512, 768)


def expand(values, positions, dim):
    if type(dim) is not int or dim != 768:
        raise ValueError("source_dim")
    if (not isinstance(positions, list) or not positions
            or len(positions) != len(values)
            or any(type(p) is not int or not 0 <= p < dim for p in positions)
            or len(set(positions)) != len(positions)):
        raise ValueError("positions")
    row = [0.0] * dim
    for p, v in zip(positions, values):
        row[p] = v
    return row


def prepare(row, dim):
    if type(dim) is not int or dim not in DIMS:
        raise ValueError("target_dim")
    if not isinstance(row, list) or len(row) != 768:
        raise ValueError("source_shape")
    if any(type(x) not in (int, float) for x in row):
        raise ValueError("numeric_type")
    try:
        if not all(math.isfinite(x) for x in row):
            raise ValueError("non_finite")
        norm = math.hypot(*row)
    except OverflowError:
        raise ValueError("numeric_range") from None
    if not math.isfinite(norm) or abs(norm - 1) > 1e-9:
        raise ValueError("source_not_unit")
    prefix = row[:dim]
    norm = math.hypot(*prefix)
    if norm < 1e-12:
        raise ValueError("prefix_too_small")
    return [x / norm for x in prefix]


def dot(a, b):
    if len(a) != len(b):
        raise ValueError("score_shape")
    return math.fsum(x * y for x, y in zip(a, b))


def score_rows(q, docs):
    return sorted(((name, dot(q, v)) for name, v in docs),
                  key=lambda pair: (-pair[1], pair[0]))


def rank(q, docs, dim):
    ids = [name for name, _ in docs]
    if not ids or any(type(n) is not str or not n for n in ids):
        raise ValueError("document_id")
    if len(set(ids)) != len(ids):
        raise ValueError("duplicate_id")
    # Prepare the whole batch first; no silently dropped bad document.
    nq = prepare(q, dim)
    nd = [(name, prepare(v, dim)) for name, v in docs]
    return score_rows(nq, nd)


def load_fixture():
    f = json.loads(Path(__file__).with_name("fixture.json").read_text())
    if f["kind"] != "synthetic-not-model-output":
        raise ValueError("fixture_kind")
    def unpack(v):
        return expand(v, f["positions"], f["source_dim"])
    return unpack(f["query"]), [(d["id"], unpack(d["values"]))
                                for d in f["documents"]]


def shown(rows):
    # Round only for reporting; ranking used unrounded scores.
    return [[name, round(score, 6)] for name, score in rows]


def experiment():
    q, docs = load_fixture()
    full = rank(q, docs, 768)
    short = rank(q, docs, 128)
    # Explicitly wrong branch: the validated unit vectors are sliced,
    # but their prefixes are NOT normalized before dot-product scoring.
    wrong = score_rows(q[:128], [(n, v[:128]) for n, v in docs])
    reranked = {}
    for budget in (1, 2, 3):
        selected = {name for name, _ in short[:budget]}
        reranked[str(budget)] = shown(rank(
            q, [(n, v) for n, v in docs if n in selected], 768))
    return {"full_768": shown(full), "cosine_128": shown(short),
            "wrong_raw_128": shown(wrong), "reranked": reranked}


if __name__ == "__main__":
    print(json.dumps(experiment(), indent=2))
expected.json · 完整文件
{
  "full_768": [["d-tail", 0.768], ["d-front", 0.48], ["d-decoy", -0.28]],
  "cosine_128": [["d-decoy", 1.0], ["d-front", 0.8], ["d-tail", 0.6]],
  "wrong_raw_128": [["d-front", 0.48], ["d-decoy", 0.36], ["d-tail", 0.288]],
  "reranked": {
    "1": [["d-decoy", -0.28]],
    "2": [["d-front", 0.48], ["d-decoy", -0.28]],
    "3": [["d-tail", 0.768], ["d-front", 0.48], ["d-decoy", -0.28]]
  }
}
check_rank.py · 完整文件
import json
import math
from fractions import Fraction as F
from pathlib import Path
from rank_lab import (dot, experiment, expand, load_fixture, prepare,
                      rank, shown, score_rows)

checks = 0

def check(condition, label):
    global checks
    if not condition:
        raise AssertionError(label)
    checks += 1


def rejected(code, fn):
    try:
        fn()
    except ValueError as e:
        check(str(e) == code, (code, str(e)))
    else:
        raise AssertionError("accepted: " + code)


expected = json.loads(Path(__file__).with_name("expected.json").read_text())
# Independent analytic oracle: exact rationals, no rank/dot/prepare call.
# Coordinates are hand-selected; these are not sampled embeddings.
check(F(3,5)**2 + F(4,5)**2 == 1, "query unit identity")
check(F(12,25)**2 + F(16,25)**2 + F(3,5)**2 == 1,
      "tail unit identity")
full_scores = {"d-tail": F(3,5)*F(12,25) + F(4,5)*F(3,5),
               "d-front": F(3,5)*F(4,5),
               "d-decoy": F(3,5)*F(3,5) - F(4,5)*F(4,5)}
short_scores = {"d-decoy": F(1), "d-front": F(4,5), "d-tail": F(3,5)}
raw_scores = {"d-front": F(12,25), "d-decoy": F(9,25), "d-tail": F(36,125)}
for key, exact in (("full_768", full_scores), ("cosine_128", short_scores),
                   ("wrong_raw_128", raw_scores)):
    check({name: F(str(score)) for name, score in expected[key]} == exact,
          "independent rational oracle: " + key)
q_raw, docs_raw = load_fixture()
for rows, oracle in ((rank(q_raw, docs_raw, 768), full_scores),
                     (rank(q_raw, docs_raw, 128), short_scores),
                     (score_rows(q_raw[:128], [(n, v[:128]) for n, v in docs_raw]), raw_scores)):
    for name, score in rows:
        check(math.isclose(score, float(oracle[name]), rel_tol=0, abs_tol=1e-12),
              "unrounded rational score: " + name)
actual = experiment()
check(actual == expected, "fixed hand-computed oracle")
q, docs = load_fixture()
check(actual["wrong_raw_128"] != expected["cosine_128"], "negative control")
check(actual["reranked"]["2"][0][0] != "d-tail", "missing candidate remains missing")
check(actual["reranked"]["3"] == expected["full_768"], "wider shortlist recovers")
check(shown(rank(q, list(reversed(docs)), 128)) == expected["cosine_128"], "row order")
check(shown(rank(q, docs, 256)) == expected["full_768"], "retain informative coordinate")
check(shown(rank(q, docs, 512)) == expected["full_768"], "512 arithmetic")
check(rank(q, [("z", docs[0][1]), ("a", docs[0][1])], 128)[0][0] == "a", "tie break")
rejected("duplicate_id", lambda: rank(q, [docs[0], docs[0]], 128))
rejected("source_shape", lambda: prepare(q[:-1], 128))
rejected("target_dim", lambda: prepare(q, True))
rejected("target_dim", lambda: prepare(q, 127))
rejected("numeric_type", lambda: prepare([True] + q[1:], 128))
rejected("non_finite", lambda: prepare([float("nan")] + q[1:], 128))
rejected("non_finite", lambda: prepare([float("inf")] + q[1:], 128))
rejected("source_not_unit", lambda: prepare([2.0] + [0.0] * 767, 128))
rejected("source_not_unit", lambda: prepare([0.0] * 768, 128))
rejected("score_shape", lambda: dot([1], [1, 0]))
rejected("positions", lambda: expand([1, 0], [0, 0], 768))
zero_prefix = expand([0, 0, 1], [0, 1, 128], 768)
rejected("prefix_too_small", lambda: rank(q, docs + [("d-zero", zero_prefix)], 128))
rejected("prefix_too_small", lambda: rank(zero_prefix, docs, 128))
recovered = shown(rank(q, docs + [("d-zero", zero_prefix)], 256))
check(recovered == [["d-zero", 0.8], ["d-tail", 0.768],
                    ["d-front", 0.48], ["d-decoy", -0.28]], "whole-batch dimension recovery")
# Numerical-policy boundaries: this lab accepts unit source vectors only.
rejected("numeric_range", lambda: prepare([10**1000] + q[1:], 128))
rejected("source_not_unit", lambda: prepare([1e308] + q[1:], 128))
rejected("source_not_unit", lambda: prepare([1.7e308, 1.7e308] + [0.0]*766, 128))
rejected("non_finite", lambda: prepare(q[:128] + [float("nan")] + q[129:], 128))
rejected("non_finite", lambda: prepare(q[:128] + [float("-inf")] + q[129:], 128))
rejected("prefix_too_small", lambda: prepare(expand([5e-324, 0, 1], [0, 1, 128], 768), 128))
rejected("prefix_too_small", lambda: prepare(expand([0.5e-12, 0, 1], [0, 1, 128], 768), 128))
check(prepare(expand([1e-12, 0, 1], [0, 1, 128], 768), 128)[0] == 1,
      "exact prefix threshold accepted")
check(rank(q, [("a", docs[0][1]), ("z", docs[0][1])], 128)[0][0] == "a",
      "tie reverse-order independence")
near_docs = [(name, expand([score, math.sqrt(1-score*score), 0], [0,1,128], 768))
             for name, score in (("a", 0.8), ("b", 0.80000001))]
check(shown(rank(expand([1,0,0], [0,1,128], 768), near_docs, 128)) ==
      [["b", 0.8], ["a", 0.8]], "rank before reporting round")
print(f"PASS {checks} checks; fixed oracle, negative control, rejection and recovery")
print("NOT TESTED: model inference, semantic relevance, ANN, latency, memory")
check_mutations.py · 完整文件
"""Run real source mutants in isolated directories, with and without -O."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile

ROOT = Path(__file__).resolve().parent
SOURCE = (ROOT / "rank_lab.py").read_text()
MUTANTS = {
    "hidden_score_drift": (
        "return math.fsum(x * y for x, y in zip(a, b))",
        "return math.fsum(x * y for x, y in zip(a, b)) + 1e-8",
        "unrounded rational score"),
    "round_before_ranking": (
        "key=lambda pair: (-pair[1], pair[0])",
        "key=lambda pair: (-round(pair[1], 6), pair[0])", "rank before reporting round"),
    "missing_renormalization": (
        "return [x / norm for x in prefix]", "return prefix",
        "unrounded rational score"),
    "truncation_ignored": (
        "prefix = row[:dim]", "prefix = row[:]",
        "unrounded rational score"),
    "reranker_sees_excluded_documents": (
        "for n, v in docs if n in selected", "for n, v in docs",
        "fixed hand-computed oracle"),
    "unstable_ties": (
        "key=lambda pair: (-pair[1], pair[0])", "key=lambda pair: -pair[1]",
        "tie break"),
    "silent_shape_mismatch": (
        'raise ValueError("score_shape")', 'pass', "accepted: score_shape"),
    "silent_zero_prefix": (
        'raise ValueError("prefix_too_small")', 'return prefix',
        "accepted: prefix_too_small"),
    "nonfinite_silent_zero": (
        'raise ValueError("non_finite")', 'return [0.0] * dim',
        "accepted: non_finite"),
}


def run(directory, optimized):
    command = [sys.executable] + (["-O"] if optimized else []) + ["check_rank.py"]
    return subprocess.run(command, cwd=directory, capture_output=True, text=True)


def main():
    killed = 0
    for optimized in (False, True):
        mode = "-O" if optimized else "normal"
        baseline = run(ROOT, optimized)
        if baseline.returncode != 0:
            raise RuntimeError("baseline failed: " + baseline.stderr)
        print(f"BASELINE {mode}: " + baseline.stdout.splitlines()[0])
        for name, (old, new, diagnostic) in MUTANTS.items():
            if SOURCE.count(old) != 1:
                raise RuntimeError("mutation anchor is not unique: " + name)
            with tempfile.TemporaryDirectory() as tmp:
                directory = Path(tmp)
                for filename in ("fixture.json", "expected.json", "check_rank.py"):
                    shutil.copyfile(ROOT / filename, directory / filename)
                (directory / "rank_lab.py").write_text(SOURCE.replace(old, new))
                result = run(directory, optimized)
                # A syntax/import/crash failure is not counted as a killed mutant.
                final_line = result.stderr.strip().splitlines()[-1] if result.stderr else ""
                if (result.returncode == 0 or
                        not final_line.startswith("AssertionError:") or
                        diagnostic not in final_line):
                    raise RuntimeError(f"mutant not caught by intended check: {name} {mode}\n"
                                       + result.stdout + result.stderr)
                print(f"KILLED {name} {mode}: {final_line}")
                killed += 1
    print(f"PASS {killed} mutation runs; nine actual source changes rejected in both modes")


if __name__ == "__main__":
    main()

13. 零前缀不能用补一个极小数悄悄带过

测试另外构造 d-zero = (0, 0, 1),第三个值仍放在第 129 个位置。它的完整长度为 1,但前 128 维全为零,没有可定义的余弦方向。查询出现同样情况也一样。实现将前缀范数低于 10⁻¹² 的输入拒绝为 prefix_too_small;恰好等于 10⁻¹² 时接受;恰好等于 10⁻¹² 时接受;这个下限是实验的明确数值策略,不是模型官方阈值。

不要只为了避免除零而把分母换成极小数,接着把零向量当作正常文档写进索引。这样虽然能让程序继续,却改了接受条件,还容易让静默缺失混进完整率。本实验让整批 128 维计算失败,并验证这个失败发生在文档端和查询端。

恢复分支把整批查询和文档一起改为 256 维,重新计算,而不是只给 d-zero 使用另一种维度。此时 d-zero 的分数是 0.8,完整榜单为 d-zero 0.8、d-tail 0.768、d-front 0.48、d-decoy −0.28。原有的三个文档都保留,新增文档没有凭空消失。

真实入口遇到近零前缀时,还应查清导出精度、截断位置与上游向量是否异常。换维度前保存失败输入的受控诊断与采用的策略;确实要隔离某条文档时,应显式报告缺失条数和身份,不能把减少后的索引包装成同一完整语料。本文没有测试真实模型是否会输出这类前缀。

14. 实际输出、验证边界与接入时的检查顺序

在这五个文件所在目录运行:

python3 rank_lab.py
python3 check_rank.py
python3 -O check_rank.py
python3 check_mutations.py

第一次命令给出的完整排序记录如下,可与手写 expected.json 逐项核对:

{
  "full_768": [
    [
      "d-tail",
      0.768
    ],
    [
      "d-front",
      0.48
    ],
    [
      "d-decoy",
      -0.28
    ]
  ],
  "cosine_128": [
    [
      "d-decoy",
      1.0
    ],
    [
      "d-front",
      0.8
    ],
    [
      "d-tail",
      0.6
    ]
  ],
  "wrong_raw_128": [
    [
      "d-front",
      0.48
    ],
    [
      "d-decoy",
      0.36
    ],
    [
      "d-tail",
      0.288
    ]
  ],
  "reranked": {
    "1": [
      [
        "d-decoy",
        -0.28
      ]
    ],
    "2": [
      [
        "d-front",
        0.48
      ],
      [
        "d-decoy",
        -0.28
      ]
    ],
    "3": [
      [
        "d-tail",
        0.768
      ],
      [
        "d-front",
        0.48
      ],
      [
        "d-decoy",
        -0.28
      ]
    ]
  }
}

第二次命令的本次实际输出:

PASS 46 checks; fixed oracle, negative control, rejection and recovery
NOT TESTED: model inference, semantic relevance, ANN, latency, memory

46 项检查包括手写预期、漏归一化负控、候选遗漏、扩大预算恢复、输入顺序不变性、同分 ID 顺序、256 与 512 维结果、重复 ID、形状与维度错误、布尔值、NaN、Infinity、非单位输入、重复坐标,以及查询或文档的零前缀。它们检验本地算术与代码路径,不是 46 个真实检索任务。

接入实际模型时,先将导出的原始向量与文档 ID 固定到同一版本,再分别建立各维度的精确搜索基线;确认重归一化和候选预算之后,才对照近似索引。否则无法分清差异来自模型、截断、近似搜索还是候选数量。本例全部遍历三个文档,没有近似索引误差,也没有并发、压缩或量化测试。

EmbeddingGemma 2 官方模型卡列出支持的截断维度,要求截断后重归一化,并提醒查询与文档维度一致。本补充在 2026-10-14 核对这些约束;仅依据它们安排教学实验,没有把官方质量表当作本站实测。最终维度与候选预算仍需用自己的任务数据决定,不能从这组三文档算术推导出推荐值。

把错误真的写进程序,再确认检查会失败

check_mutations.py 每次在临时目录里改一处真实源码,并运行同一检查器;不是只比较两个预先写好的输出。九个变体分别漏掉归一化、忽略截断、让重排偷看全部文档、破坏同分稳定排序、悄悄接受维度不一致、零前缀或非有限值,先四舍五入再排序,以及给得分偷偷加上 10⁻⁸。只有在指定检查抛出 AssertionError 时才计为检出,语法错误、导入失败或无关崩溃不算通过。所有检查使用显式条件和异常,不依赖会被 -O 移除的 assert 语句。

BASELINE normal: PASS 46 checks; fixed oracle, negative control, rejection and recovery
KILLED hidden_score_drift normal: AssertionError: unrounded rational score: d-tail
KILLED round_before_ranking normal: AssertionError: rank before reporting round
KILLED missing_renormalization normal: AssertionError: unrounded rational score: d-front
KILLED truncation_ignored normal: AssertionError: unrounded rational score: d-tail
KILLED reranker_sees_excluded_documents normal: AssertionError: fixed hand-computed oracle
KILLED unstable_ties normal: AssertionError: tie break
KILLED silent_shape_mismatch normal: AssertionError: accepted: score_shape
KILLED silent_zero_prefix normal: AssertionError: accepted: prefix_too_small
KILLED nonfinite_silent_zero normal: AssertionError: accepted: non_finite
BASELINE -O: PASS 46 checks; fixed oracle, negative control, rejection and recovery
KILLED hidden_score_drift -O: AssertionError: unrounded rational score: d-tail
KILLED round_before_ranking -O: AssertionError: rank before reporting round
KILLED missing_renormalization -O: AssertionError: unrounded rational score: d-front
KILLED truncation_ignored -O: AssertionError: unrounded rational score: d-tail
KILLED reranker_sees_excluded_documents -O: AssertionError: fixed hand-computed oracle
KILLED unstable_ties -O: AssertionError: tie break
KILLED silent_shape_mismatch -O: AssertionError: accepted: score_shape
KILLED silent_zero_prefix -O: AssertionError: accepted: prefix_too_small
KILLED nonfinite_silent_zero -O: AssertionError: accepted: non_finite
PASS 18 mutation runs; nine actual source changes rejected in both modes

46 项正向与边界检查在普通模式及 -O 模式下都通过,18 次真实源码变异都由预期检查拒绝。边界包括巨大有限数及整数、非有限尾部、次正规数与接近零前缀、恰好等于阈值、两种输入顺序的同分,以及显示相同但实际不同的近似分数。这里只保证声明过的单位向量入口合同,不承诺任意尺度输入都能被正常归一化。