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。这是页面上的文件清单信息,本站没有下载验证。运行库、缓存重复副本以及实际网络传输开销不能从这个数字直接推出来。
准备上线时,可以把测量拆成四份记录。第一次下载记录真正传输的字节和磁盘增量;模型加载记录设备、精度、启用的模态与加载耗时;索引构建记录语料数量、媒体处理及峰值占用;用户搜索记录从输入完成到结果可见的总延迟。四个数字回答四个问题,不应合并成一句“只占几百 MB,搜索很快”。
尤其不要把后台建库速度当作前台搜索速度。前者可能包含读取上千份文件和生成大量向量,后者可能只编码一个短查询。缓存命中与否也要写清:重复同一句查询得到的热启动延迟,无法代表应用刚打开时第一次搜索的等待。
3. 多模态输入先切片,再谈检索
模型卡列出共享的 8,192 token 上下文。默认预算下,单模态约可容纳 29 张图、58 帧视频或 327 秒音频;混合输入共同消耗额度,不能把这些上限相加。视频默认按每秒一帧抽样,音频应为 16 kHz 单声道。
如果目标是寻找视频里某个操作步骤,索引单位应该能指回这个步骤所在的时间段。将整段长视频只存成一条记录,即使检索到了正确文件,也未必能把用户送到想看的几秒钟。为每个片段保存源文件标识、起止时间和预处理版本,比只留一个向量更有用。
图片检索也有类似取舍:找“蓝色自行车”和读取图片中一行小字不是同一种需求。先用少量可公开的样本分别验证场景级与细节级问题,记录失败点,再决定是否提高视觉处理预算。不要在还没有样本结果时,把跨模态支持直接写成任何媒体都能准确识别。
官方的Sentence Transformers 多模态指南提供图片、音频和视频的输入方式。真实实验应以其中当前的处理接口为准,同时记录依赖版本;本文不附自动联网安装或下载权重的命令。
4. 维度是实验变量,不是越小越好
模型卡支持 768、512、256、128 维输出;截断后需要重新做 L2 归一化,查询与文档必须使用相同维度。128 维会明显损失多模态质量,不能只按存储节省决定采用它。推理精度则应使用 BF16 或 FP32,避免 FP16 造成 NaN 或隐蔽的质量下降。
图中的减量是算术,不是数据库压测。实际索引可能保存连接关系、文档标识、文本副本和缓存;缩短向量不会让所有部分同步缩小。即使数组恰好减少六倍,最终索引文件和峰值内存也不会自动按六倍下降。
比较维度时,固定同一份语料、同一组查询、相同提示格式与相同检索方法。先留 768 维基线,再分别测 512、256、128 维,检查每次变化是否把重要结果挤出前三名。只有在质量满足你的任务要求时,较小维度节约的资源才有实际意义。
5. 一组可复用的中文检索小样本
为了让第一次验证有明确目标,下面提供六份虚构产品说明和六个查询。全部由本文编写,不含私人资料。文档 d1 与 d2 刻意都涉及导出,但范围相反;q5 加入排除条件;q6 在语料中没有答案。这些设计可以帮助暴露关键词相近、语义却不同的误匹配。
样本很小,只适合接通流程后的冒烟检查,不是公开基准,也不代表中文、代码或多模态整体能力。relevant 是预先标注的人工期望,并非模型跑出的成绩。不要先看输出再修改答案;如果业务理解发生变化,应保存新数据版本和理由。
[
{
"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": "上传附件之前检查大小与文件格式;上传失败不代表原文件已删除。"
}
]
[
{
"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。
{
"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,不会联网,也不会计算检索成绩。布尔值不会被当作有效数字;零向量和非单位向量会失败。
"""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")
{"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, latencyPASS 只表示这三个手写向量满足格式约定。即使把同一个单位向量复制给所有文档,程序也可能通过,而检索完全没有区分能力。因此真实接入时,需要先做这个结构检查,再看预先标注样本的结果;二者不能互相代替。
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 升序。相近但不同的分数不会被人为并成同名次。
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 重复键、巨大文件的内存上限和不可信输入来源不在这次实验的覆盖范围。
{
"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]}
]
}
"""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))
{
"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]]
}
}
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")
"""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 次真实源码变异都由预期检查拒绝。边界包括巨大有限数及整数、非有限尾部、次正规数与接近零前缀、恰好等于阈值、两种输入顺序的同分,以及显示相同但实际不同的近似分数。这里只保证声明过的单位向量入口合同,不承诺任意尺度输入都能被正常归一化。