feat(search): 反向查询、零命中自动切分、最长可命中子串回退 - #5
Merged
Conversation
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 次差分调用,逐字节相同。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rs与fallback.rs是纯函数(命中判定由闭包注入),单元测试完全不碰数据库。Verification
本机默认 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 输出)、繁简归一、cite、cite --juan、texts、texts --json。"有命中的查询输出不变"这一承诺因此是在真实数据的多条路径上被证实的,不只依赖单条 golden 测试。零命中路径耗时(真实数据,热缓存):
--from sa最后一项在开发中途曾是 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
cargo +stable fmt --all --checkpasses.(以 1.96.0 代 stable,理由见上)cargo +stable clippy --all-targets --locked -- -D warningspasses.(同上)cargo +stable test --all --lockedpasses.(同上)cargo +1.95.0 test --all --lockedpasses when the change affects Rust code or dependencies.CONTRIBUTING.mdpass.(normalize.rs与data-pipeline均未改动,归一化一致性契约未受影响,parity 测试仍通过)Cargo.lockupdate and retain the MSRV.(未新增任何依赖,Cargo.toml/Cargo.lock未改动)DATA_LICENSE; code changes are acceptable under MIT OR Apache-2.0.已知遗留(建议另开 issue,均不阻塞本 PR)
--from短串的预检文案仍是"至少需要 2 个汉字",而反查实际下限是 3 个字符。修它需让预检按from.is_some()分支。instr全表扫描(单次约 0.11 s,20 句上限约 2.2 s)。这是既有行为(2 字查询走instr早于本分支),切分只是放大了它。src/cli.rs中crate::lang::与lang::两种调用风格并存;src/render.rs内联全限定crate::search::split::MAX_SEGMENTS。src/search/mod.rs与src/query.rs各有一份分组身份三元组(后者未导出无法直接复用),建议在model.rs上加MatchGroup::identity()。tests/command.rs中新测试手搓std::process::Command,未用文件既有的run_fojinhelper(该文件本就混用两种风格)。--json示例现与data-v1绑定,换数据集需刷新。