v2.39 把向量写入变成 O(新增)后,append_run 只剩一个随库存量线性增长的
成本:去重扫描。当时记的顺延方案是"再引入一个 key 索引文件",并判断
"不划算"。这一版发现根本不需要那个文件。
30 万行 / 41MB 的 manifest:
| 环节 | 耗时 |
|---|---|
| 读文件 | 0.014s |
splitlines |
0.031s |
json.loads × 30 万 |
0.525s(73%) |
| 建 key 集合 | 0.115s |
load_manifest 合计 |
0.720s |
主导项是 JSON 解析,不是 I/O。而一次覆盖全部 30 万行的子串预筛只要 0.013s。
去重键是 run_id \x1f filename \x1f int(mtime) —— 以 run_id 开头。
append_run 一次只处理一个 run,所以属于其他 run 的行永远不可能
与传入条目匹配,解析它们是纯浪费。
于是:先用原始字符串筛掉不含本 run 标识的行,只把候选行交给 json.loads。
- 新 run 入库(最常见 —— v2.34 起每挑完一次片都会自动入库):解析 0 行;
- 重索引某个已有 run:只解析那个 run 的行。
不新增文件、不新增需要保持同步的不变量。
理论上文件名里可以恰好含有 "run_id": "别的run" 这段文本。所以预筛只
当作超集:命中后仍要 e.get("run_id") == run_id 复核。假阳性的代价是
一次多余解析,永远不会是错误答案。
needle 用 json.dumps({"run_id": run_id}, ensure_ascii=False)[1:-1] 生成,
保证转义方式与写入时逐字节一致(引号、反斜杠、ensure_ascii=False 下
原样保留的非 ASCII)。
顺带:if not keep_rows 分支原先返回 len(existing),这是最后一个"必须读
全量 manifest"的理由 —— 改成从 meta["n_rows"] 取。
一次典型拍摄(2000 张)入库,库里是多次历史拍摄:
| 库中已有 | v2.39 | v2.40.1 |
|---|---|---|
| 50,000 | 0.159s | 0.062s |
| 150,000 | 0.380s | 0.039s |
| 300,000 | 0.697s | 0.068s |
不再随库存量增长(0.039 < 0.062 只是抖动)。30 万张那档比 v2.39 快约 10×,比 v2.38 的 3.30s 快约 48×。
如果重索引一个本身就占满全库的 run,needle 命中全部 30 万行,等于回到 全量解析:0.708s。同一个库里换成新 run 则是 0.061s。
这个退化只在"整个库只有一个巨型 run 且正在重索引它"时出现,是可接受的; 写在这里,免得下次有人以为它已经是无条件 O(1)。
- 门禁绿:1553 passed, 5 skipped。
tests/test_library_index.py22 → 33,其中等价性与对抗用例:- 快路径与全量扫描逐 run 结果一致;
- 其他 run 的键不会混进来;
- needle 转义对
婚礼·二号/quo"te/back\slash/ 含空格 均正确; - 文件名里植入
x"run_id": "victim"y.jpg的假阳性被正确排除; - 重索引幂等、部分重叠只加新行、mtime 变了必须重新入库(改过的照片 不能被去重掉);
- 撕裂的 manifest 尾行不影响去重;无 manifest 返回空集。
- 端到端:100 张 / 2 run 的库,检索 top1 sim = 1.0000。
- 版本 2.40.0 → 2.40.1 lockstep。
上一版把这条记成"要新增 key 索引文件,不划算"。那个判断错在把方案和 问题绑死了 —— 真正的成本是"解析了不需要解析的行",而不是"没有索引"。 先量清楚成本构成,比先想解决方案更省事:最后的改动没有新增任何文件。