Skip to content

feat(search): 反向查询、零命中自动切分、最长可命中子串回退 - #5

Merged
xr843 merged 20 commits into
masterfrom
feat/search-capabilities
Jul 26, 2026
Merged

feat(search): 反向查询、零命中自动切分、最长可命中子串回退#5
xr843 merged 20 commits into
masterfrom
feat/search-capabilities

Conversation

@xr843

@xr843 xr843 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

fojin parallel 增加三项检索能力。三者只在整串查询零命中时介入——有命中的查询路径逐字节不变。

1. --from <lang> 反向查询(梵/藏 → 汉)

foreign_text 一直躺在本地库里却没有入口。现在可以拿一句梵文转写或藏文反查汉文对应,输出结构与正查完全一致(命中某行外文后,仍按汉文分段分组,并展示该组的全部平行——用梵文找到,也看得见藏文)。

匹配在 Rust 侧做完整 Unicode 大小写折叠,不用 SQL instr:SQLite 的 instr 大小写敏感,其 LOWER() 只处理 ASCII,折叠不了 Śś,而数据里句首大写很常见(Tasmāc Chāriputra …),用户输入 tasmāc 会一条都查不到。变音符号仍须精确匹配。

不建索引(否则库要从 588 MB 涨到近 1 GB、且强制所有用户重下 183 MB)。改用两趟扫描:第一趟只在 from_lang 的行里定位命中的分组键,无命中直接返回;第二趟按 cbeta_id 分批收窄取回各语种行。反查最少 3 个字符,且不做切分与回退。

2. 零命中时按句自动切分重查

此前 README 的处理是让用户"请拆成短句分别查"——这件事工具自己能做。整串零命中且能切出 ≥2 个有效分句时,按句读切分逐段重查,输出分句小节。--no-split 关闭,20 句上限且明确告知截断了多少。

断句字符集只取句级标点,不含书名号、引号、括号、间隔号和顿号——它们出现在句中,在那里断会把词组切碎。顿号尤其关键:佛典列举无处不在(色聲香味觸法),在 处断句会把「不住色、聲、香、味、觸、法」切成【不住色】(32 组噪音)加一串被丢弃的单字。

3. 仍零命中时给出最长可命中子串

"未找到对齐"此前是条死路。现在对无命中的串二分搜索出最长的、确实有对齐的连续子串并提示出来(依据:若长度 L 的某子串命中,其任意长度 L−1 子串也命中,故该命题关于 L 单调)。最短 3 字(2 字子串在 90 万行语料里几乎总能命中,毫无信息量),归一化后超过 60 字则跳过。

附带

  • --lang / --from 的未知语种代码现在报用法错误(退出码 2),此前静默返回"未找到对齐",会让人误判成数据没覆盖。
  • --json 新增 schema_version;切分时带 segments[],整串回退时带 fallback{}。顶层 matched/total/shown/groups 语义不变,现有 agent 消费者零修改继续工作。
  • 抽出 src/search/ 编排层,cli.rs 不再承担策略。split.rsfallback.rs 是纯函数(命中判定由闭包注入),单元测试完全不碰数据库。

Verification

rustup run 1.95.0 cargo test --all --locked        # MSRV,213 passed
rustup run 1.96.0 cargo fmt --all --check          # 通过
rustup run 1.96.0 cargo clippy --all-targets --locked -- -D warnings   # 0 警告
cd data-pipeline && python3 -m pytest tests/ -q    # 1 passed(归一化 Rust/Python 一致性)

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

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

从 merge-base 编译基线二进制,对 12 组真实查询与本分支逐字节对比——11 组完全一致,唯一差异是有意新增的 "schema_version": 1。覆盖命中查询、高频词(涉及数千行)、--lang 过滤、--all(21 KB 输出)、繁简归一、citecite --juantextstexts --json。"有命中的查询输出不变"这一承诺因此是在真实数据的多条路径上被证实的,不只依赖单条 golden 测试。

零命中路径耗时(真实数据,热缓存):

场景 耗时
反查 --from sa 1.31–1.36 s
回退提示 0.016 s
20 段 × 46 字非匹配拉丁文(最坏) 0.078 s
命中路径(对照) 0.003 s

最后一项在开发中途曾是 114.9 s:回退探测用完整查询判空、二分下界为 2,而长度 <3 的候选会落进 instr 全表扫描(90 万行、无索引)。改为最短 3 字 + 专用存在性检查(SELECT 1 … LIMIT 1,保持 --lang 过滤语义)后降至 0.078 s。新增的 never_probes_a_candidate_below_the_fts_floor 测试与一条编译期断言共同防止该回归重现。

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 均未改动,归一化一致性契约未受影响,parity 测试仍通过)
  • 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,均不阻塞本 PR)

  1. --from 短串的预检文案仍是"至少需要 2 个汉字",而反查实际下限是 3 个字符。修它需让预检按 from.is_some() 分支。
  2. 恰好 2 字的分句仍会走 instr 全表扫描(单次约 0.11 s,20 句上限约 2.2 s)。这是既有行为(2 字查询走 instr 早于本分支),切分只是放大了它。
  3. src/cli.rscrate::lang::lang:: 两种调用风格并存;src/render.rs 内联全限定 crate::search::split::MAX_SEGMENTS
  4. src/search/mod.rssrc/query.rs 各有一份分组身份三元组(后者未导出无法直接复用),建议在 model.rs 上加 MatchGroup::identity()
  5. tests/command.rs 中新测试手搓 std::process::Command,未用文件既有的 run_fojin helper(该文件本就混用两种风格)。
  6. README 的 --json 示例现与 data-v1 绑定,换数据集需刷新。

xr843 and others added 20 commits July 25, 2026 19:33
parallel 与 cite 两处逐字节相同的语种校验块合并为共享 helper,
避免以后改错误输出格式时漏改一处。顺带将 crate::lang::validate_langs
的两处新增调用改为 lang::validate_langs,与文件既有的 use 别名约定
对齐(--from 校验保持原样,不在本次范围)。行为不变:非法语种仍在
open_ensured 之前 eprintln 并返回退出码 2。
Two review findings on search_foreign, both in its own newly-landed code:

- Pass 2 selected the full parallels table (SELECT ... FROM parallels, no
  WHERE) whenever pass 1 had any hit, materializing ~900k rows into Rust
  just to keep a handful. Narrow it with WHERE cbeta_id IN (...), batched
  under SQLite's default 999-bound-parameter limit (dataset has ~1016
  distinct cbeta_id, so two batches cover it), plus a dedicated
  cbeta_id IS NULL query since IN never matches NULL and cbeta_id is
  nullable. Rust still re-filters by the full (zh_text, cbeta_id, juan_num)
  key, so the narrowed result set is unchanged.

- group_and_rank recomputed the exact/excess_chars ranking signal from
  whatever row survived the caller's display --lang filter. For reverse
  search that row can be a non-source language (e.g. --from sa --lang bo
  leaves only Tibetan rows), so the signal ends up driven by irrelevant
  text instead of the actual Sanskrit match, corrupting group-vs-group
  ranking (members are unaffected). Fix: pass 1 now records a per-group
  best signal (exact OR'd, excess_chars MIN'd across from_lang witnesses)
  while it still has the real matching text, and group_and_rank's
  MatchColumn::Precomputed arm looks that up instead of recomputing from
  row.foreign_text.

MatchColumn::ZhNorm now borrows row.zh_norm instead of cloning it, as a
side effect of restructuring the match arms directly around (exact,
excess_chars) rather than a cloned "hay" string — removing the Minor
clone flagged in review without adding a parallel code path.

Adds two tests: a multi-group ranking test that fails before the fix
(loose-but-shorter Tibetan display text outranks a real exact Sanskrit
match) and passes after, and a NULL-cbeta_id pass-2 regression test.
…t path

merged_groups_preserve_segment_order_over_relevance pins segment-order-first
merging by making the second segment's group strictly better on confidence
and cbeta_id ordering, so any future global relevance re-sort of `merged`
flips the assertion.

segment_display_uncapped_with_limit_none exercises the previously-untested
`limit: None` (`--all`) combination with the zero-hit split path, asserting
both the per-segment SEGMENT_GROUP_CAP and the top-level --limit cap are
lifted together.
- Note the 20-sentence split cap and 60-char fallback cap (with explicit
  overflow reporting) in README's 输入规则与匹配方式 section and the
  matching CHANGELOG entry, previously undocumented.
- State the same split/fallback boundaries in the English Summary, which
  previously described --no-split/fallback with no limits at all, and
  add the missing "does not split or fall back" caveat to --from so the
  English and Chinese sections carry equal information.
- Fix the --json example, which showed no schema_version despite the
  surrounding text stating the field always exists; added it and
  reordered every level of the example to genuinely alphabetical key
  order to match the caption's own claim.
- Note that the fallback substring is normalized (simplified script,
  punctuation stripped), so it can differ from the user's input glyphs.
- Fix an over-promise found while editing the same lines: both the
  Chinese and English --from descriptions claimed diacritic-insensitive
  matching ("不区分大小写与首字母" / "case/diacritic-insensitive").
  Verified against src/query.rs::search_foreign (case-folds via
  to_lowercase but never strips diacritics) and empirically ("sunyata"
  does not find "śūnyatā" rows, while "ŚŪNYATĀ" does) that only case
  folding happens; reworded both to say so.
终审五项修复。

Finding 1(阻塞):零命中回退在非汉文输入上悬挂 7–115 秒。
二分下界原为 2,而 query::search 对 <3 字候选走 instr 全表扫描
(908,620 行、无索引),每个起点一遍,再乘最多 20 个分句。

(a) 回退最短子串 2 → 3(MIN_FALLBACK_CHARS)。真子串上界是 n−1,
    故守卫相应改为 n >= MIN_FALLBACK_CHARS + 1。2 字子串在 90 万行
    语料里几乎必然命中,提示本就无信息量;3 字又正是 FTS5 trigram
    下限,于是所有探测彻底不再触碰全表扫描那一层。
(b) 新增 query::exists,以 SELECT 1 … LIMIT 1 取代「完整查询后判空」。
    它镜像 group_and_rank 的 --lang 过滤(空列表短路),也镜像
    fetch_rows 的索引选择,故 exists == !search().is_empty() 无条件成立。

实测(真实数据,热缓存):藏文 17 字 1.82s → 0.004s;IAST 53 字
6.57s → 0.007s;20 段 × 46 字拉丁文 125.4s → 0.078s。

Finding 2:SPLIT_CHARS 移除顿号。佛典列举(色聲香味觸法)是一条对齐
分段,在那里断会把它切碎且单字项被 keep 静默丢弃。冒号保留——它切开
后两半仍是可独立命中的语块,与句级标点同类。

Finding 3:长度预检移到 --from/--lang 白名单校验之后,使短串 + 未知
语种得到退出码 2 与正确提示,而非退出码 1 与「2 个汉字」的错误建议。

Finding 4:换掉 fallback.rs 的「Task 4 implements this」桩注释。

Finding 5:README 的 --json 示例改用真实输出(实为 T0310 而非 T0237);
命中/零命中延迟分别限定并给出实测;回退的 3 字下限与断句规则写入文档。

测试 201 → 213。改动 3 条既有测试(均为新行为下的等价重述,断言强度
不减,详见 .superpowers/sdd/final-review-fix-report.md),新增 12 条。
命中路径:golden 之外另做 45 次差分调用,逐字节相同。
@xr843
xr843 merged commit 41eb968 into master Jul 26, 2026
19 checks passed
@xr843
xr843 deleted the feat/search-capabilities branch July 26, 2026 08:46
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