Skip to content

Latest commit

 

History

History
89 lines (63 loc) · 3.63 KB

File metadata and controls

89 lines (63 loc) · 3.63 KB

v2.40.1 charter — 入库的最后一个线性项,不靠新增文件解决

v2.39 把向量写入变成 O(新增)后,append_run 只剩一个随库存量线性增长的 成本:去重扫描。当时记的顺延方案是"再引入一个 key 索引文件",并判断 "不划算"。这一版发现根本不需要那个文件。


1. 先量:0.556s 到底花在哪

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

2. 关键观察:根本不需要"整个库"的键

去重键是 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"] 取。

3. 结果

一次典型拍摄(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)。

4. 验收

  • 门禁绿:1553 passed, 5 skipped
  • tests/test_library_index.py 22 → 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。

5. 教训

上一版把这条记成"要新增 key 索引文件,不划算"。那个判断错在把方案和 问题绑死了 —— 真正的成本是"解析了不需要解析的行",而不是"没有索引"。 先量清楚成本构成,比先想解决方案更省事:最后的改动没有新增任何文件。