# 交接 · 知识库分块：两项补查 + 单章 A/B 对照（2026-10-02 续）

> 上一轮：`工作区\交接-知识库修复与EPUB碎块-20261002.md`（重排修复、碎块根因、723 块候选方案）。
> 本轮做的是 ChatGPT 回复里要求的补查与单章对照测试。**全部结论均为本机实测**，命令与产物路径见文末。

---

## 一、补查 1：阅读顺序 —— 本机数据「ZIP 顺序恰好等于 spine 顺序」，但实现的正确性是偶然的

实测（脚本 `_tmp\epub_spine_check.py`，直接读 `content.opf`）：

```
META-INF/container.xml → rootfile OEBPS/content.opf
manifest 251 项 = xhtml 29 + image/jpeg 219 + css 2 + ncx 1
spine itemref = 29，顺序 text00000.html … text00028.html
ZIP 内 29 个 html 的字母序      = text00000.html … text00028.html
两者顺序完全一致：True        集合一致：True
```

结论要限定范围：

| 项 | 结论 |
|---|---|
| 本 EPUB 的阅读顺序 | ✅ 正确。spine 与 ZIP 顺序逐项一致，29 页无缺无多 |
| 实现是否遵循 OPF spine | ❌ **没有**。`parseEpub`（`lib/knowledge/index.js:4433-4445`）只做 `Object.keys(zip.files).sort()` + 扩展名过滤 + `join("\n\n")`，从不读 `container.xml` / `content.opf` / `spine` |
| 风险 | 属**潜在**缺陷：凡 spine 顺序 ≠ ZIP 字母序的 EPUB（改名、非零填充编号、spine 重排），解析出的正文顺序会错。本文件恰好看不出问题 |

## 二、补查 2：219 张图 ↔ 217 个占位 —— 差异是两个「结构性图片」，不是丢失

实测（`_tmp\epub_img_refs.py`）：

```
ZIP 内 .jpg 文件        219 个，编号 Image00000–00219，其中 00218 缺号（共 219 个 = 0..219 去掉 218）
HTML 中 <img> 标签      217 个，对应 217 个不同文件名（无重复引用）
孤儿图片（存在但无 <img> 引用） Image00217.jpg、Image00219.jpg
存档文本图片占位 ![](ImageNNNNN.jpg) 217 个，使用的文件名最大到 Image00216.jpg
```

两个孤儿的身份（`content.opf` manifest 属性 + 引用关系）：

| 文件 | manifest 属性 | 说明 |
|---|---|---|
| `Image00217.jpg` | `properties="cover-image"` | 封面图，由阅读器取用，正文不引用 |
| `Image00219.jpg` | 普通 image 项，无 properties | 正文、toc.ncx、guide 均无引用；相邻的 00218 已被出版社删除 → 判为**封底/版权类页图**（同批被清理） |

**逐项对应**：217 个占位 ↔ 217 个 `<img>`，文件名一一对应，无缺失、无多余、无重复。差 2 = 封面 1 + 封底类 1。

顺带核到的可访问性（此前未测）：

| 项 | 结果 |
|---|---|
| 脚注锚点 `<a href="textNNNNN.html#filepos…">` | 1922 个，**断链 0 个**（目标页与目标 id 全部存在）；同页 840 / 跨页 1082 |
| 其他 `<a href>`（非 text 开头） | 58 个，未逐一定位 |
| `<img>` 的 alt 文本 | **全部为空**（0/217）→ 图内文字确实没有任何文字化入口，只能靠视觉模型另做 |
| 图片路径 | 全部在 `OEBPS/` 同一目录，与 HTML 同目录，相对路径可直接解析 |

## 三、补查 3（顺带修正）：真实字符预算是 1824，不是 1280

按插件自己的口径复算（`chunkSize=800`、`chunkOverlap=100`、`smartChunk=true`）：

```
normalizeText 后长度 283093；estimateTokens(ck/1.5 + 其他/4) = 124162
charsPerToken = 2.2800  →  safeSize = round(800×2.28) = 1824 字符，safeOverlap = 228
```

上一轮交接里「折算 1280 字符」需**更正为 1824**。对结论无实质影响（现状仍是 0 个块超限），但后续拆分阈值要按 1824 判断。

---

## 四、复刻：抽取链路第一次做到与库内逐字一致

写了两版抽取器，第二版才对齐（差异全在非 `<p>` 结构上）：

| 版本 | 差异原因 | 结果 |
|---|---|---|
| v1 只取 `<p>` | 漏掉 `text00007.html`（540 个 `<blockquote>` 目录行）与 `text00028.html`（嵌套 blockquote）内的 1057 块 | 5478 块 |
| **v3/v4 取 body 顶层块元素（`<p>` + `<blockquote>`）** | — | **6535 块，拼接后 283096 字符，与库内 `rawText` 逐字相同（含 29 处仅来自我复刻脚本的页眉行）** |

抽取器同时输出可追溯字段：`page`（原始 XHTML）、`elemIndex`（页内第几个 `<p>`）、`id`（如 `filepos203143`）、`class`、`spans`、`images`。这满足「每块能定位到版本、章节、原 XHTML/锚点」的要求。

## 五、单章 A/B 对照：结论是「分块必须落在插件解析层，Markdown 聚合会被入库时重新切掉」

按上轮 §五.2 的做法执行：**第三章（手太阴经络与腧穴，text00011.html）**，两个临时库、同一套嵌入/重排/检索参数。

文件与库：

| 项 | A（现状策略） | B（结构优先） |
|---|---|---|
| 源文件 | `_tmp\ch3-A.md`（每段一块，183 段 / 7451 字符） | `_tmp\ch3-B.md`（结构优先，20 片 / 7604 字符） |
| 临时库 | `_tmp-第三章策略A` `30cea406-cc9e-4a5b-a538-9b9f7a43536e` | `_tmp-第三章策略B` `b0fdc239-1b96-4d1c-8891-1578bad6ae25` |
| 入库块数 | 183 | 184 |
| 块长中位/均值/p90/最长 | 22 / 38.7 / 74 / 430 | 24 / 39.3 / 74 / 430 |
| `【定位】`/`【主治】`/图片/脚注锚点 | 11 / 11 / 9 / 68 | 11 / 11 / 9 / 68 |
| 孤立标题块 | **9 个**（章、节、一、、（一）…各自成块） | 7 个（其中 3 级标题被并成一片） |

**关键实测**：B 的源文件本来就是 20 片，但入库后仍变成 184 块 —— 因为插件的 `splitBlocks` 按空行分段，B 片内部为了保留段落结构必然留有换行分隔，于是**入库时被重新切开**；我为超预算穴位条目加的「章·节」来源前缀也因此被单独切成了 `第三章手太阴经络与腧穴 · 第二节 手太阴腧穴` 这种小片（库内块 134/142/175 实测）。

也就是说：

1. **在 Markdown 层面聚合无法改变入库块边界** —— 只要走「导入文件」这条路，`splitBlocks` 就按空行把聚合拆回去。
2. 想让「章 → 节 → 穴位条目 → 字段」真正成为块边界，只有两条路：
   - **改插件 `parseEpub` / `splitBlocks`**：让 EPUB 解析按 OPF spine + 元素结构产出块（推荐，一次到位，且能顺带修 §一 的 spine 问题）；
   - 或走**文本导入通道**并在导入前把单元内的空行全部消掉（能保住单元原子性，但章节内段落层级会被压平，需另存结构元数据）。
3. 现状策略 A 的检索并**不差**：12–20 道本章问题（穴位定位、主治、循行、络脉、经别、经筋、脚注引文）两库 TopK=4 结果几乎一致，B 只在个别题上把正确条目提到第 2 位（如「天府 定位」A 的第 2 位是无关的「取法：先确定云门…」（0.700），B 是正确【定位】（0.998））。**检索退步没有出现，噪声下降很轻微。**

## 六、结构优先分块的量化效果（在同一份解析文本上，纯离线计算）

在同一 6535 块基线上按 §五 规则离线重排（`_tmp\seg_v9.py`）：

| 指标 | A 现状 | B 结构优先 |
|---|---|---|
| 块/片数 | 6535 | **1151** |
| 中位/均值/p90/最长 | 28 / 41.3 / 75 / 951 | 108 / 244.0 / 405 / **43371（附录歌诀那段，嵌套 blockquote 无段落边界，属解析层残留问题）** |
| 超 1824 字符 | 0 | 6（全部是论文式长段/附录，需按句再切） |
| 孤立标题 | 253（含目录行 540 另计） | 0（标题与紧随内容同块） |
| 无损性 | — | **去空白逐字相同；253 个标题一个不少** |
| 穴位条目 | 401 个【定位】分散在 401+ 块 | 401 个条目各自成单元，穴位头与【定位】同片 |

（注：401 是全书穴位条目数，与单章 A/B 的 11 个不矛盾。）

## 七、下一步建议（未动手）

1. **先定路线**：是否允许改插件 `dsh-knowledge`（改 `parseEpub` 读 spine + 按元素层级切块）。这是唯一能让结构优先真正生效的位置；插件升级会覆盖改动，需记录还原点。
2. 若暂不改插件：可先把 A/B 测试扩到 3–5 章 + 全书离线评估，用 §六 的离线指标证明收益，再决定。
3. **图片文字**仍按上轮 §五.3 分类处理（字形/正文/注释图提字核对；经络示意图描述另存并标注；封面装饰图保留资源不生成图注）；`imageCaptionProvider=off`，尚未启用。
4. 原 EPUB、原「中医新」库、旧「中医」库**均未改动**。
5. 临时库（`_tmp-策略验证-可删`、`_tmp-第三章策略A/B`）用完即删。

## 八、本轮产物（均在 `工作区\_tmp\`，未清理）

| 文件 | 用途 |
|---|---|
| `epub_spine_check.py` / `epub_img_refs.py` | 补查 1、2 的脚本与结论 |
| `extract_v1.js` / `extract_v2.js` / `extract_para.js` | EPUB 结构抽取器（v2 = 与库内逐字一致的版本） |
| `blocks_src.json` / `block_tiers.json` | 6535 块 + 结构层级标注 |
| `seg_v9.py` / `B-pieces-v9.json` | 结构优先切分引擎与结果（无损性已验） |
| `ch3-A.md` / `ch3-B.md` / `bases.json` | 单章 A/B 材料与临时库 id |
| `compare_ab.py` / `ck_rechunk.py` | 临时库块级对比、重切验证 |
| `db-raw.txt` / `doc-full.json` | 从插件 API 取回的库内 rawText 与全量块（用于比对） |

> 凭据提醒（上轮遗留未处理）：`GET /knowledge/config` 与 `GET /knowledge/bases` 会把嵌入/重排/图注/mi​​neru 的 key 明文返回；本机环回口暂无鉴权。本轮未把任何 key 写入文件或回复。
