P0 fixes from test report review:
- object unit_id: fallback to f"{obj_type}:p{page}:{block_id}" when
figure_id/table_id missing (prevents DB overwrite on INSERT OR REPLACE)
- object caption_key: page-qualified f"p{page}:{caption_bid}" to match
page-qualified subtree_block_ids in find_owning_node
- object block_map: page-qualified keys for consistency with body_units
- structure node_id: changed from sec:{block_id} to sec:p{page}:{block_id}
for stability (reduces vector ID churn from emitted_order changes)
New tests:
- Section 4b: Object units DB persistence (unit_id unique, non-empty labels,
DB count == list count after upsert)
- Section 5b: Coverage gate (v2_tree / render_map / body_papers counts)
Layer A: 1669/1669 pass (+60 checks from 4b + 5b)
Unit tests: 153 pass, 1 skip
14 KiB
Memory Layer — 功能测试报告
日期: 2026-07-06
测试目标: PaperForge Memory Layer 全面改造(7 个 PR)的功能验证
测试范围: Layer A(artifact 一致性)+ Layer B(vector/API)
测试环境:
- Repo:
github-release@01461330(master) - Vault:
D:/L/OB/Literature-hub(734 OCR papers, 22 篇有 v2 结构) - Embedding: SiliconFlow
Qwen/Qwen3-Embedding-4B(OpenAI-compatible API) - Unit tests: 153 passed, 1 skip
TC-1: Render Events
内容
从持久化 render/render-map.json 验证 heading_events 和 emitted_block_events:
- heading_events 数量 ≥ 1
- emitted_order 全局单调递增且唯一
- heading 在 emitted_block_events 中有对应的 emitted block(
emitted_as="heading") - 向下兼容:
return_events=False返回 str
方法
Layer A 自动脚本从 render-map.json 读取 heading_events 和 emitted_blocks, 检查 emitted_order 单调性/唯一性,以及 heading 对应的 emitted block 存在性。
效果
全部通过(1609/1609 checks pass)。每篇论文 7–33 个 heading,emitted_order 递增无碰撞。
风险
render-map.json只存在于 22 篇 v2 render 论文中。709 篇旧论文没有该文件。- backmatter heading 现在进入 heading_events(markdown_level=2),这是新行为,旧 render-map 没有。
TC-2: Structure Tree
内容
从持久化 index/structure-tree.json 验证:
- 嵌套深度正确(child level > parent level)
own_block_ids和subtree_block_ids中的所有 page-qualified key 在 emitted_block_events 中存在- own_block_ids 不与 children 的 subtree_block_ids 重叠
- 所有 node_id 唯一(block_id 跨页碰撞通过
:order{N}后缀消除) - page_span 与实际 emitted blocks 的页码范围一致
方法
Layer A 自动脚本遍历树节点,对比 emitted_ids(page-qualified p{page}:{block_id} 格式)。
效果
全部通过。tree 中所有 block reference 都使用 p{page}:{block_id} 格式,
消除跨页同名 block_id(如 running headers block_id=0)的歧义。
风险
- bounds_map 的修复(
bounds_map[f"p{h['page']}:{h['block_id']}"])是在测试过程中发现的漏洞, 3 个 commit 后才解决。如果后续修改 bounds_map 逻辑,可能重新引入该问题。 - 节点 page 字段必须存在,否则 bounds lookup 会 miss。
TC-3: Body Units
内容
从 build_body_units 验证:
- unit_id 全局唯一(跨论文)
- unit_kind 分类:当前 policy 只排除
reference_item/reference_heading - section_path 完整性(
path == "/".join(json.loads(section_path_json))) - token cap:每 part
len(text)//4 <= 1000(递归拆分) - 空 section 不产出 body unit
- reference text 不泄漏进 body units
方法
Layer A 自动脚本从结构化 blocks + structure tree 重建 body_units, 检查 token cap、section_path、role 过滤、unit_id 唯一性。
效果
- 811 body_units,20 篇论文
- unit_kind 全部为
body(无backmatter_body,policy 变更后合并) - token cap: 全部 ≤1000 tokens
- reference items 未泄漏
- 全局 unit_id 唯一
风险
- Policy 变更:
_body_unit_role_kind从 inclusion list 改为 exclusion list, 现在包含abstract_body、authors、footnote等之前排除的角色。这是意图中的行为, 但如果下游系统依赖unit_kind=="backmatter_body",会出问题(目前无下游消费该字段)。 - abstract_body 的分类可能不准确(OCR pipeline 的误标),导致一些非 abstract 内容 被当作 abstract_body 处理—但这不影响检索质量。
7TG7U4U4的 "SI Supporting Information" 被归类为最后一个 section heading, 而不是 backmatter heading,其 body_units 内容包含 author bios 和 reference list portions。 这是正确的(block 在 rendered fulltext 中,不是 reference zone),但 section_path 不直观。
TC-4: Object Units
内容
从真实 role-index.json 验证:
- object_kind 为 "figure" 或 "table"
- caption_text 非空,section_path 非空
- role_index key 兼容:
captions和figure_captions都行
方法
Layer A 自动脚本从 role-index.json + tree + blocks 重建 object_units, 检查 object_kind、caption、section_path。key 兼容性单独测试(不在论文循环内)。
效果
- 6–36 object units/篇(22 篇论文共约 200+ units)
- 全部 object_kind 正确
- key 兼容性通过(
captions和figure_captions都能产出)
风险
- P0: unit_id collision — 真实 role_index 没有 figure_id/table_id,
fallback 为空字符串,导致
uid = "{paper_id}:obj:",INSERT OR REPLACE时覆盖。 已在代码中修复:使用f"{obj_type}:p{page}:{block_id}"作为 fallback。 - P0: caption_key 未 page-qualified —
find_owning_node检查 page-qualifiedsubtree_block_ids,但 caption_bid 只有裸 block_id,始终无法匹配。 已在代码中修复:caption_key = f"p{page}:{caption_bid}"。 - 修复验证: DB count == list count,所有 unit_id 唯一。
TC-5: FTS No Duplicate
内容
验证 FTS5 表行数等于 body_units 中 indexable=1 的行数:
- 顺序 upsert 多篇论文后 FTS 不重复
- 同一论文重复 upsert 不累积
- 全库 upsert 后总数一致
方法
Layer A 自动脚本使用 _upsert_body_units 对 10 篇论文顺序 upsert,
每步检查 FTS line count。
效果
- FTS=811, indexable body_units=811(严格相等)
- 重复 upsert 不增加行数
风险
- FTS5 external content 表在空表时
DELETE会抛DatabaseError,已通过 try/except 处理。 - 如果 future schema 变更改变了 indexable 的定义,需要重新验证。
TC-6: Content Discovery
内容
验证 Content Discovery 行为:
- 正文章节能检索到结果
- 无匹配时返回 coverage 和 next_action,不静默 fallback 到 metadata
- DB 不存在时返回错误
方法
部分覆盖在 Layer A 脚本中(从 body_units 取高频词查询 FTS)。 不静默 fallback 的逻辑在 Layer A 的 reference exclusion 测试中覆盖。 DB 不存在测试需要模拟空库。
效果
- FTS 查询正常返回结果
- reference 内容被正确排除
- 空库错误路径(
build_body_units对空 inputs 返回空 list)
风险
- Content Discovery 的
next_action精确格式未验证(依赖下游命令定义) - TC-6.2/6.3 的完整覆盖需要 content-discovery 命令的 e2e 测试,当前只覆盖了 FTS 部分
TC-7: Memory Builder Incremental
内容
验证 build_from_index 增量检测:
- Full rebuild 产出 body_units 且 FTS 一致
- 再次 run 无变化(hash match)
- OCR_hash 变化触发增量
retrieval_policy_version变化触发增量
方法
直接调用 build_from_index,检查返回结果和 DB 状态。
效果
- body_units=811, FTS=811 ✅
- 再次 run:
hash_match=True, 无变化 ✅ RETRIEVAL_POLICY_VERSION=l4.body.v1✅
风险
- TC-7.3(修改 result-hash.txt 后触发增量)未完整执行——依赖修改真实文件,目前通过 hash mismatch 间接验证。
- TC-7.4(空 vault 不崩溃)未单独测,但
build_from_index对空 index 返回 0 papers。
TC-8: Embed Build (Layer B)
内容
验证 embedding pipeline:
- body_units 路径:嵌入 body_units 到
paperforge_bodycollection - Legacy 路径:未 rebuild 论文走
paperforge_fulltext路径 - Resume 按 body_units_hash 检测变更
- 双 collection 状态输出
- 无 body_units 但有 structured files 时给出提示
方法
检查 ChromaDB collection 内容,对比 body_units_hash,验证 resume 逻辑。
效果
| 指标 | 值 |
|---|---|
| body_chunk_count | 811 |
| fulltext_chunk_count | 57,435 |
| total_chunks | 58,246 |
| model | Qwen/Qwen3-Embedding-4B |
| 3 篇嵌入论文的 hash mismatch | 全部检测到(policy 变更后 hash 不同) |
风险
- ChromaDB query limit 陷阱:
col.get(limit=100)只返回 100 条记录, 在 811 chunks 中抽样只看到 3 篇 paper(测试脚本 bug),实际全部 20 篇已正确嵌入。 - Embed 慢:fulltext 57435 chunks 的 full rebuild 需要大量 API 调用。
建议使用
--resume模式增量更新。 - API key 泄露风险:测试使用了 SiliconFlow key,应在测试后轮换。
TC-9: Retrieve Merge (Layer B)
内容
验证 merge_retrieve 混合检索:
- 同时返回
body_unit和legacy_chunk结果 - 按
(source, unit_id)去重 - Per-paper cap(每篇最多 2 条结果)
- 空查询返回 5 条低分结果(vector search 行为)
- 无 body 向量时 fallback 到 fulltext
方法
使用 merge_retrieve(vault, query, limit=N) 执行查询,
检查 source、去重、cap。
效果
- 混合返回:VNS 查询返回 5 body + 5 legacy
- 去重:0 重复(body 有
unit_id,legacy 有chunk_index) - Per-paper cap:无 paper 超过 2 条
- 差查询返回 5 条 ~0.55 低分结果
风险
- Legacy chunks 没有标准
unit_id——使用(source, paper_id, hash(chunk_text))作为后备唯一键。 如果 legacy chunks 在 future 被适配为有 unit_id,dedup 逻辑会更可靠。 - body 向量只有 20 篇论文的实际数据,混合查询主要体现 legacy 结果。 当 709 篇也获得 body 向量后,混合分布会更平衡。
- 向量搜索的
limit * 2 (expand)策略在 body 集合较小时(811 vs 57k)可能导致 body 结果被 legacy 结果淹没。当前merge_retrieve按 score 降序排序,不做 source bias。
TC-10: Backmatter & Abstract
内容
验证:
- backmatter_body section_path 包含合理关键词(旧 policy,现已 moot)
- Abstract 覆盖策略
方法
检查 body_units 中 abstract_body 块的分布。验证 paper_fts 可以搜到 abstract 内容。
效果
- Abstract_body 现在进入 body_units(新 policy:排除 ref zone,其余全纳入)
paper_ftsmetadata 搜索:"abstract"匹配 35 篇论文- 22 篇 body_units 中
abstract_in_body_units=0—abstract_body 块被合并在其所在 section 中, 没有独立的 "Abstract" 标题
风险
abstract_bodyOCR 分类可能不准,一些 body paragraph 被标为 abstract_block— 目前没影响,但如果未来需要精确 abstract filter 就有问题。
TC-10.2: Abstract 覆盖决策(已过时)
- 旧决策:abstract 不进 body_units,由 paper_fts 覆盖
- 新 policy 变更:
_body_unit_role_kind只排除 reference zone,abstract_body 现在进入 body_units - 影响:无负面(abstract 内容现在可被 body retrieval 命中,paper_fts 仍然可用)
- 如果未来需要恢复旧行为,需为 abstract_body 角色加 exception
TC-11: Regression
内容
验证:
- 单元测试全部通过
- CLI 命令不破坏
- 跨 vault 切换正常
方法
运行 pytest、CLI help、embed status。
效果
- 153 passed, 1 skip ✅
paperforge --help/embed status --json正常 ✅- unit tests 覆盖 n 至 n 层
风险
- 测试包含 1 个 pre-existing skip(非本改动引入)
- 未做跨 vault 切换的 e2e 测试(需要两个真实 vault)
风险总览
| 风险等级 | 问题 | 影响 | 缓解 |
|---|---|---|---|
| 中 | 709 篇论文无 structure-tree.json → 无 body_units → 无 body 向量 | 只有 22/734 篇论文使用 body 向量检索 | 全量 rebuild(derived_stale=True → ocr rebuild --all → memory build → embed build --resume) |
| 低 | abstract_body 进入 body_units(新 policy 行为) |
行为与旧设计文档不符,但不影响检索质量 | TC-10.2 已记录;如需回退需加 role exception |
| 低 | Legacy chunks dedup 基于 hash(chunk_text) | 极少情况下两条相同文本的 legacy chunk 会被去重 | body 向量逐步替换 legacy,问题自愈 |
| 低 | bounds_map 依赖 node.page 字段 | future tree 结构变更可能破坏 bounds lookup | 单元测试 + Layer A 自动检查 |
| 极低 | FTS5 空表 DatabaseError | 发生在首次 build 前 | try/except pass 已处理 |
| 极低 | ChromaDB get(limit=100) 抽样偏差 | 开发/调试时误判向量覆盖范围 | 使用 col.count() 或 get(limit=col.count()) |
PR 7: Post-Review Hardening
Based on the test report review (2026-07-06, attached external review):
Code Fixes
- object unit_id fallback: figure_id/table_id 缺失时用
f"{obj_type}:p{page}:{block_id}"替代空字符串 → 解决 DB overwrite - object caption_key page-qualified:
find_owning_node使用f"p{page}:{caption_bid}"匹配 page-qualified subtree_block_ids - structure node_id page-qualified:
node_id = f"sec:p{h['page']}:{h['block_id']}"减少 emitted_order 变化引起的 vector ID 大面积变更 - object block_map page-qualified: 与 body_units 一致
Added Tests
- Object DB persistence: upsert 后 DB count == list count, unit_id 唯一性
- Coverage gate: v2_tree / body_papers / body_vector 比例输出
Remaining Risks
- 709 篇等待全量 rebuild 后才会有 v2 tree + body vectors
- Content Discovery CLI e2e 未完整执行(仅 FTS 部分)
- Legacy collection 57k chunks 可能在混合检索中淹没 body 结果
结论
Memory Layer 全面改造通过功能验证。
- Layer A:1609/1609 pass(artifact 一致性)
- Layer B:混合检索、resume hash 检测、去重、per-paper cap 全部正常
- Embedding:811 body chunks in 20 papers,
Qwen/Qwen3-Embedding-4B, healthy - PR 7 Hardening:unit_id/caption_key/block_map page-qualify 修复,Object DB 验证,coverage gate 输出
还剩 709 篇论文等待重建(无 v2 structure tree → 无 body_units → 无 body 向量)。 如果你决定全量跑,流程是:
1. ocr rebuild --mark-stale (或用脚本设置 derived_stale=True)
2. paperforge ocr rebuild --all --parallel 4
3. paperforge memory build
4. paperforge embed build --resume (需 API key)