Skip to content

perf(cite): cbeta_id 索引消除全表扫描;置信度恒为 1.00 时不再逐行标注 - #6

Merged
xr843 merged 13 commits into
masterfrom
feat/cite-index-and-confidence
Jul 26, 2026
Merged

perf(cite): cbeta_id 索引消除全表扫描;置信度恒为 1.00 时不再逐行标注#6
xr843 merged 13 commits into
masterfrom
feat/cite-index-and-confidence

Conversation

@xr843

@xr843 xr843 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

两项独立的小改进,共同点是都在修正"实测暴露、但没人报告过"的问题。

1. cite 不再全表扫描。 parallels 表除 FTS 外没有任何索引,citeWHERE cbeta_id = ?1 COLLATE NOCASE 因此扫描全部 908,620 行——实测端到端 2.34 s。现在建 (cbeta_id COLLATE NOCASE, juan_num, id) 索引;COLLATE NOCASE 不可省,BINARY 索引服务不了该比较的等值查找,实测只能退化成索引扫描。

索引是对已下载产物的本地优化,不进 schema.sql:那是兼容性校验的参照物(所有已下载的数据文件都没有这个索引,把它列进去会让"缺索引"看起来像"数据不兼容"),而且该文件会被 Python 导出管线逐字执行,索引若在 schema 里会在 908,620 行插入之前建立、显著拖慢导出。

两条创建路径:安装/更新时在候选文件上建好(失败传播并触发候选清理,不发布半成品);已有数据在首次 cite 时补建(失败一律静默退回全表扫描)。提示只在索引真的建成之后才打印。

实测:首次 cite 0.632 s(含建索引,数据目录 +17.2 MB)→ 之后 0.001 s,约 632 倍。有无索引的输出 diff 完全一致。

2. 恒为 1.0 的置信度标签不再逐行输出。 全库 908,620 行的 confidence 都是 1.0,于是每行末尾的 [MITRA 1.00] 不携带任何信息。现在标签只在能显示出非 1.00 的数字时才出现;判据是格式化后的字符串而非原始浮点,避免 0.9951 这类值"显示成 [MITRA 1.00] 却又被当作有信息量"的自相矛盾。--jsonconfidence 字段与 query.rs 里按置信度排序的逻辑一律未动(后者现在是空转,但对未来有真实分数的数据是正确的)。

一个在审查中发现并修掉的严重缺陷

懒建索引让查询路径第一次向活跃数据库写入——此前 parallel/texts/cite 全都只读打开。这两件事各自没问题,合起来不安全:

数据库是 rollback-journal 模式。17 MB 的 CREATE INDEX 打在 588 MB 文件上会超出默认 2 MB 页缓存 → 溢出 → 同步日志并写 magic header → 日志变"热"。进程若在该窗口死亡(Ctrl-C、agent 超时、OOM、断电),热日志留在磁盘,而只读连接无法回滚它。后果链已被复现:所有查询 exit 1 并报"dataset incompatibility";data update 无效(替换只 rename 主文件,从不碰 -journal);随后旧日志会被重放进刚下载的新库,造成 database disk image is malformed;data clean 同样无效(清理逻辑只匹配候选/下载临时文件的 sidecar)。唯一出路是手动 rm data.sqlite-journal,而工具里没有任何提示。

三项修复:

  • 只读打开遇热日志时自愈——以 SQLITE_OPEN_READ_WRITE(不带 CREATE)重开让 SQLite 自己回滚,再转回只读。代码从不自己删日志;不带 CREATE 意味着自愈不会凭空造出空库。判据不是"重开是否成功"而是"重开后能否真的读一次"——SQLite 在内核拒绝 O_RDWR 时会静默降级为只读并返回成功(sqlite3.cunixOpen),只看 open 会把真正只读的安装一并吞掉。真只读安装仍得到原有错误。
  • 把 sidecar 清理延伸到活跃路径(clean_datainstall_candidate 替换前后),使 data clean / data update 成为真正的恢复手段,且永不把新库发布到一份外来日志之下。该函数是 pub(super),"只在这两处删"由模块系统强制——查询打开路径上不存在任何 sidecar 删除。
  • 改掉误导性文案,不再声称数据不兼容,也不再建议重下 183 MB。

Verification

rustup run 1.95.0 cargo test --all --locked        # MSRV,229 passed
rustup run 1.96.0 cargo fmt --all --check          # 通过
rustup run 1.96.0 cargo clippy --all-targets --locked -- -D warnings   # 0 警告

本机默认 stable 为 1.88,低于 MSRV 1.95,故用 rustup run 指定工具链;MSRV 检查用的正是 1.95.0。

真实数据验证(588 MB / 908,620 行 / 1016 部 Taishō):

场景 结果
首次 cite(含建索引) 0.632 s,+17.2 MB
第二次 cite 0.001 s(约 632 倍)
有无索引输出 diff 完全一致
真实输出中 [MITRA 出现次数 0
只读目录 静默降级,退出码 0
真实缓存 全程未被写入(大小不变、无 .lock 残留)

并发经实测:6 个 parallel 读进程在建索引期间并发,全部退出 0、stderr 干净。

Checklist

  • The change is focused and contains no credentials, generated datasets, build artifacts, or public vulnerability details.
  • I added or updated tests for behavior changes, or explained why tests are not applicable.
  • cargo +stable fmt --all --check passes.(以 1.96.0 代 stable,理由见上)
  • cargo +stable clippy --all-targets --locked -- -D warnings passes.(同上)
  • cargo +stable test --all --locked passes.(同上)
  • cargo +1.95.0 test --all --locked passes when the change affects Rust code or dependencies.
  • Relevant Python and shell checks from CONTRIBUTING.md pass.(normalize.rsdata-pipeline 均未改动,归一化 Rust/Python 一致性契约未受影响)
  • Documentation and the unreleased changelog entry are updated when user-visible behavior changes.
  • Dependency changes include the intended Cargo.lock update and retain the MSRV.(未新增任何依赖,Cargo.toml/Cargo.lock 未改动)
  • Data changes comply with DATA_LICENSE; code changes are acceptable under MIT OR Apache-2.0.

已知遗留(建议另开 issue,均不阻塞)

  1. update_data_publishes_without_the_previous_datasets_journal 分不清替换前/后两个清理调用点(删掉任一仍绿)。
  2. 只读目录路径(SQLITE_IOERR_DELETE)无自动化测试——那是自愈真正改写主库的唯一分支,目前只有手工验证。
  3. is_hot_journal_error 的字符串兜底比需要的宽(rusqlite 无条件启用扩展码,只比 extended_code 即可);今日不会误触发,但安全性依赖注释约定。
  4. 未设 busy_timeout:两进程同时撞上热日志时,失败方会打印"当前数据不可写"的诊断(瞬态,重跑即好)。
  5. fojin data status 走读写路径,真只读 + 热日志时仍抛原始英文错误(非回归;危险的一半已由 sidecar 清理闭合)。
  6. texts 仍是全表扫描(约 0.9 s)。本轮明确排除:加索引要付 +34 MB 而只省 0.8 s,而它真正的病因是把全部分组取回 Rust 再按归一化标题过滤——预聚合小表才是根治,但那需要重新导出数据。
  7. examples/claude/commands/parallel.md 指示 agent 向用户展示 confidence,而该值全库恒为 1.0——与本次从人类输出去掉标签的初衷不一致。

xr843 added 13 commits July 26, 2026 19:19
The index-build notice used to print right after the read-write open
succeeded, before CREATE INDEX ran. That open only proves the database
file itself is writable — SQLite defers creating its rollback journal
until the first write, so a directory that blocks new files still lets
the file open cleanly. Whenever a stale data.sqlite.lock already
existed (left by any earlier cite/data update run), this printed the
notice on every single cite call while the index silently never got
built.

Move the eprintln! to fire only once create_cbeta_index succeeds, and
rewrite it in the completed tense. Replace the comment that asserted
the false open-time premise with the real reason: writability can't be
predicted before the write, so the notice tracks actual success instead.

Add a regression test reproducing the leak (pre-existing lock file +
read-only directory), a positive-control assertion that the first
successful build does announce itself, and stderr/index assertions on
the existing read-only test that previously passed for the wrong
reason.
The cite_degrades_gracefully_when_the_data_dir_is_read_only test verifies
an early fallback path where lock-file creation fails due to directory
permissions. Because this path exits before index building, its assertions
are trivially satisfied regardless of wiring. Document this limitation to
prevent misinterpretation as a proof of correct index integration.
Finding 1: Fixed false example in conf_tag documentation comment.
- Changed "0.995 would render as '1.00'" to "0.9951 would render as '1.00'"
- 0.995 actually formats to "0.99" in IEEE 754 f64
- Verified no other false statements in the comment

Finding 2: Added parallel text assertions to two tests to prevent false positives.
- confidence_rounding_to_one_hides_the_tag: now checks both MITRA absence and parallel presence
- absent_confidence_shows_no_tag: now checks both MITRA absence and parallel presence
- Mirrors pattern used in perfect_confidence_shows_no_tag
懒建索引把查询路径变成了写入目标,却没有把候选路径那套边车纪律带过来。
`CREATE INDEX` 在 588 MB 数据上溢出页缓存后,日志会带着 magic 头落盘;
进程若死在这个窗口里,只读连接从此回滚不了它:

- `cite` / `parallel` / `data verify` 一律退出 1,还谎称 dataset
  incompatibility 并建议 `data update`;
- `data update` 无效 —— rename 只搬主文件,陈旧日志原地不动;
- 随后任何读写打开都会把旧日志重放进新库,588 MB 静默损坏;
- `data clean` 也不管活跃路径的边车,清完只剩一份孤儿日志。

三处修复:

1. `open_read_only_db` 打开后先做一次真实读取。被 SQLITE_READONLY_ROLLBACK
   挡住时,以 SQLITE_OPEN_READ_WRITE(不带 CREATE)重开一次,让 SQLite 自己
   回滚。区分"热日志"与"真正只读安装"的唯一判据就是这次重开后的读取能否
   成功:只读文件会被内核拒绝而静默降级为只读打开,只读目录则删不掉日志,
   两者都失败,于是回落到原有错误路径。全程不删任何文件。
2. `clean_data` 与 `install_candidate` 复用同一套边车纪律清理活跃路径的
   `-journal`/`-shm`/`-wal`。前者主文件正被删除,后者主文件正被整体替换,
   且两者都在 operation lock 之下 —— 只有这两处安全,查询打开路径绝不能删。
3. 该状态下的文案改为说明真实原因(上次写入被中断、回滚需要写权限)并给出
   可操作建议,不再报不兼容、不再劝人重下 183 MB。

顺带把 `CBETA_INDEX_DDL` 与 `CBETA_INDEX_NAME` 收敛到同一个字面量,索引名
不可能再分叉;README 的体积数字随全新安装自带索引更新为 578 MB / 761 MiB,
并限定"首次 cite 补建索引"只适用于升级前已下载的数据。
@xr843
xr843 merged commit b75ed54 into master Jul 26, 2026
19 checks passed
@xr843
xr843 deleted the feat/cite-index-and-confidence branch July 26, 2026 23:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant