lllin000_PaperForge/docs/plans/memory-layer-implementation-plan.md
LLLin000 288829bb5b feat: PR 1 — nested structure tree + body_units correctness
- RenderOutput dataclass with heading_events/emitted_block_events tracking
- Stack-algorithm structure tree with own/subtree block IDs
- Recursive body_units builder with role helper + token cap splitting
- Schema v4: section_path_json, section_level, section_title, part_ordinal
- Phase 4/5 reorder in ocr_rebuild.py and ocr.py
- 12 new test cases for tree nesting, own_block_ids, rendered-order intervals
2026-07-06 16:42:08 +08:00

22 KiB
Raw Permalink Blame History

Memory Layer 改造 — 实施计划

基于:2026-07-06-memory-layer-overhaul-design.md 状态:待执行 策略5 个 PR 顺序执行PR n+1 依赖 PR n


总依赖图

PR 1: 结构树 + body_units 正确性
  │
  ▼
PR 2: Memory Builder 增量
  │
  ▼
PR 3: Content Discovery 行为(语义依赖 PR 2PR 2 后做)
  │
  ▼
PR 4: Embedding 双 collection依赖 PR 1 + PR 2
  │
  ▼
PR 5: Retrieve 合并(依赖 PR 4

PR 1结构树和 body_units 正确性(最核心,不可拆分)

目标: 从 rendered markdown H1-H6 产出真实嵌套结构树body_units 跟随树递归生成,不重复、不丢内容。

文件变更

操作 文件 改动
新增 paperforge/core/io.py read_jsonl(path) — jsonl 逐行读取(待加)
修改 paperforge/worker/ocr_render.py render_fulltext_markdown() 新增 return_events=False 参数;为 True 时返回 RenderOutput(markdown, heading_events, emitted_block_events)不破坏旧返回类型。
修改 paperforge/worker/ocr_rebuild.py Phase 4/5 Phase 4 传 return_events=TrueRenderOutput 传入 Phase 5
修改 paperforge/worker/ocr.py Phase 6 同上顺序调整
新增 render 阶段产出 render/render-map.json{headings: [...], emitted_blocks: [...]}
重写 paperforge/retrieval/structure_tree.py build_structure_tree(heading_events, emitted_block_events, structured_blocks) — stack 算法interval 基于 emitted order
修改 paperforge/retrieval/units.py build_body_units() / build_object_units() recursive walkown_block_idsrole helper
修改 paperforge/memory/schema.py body_units 表加字段CURRENT_SCHEMA_VERSION → 4

实现要点

1.1 RenderOutput 与 render-map.json

不直接改 render_fulltext_markdown() 返回类型——当前返回 str,旧调用点很多。新增 return_events=False 参数:

@dataclass
class RenderOutput:
    markdown: str
    heading_events: list[dict]         # heading block 事件
    emitted_block_events: list[dict]   # 所有真正进入正文阅读流的 block

def render_fulltext_markdown(..., return_events=False) -> str | RenderOutput:
    ...
    if return_events:
        return RenderOutput(markdown=markdown,
                            heading_events=heading_events,
                            emitted_block_events=emitted_block_events)
    return markdown

heading_events 每条记录:

{"line_number": 42, "markdown_level": 3, "title": "Study population",
 "page": 2, "block_id": "b_10", "emitted_order": 7}

emitted_block_events 每条记录(每个真正进入正文阅读流的 block

{"emitted_order": 123, "line_start": 456, "line_end": 460,
 "page": 3, "block_id": "b_10", "role": "body_paragraph",
 "emitted_as": "body"}

写入 render/render-map.json

{"headings": [...], "emitted_blocks": [...]}

build_structure_tree() 不再依赖 structured_blocks 的 _emitted_order(该字段不存在),而是接收 emitted_block_events

1.2 Phase 4/5 顺序和数据流

× 当前:
  Phase 4: render_fulltext_markdown(...) → markdown (str)
  Phase 5: build_role_indexes
           build_structure_tree(structured)     ← 此时无 headingemitted 信息
           write_render_outputs(...)

✓ 改为:
  Phase 4: rendered = render_fulltext_markdown(..., return_events=True)
           return rendered, health_overall

  Phase 5: write_render_outputs(..., markdown=rendered.markdown,
                                 heading_events=rendered.heading_events,
                                 emitted_block_events=rendered.emitted_block_events)
           build_role_indexes(...)
           build_structure_tree(
               heading_events=rendered.heading_events,
               emitted_block_events=rendered.emitted_block_events,
               structured_blocks=structured,
           )
           write_structure_tree(index/structure-tree.json)

两处都改:

  • paperforge/worker/ocr_rebuild.py — rebuild 路径
  • paperforge/worker/ocr.py — 初始 OCR 路径

write_render_outputs() 新增参数 heading_events / emitted_block_events,写入 render/render-map.json

1.3 build_structure_tree() 使用 emitted_block_events

def build_structure_tree(heading_events, emitted_block_events, structured_blocks):
    # 1. heading_events 按 emitted_order 排序
    heading_events.sort(key=lambda h: h["emitted_order"])

    # 2. 建立 block_id → structured_block 的映射
    block_map = {b["block_id"]: b for b in structured_blocks}

    # 3. stack 建树
    stack, root_nodes = [], []
    for h in heading_events:
        node = {
            "node_id": f"sec:{h['block_id']}",
            "kind": "section",
            "title": h["title"],
            "level": h["markdown_level"],
            "page_span": [h["page"], h["page"]],
            "block_id": h["block_id"],
            "own_block_ids": [],
            "subtree_block_ids": [],
            "children": [],
            "objects": [],
        }
        while stack and stack[-1]["level"] >= h["markdown_level"]:
            stack.pop()
        if stack:
            stack[-1]["children"].append(node)
        else:
            root_nodes.append(node)
        stack.append(node)

    # 4. 计算 block intervals基于 emitted_block_events
    _assign_block_intervals(root_nodes, heading_events, emitted_block_events)
    return {"paper_id": ..., "nodes": root_nodes}

1.4 block interval 计算(基于 emitted order

def _assign_block_intervals(nodes, heading_events, emitted_block_events):
    # 构建每个 heading 的 emitted_order 边界
    bounds_map = {}
    for i, h in enumerate(heading_events):
        next_sibling = None
        for h2 in heading_events[i+1:]:
            if h2["markdown_level"] <= h["markdown_level"]:
                next_sibling = h2["emitted_order"]
                break
        bounds_map[h["block_id"]] = {
            "start": h["emitted_order"],
            "end": next_sibling or float("inf"),
        }

    def compute(node):
        bounds = bounds_map.get(node["block_id"])
        if not bounds:
            return
        # subtree = bounds 范围内的所有 emitted blocks
        node["subtree_block_ids"] = [
            e["block_id"] for e in emitted_block_events
            if bounds["start"] <= e["emitted_order"] < bounds["end"]
        ]
        for child in node.get("children", []):
            compute(child)
        # own = subtree  children subtree  heading blocks
        child_ids = set()
        for child in node.get("children", []):
            child_ids.update(child.get("subtree_block_ids", []))
        node["own_block_ids"] = [
            bid for bid in node["subtree_block_ids"]
            if bid not in child_ids and bid != node["block_id"]
        ]

    for n in nodes:
        compute(n)

1.5 build_body_units() 递归

def _body_unit_role_kind(role):
    if role == "body_paragraph": return "body"
    if role in {"structured_insert", "non_body_insert", "backmatter_body"}:
        return "backmatter_body"
    return None  # reference_item, reference_heading, heading, caption, asset → excluded

def build_body_units(tree, structured_blocks):
    units = []
    block_map = {b["block_id"]: b for b in structured_blocks}

    def walk(node, path):
        this_path = path + [node["title"]]
        own = [block_map[bid] for bid in node.get("own_block_ids", [])
               if bid in block_map
               and _body_unit_role_kind(block_map[bid].get("role", ""))]
        if own:
            from itertools import groupby
            for kind, grp in groupby(own, key=lambda b: _body_unit_role_kind(b["role"])):
                if not kind: continue
                text = "\n\n".join(b.get("text","") for b in grp)
                parts = _split_if_oversized(text, 1000)
                n = len(parts)
                for pi, pt in enumerate(parts):
                    ord_ = pi + 1 if n > 1 else 0
                    suf = f":part_{ord_:03d}" if ord_ else ""
                    units.append({
                        "unit_id": f"{paper_id}:body:{node['node_id']}{suf}",
                        "paper_id": paper_id,
                        "section_path": "/".join(this_path),
                        "section_path_json": json.dumps(this_path),
                        "section_level": node["level"],
                        "section_title": node["title"],
                        "page_span": node["page_span"],
                        "unit_text": pt,
                        "token_estimate": len(pt)//4,
                        "unit_kind": kind,
                        "part_ordinal": ord_,
                        "indexable": True,
                        "veto_reason": "",
                        "quality_hints": [],
                    })
        for child in node.get("children", []):
            walk(child, path)

    for root in tree.get("nodes", []):
        walk(root, [])
    return units

1.6 build_object_units() own_block_ids

与 body_units 同理,只取 own_block_ids 范围内 role 为 figure/table/object 的 blocks。一个 object 只归属一个最具体的 section。

1.7 schema 升级

-- CURRENT_SCHEMA_VERSION: 3 → 4
ALTER TABLE body_units ADD COLUMN section_path_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE body_units ADD COLUMN section_level INTEGER NOT NULL DEFAULT 0;
ALTER TABLE body_units ADD COLUMN section_title TEXT NOT NULL DEFAULT '';
ALTER TABLE body_units ADD COLUMN part_ordinal INTEGER NOT NULL DEFAULT 0;

测试清单

1. duplicate heading title 通过 block_id 区分,不通过 title match
2. H2/H3/H3 sibling 产生正确 section_path无跨级错误
3. parent intro + child + parent summary:
   parent.own_block_ids = [intro_para, summary_para](不含 child 内容)
4. child body 不重复出现在 parent unit
5. empty container 不产生 body unit
6. reference_item 不进入 body_units
7. structured_insert 进入 backmatter_body unit
8. mixed body + backmatter: 拆成两个 unit不同 unit_kind
9. token cap: >1000 tokens 拆 part_001 / part_002
10. object 只归属最具体 section
11. rendered order ≠ structured order: tree interval 基于 emitted order
12. consumed object blocks 不进入 own_block_ids

PR 2Memory Builder 增量

目标: memory build 不再被 canonical_index_hash 锁定per-paper 检测 OCR 结果变化后增量重建 body_units。

文件变更

操作 文件 改动
修改 paperforge/memory/builder.py 结构化路径修正;不 early returnper-paper ocr_result_hash 增量;三级 fallbackschema v4 兼容
修改 paperforge/memory/schema.py ensure_schema 支持 v4 upgrade path

实现要点

2.1 路径修正

# 改前
structured_path = ocr_dir / "structured-blocks.json"
structured_blocks = read_json(structured_path)

# 改后
structured_path = ocr_dir / "structure" / "blocks.structured.jsonl"
structured_blocks = read_jsonl(structured_path)

2.2 不 early return

# 改前
if canonical_hash matches and db_path.exists():
    return {"papers_indexed": N, "hash_match": True}

# 改后
index_changed = not _hash_matches(...)
if index_changed:
    _rebuild_metadata_tables(conn, items)    # papers / aliases / FTS
    _rebuild_all_units(conn, items, vault)   # body + object + manifest
else:
    _incremental_units_only(conn, items, vault)  # only body/object units

2.3 _incremental_units_only()

def _incremental_units_only(conn, items, vault):
    ocr_root = vault / "System" / "PaperForge" / "ocr"
    if not ocr_root.exists():
        return
    for entry in items:
        key = entry["zotero_key"]
        paper_dir = ocr_root / key
        tree_path = paper_dir / "index" / "structure-tree.json"
        blocks_path = paper_dir / "structure" / "blocks.structured.jsonl"
        if not tree_path.exists() or not blocks_path.exists():
            continue
        current_hash = _resolve_ocr_result_hash(paper_dir)
        row = conn.execute(
            "SELECT value FROM meta WHERE key=?", (f"manifest:{key}",)
        ).fetchone()
        if row:
            stored = json.loads(row[0])
            if stored.get("ocr_result_hash") == current_hash:
                continue
        _rebuild_paper_units(conn, key, paper_dir, tree_path, blocks_path)

2.4 _resolve_ocr_result_hash() 三级 fallback

def _resolve_ocr_result_hash(paper_dir):
    # 1. index/result-hash.txt
    rp = paper_dir / "index" / "result-hash.txt"
    if rp.exists():
        return rp.read_text().strip()
    # 2. hash of structured artifacts
    h = hashlib.sha256()
    for rel in ["structure/blocks.structured.jsonl", "index/structure-tree.json",
                 "index/role-index.json"]:
        p = paper_dir / rel
        if p.exists():
            h.update(p.read_bytes())
    if h.hexdigest() != hashlib.sha256(b"").hexdigest():
        return h.hexdigest()
    # 3. meta derived_version
    meta_p = paper_dir / "meta.json"
    if meta_p.exists():
        dv = json.loads(meta_p.read_text()).get("derived_version", {})
        return hashlib.sha256(json.dumps(dv, sort_keys=True).encode()).hexdigest()
    return ""

2.5 _rebuild_paper_units() 删除 + _upsert_body_units() 只插入

职责分离: _rebuild_paper_units() 负责 DELETE_upsert_body_units() 只 INSERT + FTS。

def _rebuild_paper_units(conn, key, paper_dir, tree_path, blocks_path):
    # ── 删除(一个地方做)──
    conn.execute("DELETE FROM body_units WHERE paper_id = ?", (key,))
    conn.execute("DELETE FROM body_units_fts WHERE paper_id = ?", (key,))
    conn.execute("DELETE FROM object_units WHERE paper_id = ?", (key,))

    # ── 构建 ──
    tree = json.loads(tree_path.read_text())
    blocks = read_jsonl(blocks_path)
    from paperforge.retrieval.units import build_body_units, build_object_units
    body_units = build_body_units(tree=tree, structured_blocks=blocks)
    object_units = build_object_units(tree=tree, structured_blocks=blocks, ...)

    # ── 插入 + FTS不做 DELETE──
    _upsert_body_units(conn, body_units)
    _upsert_object_units(conn, object_units)

    # ── manifest ──
    from paperforge.retrieval.manifest import build_paper_manifest
    manifest = build_paper_manifest(paper_id=key, ...)
    _write_manifest_row(conn, manifest)

_upsert_body_units() 只 INSERT + FTSDELETE 已在 _rebuild_paper_units() 完成:

def _upsert_body_units(conn, body_units):
    for unit in body_units:
        conn.execute("""INSERT INTO body_units (...) VALUES (...)""", ...)
    paper_ids = list({u["paper_id"] for u in body_units})
    for pid in paper_ids:
        conn.execute("""INSERT INTO body_units_fts(...)
                        SELECT rowid, unit_id, paper_id, section_path, unit_text
                        FROM body_units
                        WHERE paper_id = ? AND indexable = 1""", (pid,))

PR 3Content Discovery 行为

目标: body_units_fts 做 primary 检索,无结果时不静默 fallback。

依赖: PR 2 后做(依赖 body_units 表稳定 + indexable 语义稳定 + schema v4

文件变更

操作 文件 改动
修改 paperforge/retrieval/gateway.py _run_body_unit_discovery() 无结果时返回空 + coverage 提示

实现要点

def _run_body_unit_discovery(vault, query, limit=5):
    body_rows = _search_body_units_fts(vault, query, limit)
    coverage = _get_body_coverage(conn)
    if not body_rows:
        return PFResult(ok=True, data={
            "results": [],
            "coverage": coverage,
            "next_action": {
                "command": f"paperforge search {query}",
                "reason": (f"正文检索无匹配。正文索引覆盖 "
                           f"{coverage['body_papers']}/{coverage['ocr_papers']} 篇 OCR 完成论文。"
                           f"尝试 paperforge search 进行元数据全文搜索。")
            }
        })
    return PFResult(ok=True, data={"results": body_rows, "coverage": coverage})

def _get_body_coverage(conn):
    body = conn.execute("SELECT COUNT(DISTINCT paper_id) FROM body_units WHERE indexable=1").fetchone()[0]
    ocr = conn.execute("SELECT COUNT(*) FROM papers WHERE ocr_status='done'").fetchone()[0]
    lib = conn.execute("SELECT COUNT(*) FROM papers").fetchone()[0]
    return {"body_papers": body, "ocr_papers": ocr, "library_papers": lib}

PR 4Embedding 双 collection

目标: 新建 paperforge_body collectionembed builder 从 DB 读 body_units。

文件变更

操作 文件 改动
修改 paperforge/embedding/_chroma.py get_collection() 支持 name 参数;delete_paper_vectors() 双删
修改 paperforge/embedding/builder.py 新增 embed_body_units()get_body_units_for_embedding() 读 DB
修改 paperforge/embedding/search.py 新增 merge_retrieve()
修改 paperforge/embedding/status.py 统计两个 collection
修改 paperforge/commands/embed.py build 流程以 DB 分流、resume 检测 body_units_hash

实现要点

4.1 get_collection(name=)

def get_collection(vault, name="paperforge_fulltext"):
    db_path = get_vector_db_path(vault)
    client = chromadb.PersistentClient(str(db_path))
    return client.get_or_create_collection(
        name=name, metadata={"hnsw:space": "cosine"},
    )

4.2 embed build 分流(以 DB 为准)

是否走 body_units 路径以 DB body_units 表为准Memory Builder 是唯一生产者),不重复检测文件。

def _has_body_units_in_db(vault, key) -> bool:
    db_path = get_memory_db_path(vault)
    if not db_path.exists():
        return False
    conn = get_connection(db_path, read_only=True)
    cnt = conn.execute(
        "SELECT COUNT(*) FROM body_units WHERE paper_id=? AND indexable=1",
        (key,)
    ).fetchone()[0]
    conn.close()
    return cnt > 0

for entry in papers:
    key = entry["zotero_key"]
    has_body = _has_body_units_in_db(vault, key)

    if has_body:
        body_units = get_body_units_for_embedding(vault, key)
        if not body_units:
            continue
        if resume:
            col = get_collection(vault, name="paperforge_body")
            # Chroma: 不依赖多字段 where 隐式 AND
            existing = col.get(where={"paper_id": key}, limit=1)
            metas = existing.get("metadatas", [])
            if metas and metas[0].get("body_units_hash") == current_hash:
                papers_skipped += 1; continue
        delete_paper_vectors(vault, key)
        embed_body_units(vault, key, body_units)
    else:
        # 文件存在但 DB 无 body_units → 提示先 memory build
        ocr_root = vault / "System" / "PaperForge" / "ocr" / key
        has_files = ((ocr_root / "structure" / "blocks.structured.jsonl").exists()
                     and (ocr_root / "index" / "structure-tree.json").exists())
        if has_files:
            print(f"Skip {key}: has structured blocks but no body_units in DB. "
                  f"Run `paperforge memory build` first.")
            continue
        # 旧路径
        fulltext_path = vault / (entry.get("fulltext_path") or "")
        if resume:
            col = get_collection(vault, name="paperforge_fulltext")
            existing = col.get(where={"paper_id": key}, limit=1)
            if existing.get("ids"):
                papers_skipped += 1; continue
        delete_paper_vectors(vault, key)
        chunks = chunk_fulltext(fulltext_path)
        if chunks:
            embed_paper(vault, key, chunks)

PR 5Retrieve 合并

目标: 同时查两个 collectionunit-level 去重per-paper cap。

文件变更

操作 文件 改动
新增 paperforge/embedding/search.py merge_retrieve()
修改 paperforge/commands/retrieve.py merge_retrieve() 替代 retrieve_chunks()

实现要点

RETRIEVAL_COLLECTIONS = ["paperforge_fulltext", "paperforge_body"]

def merge_retrieve(vault, query, limit=5, expand=True):
    provider = OpenAICompatibleProvider(vault)
    q_emb = provider.encode_single(query)
    n = limit * 2

    all_results = []
    for name in RETRIEVAL_COLLECTIONS:
        try:
            col = get_collection(vault, name=name)
            res = col.query(
                query_embeddings=[q_emb],
                n_results=n,
                include=["documents", "metadatas", "distances"],
            )
            for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
                all_results.append({
                    "paper_id": meta.get("paper_id", ""),
                    "section_path": meta.get("section_path", ""),
                    "chunk_text": doc,
                    "score": round(1.0 - dist, 4),
                    "source": "legacy_chunk" if name == "paperforge_fulltext" else "body_unit",
                    "unit_id": meta.get("chunk_index", ""),
                })
        except Exception:
            continue

    # 排序 + unit-level 去重 + per-paper cap
    all_results.sort(key=lambda r: r["score"], reverse=True)
    seen = set()
    per_paper = {}
    merged = []
    for r in all_results:
        # dedupe key: (source, unit_id) 或 (source, paper_id+text_hash) 防空 unit_id
        dedupe_key = (r["source"], r["unit_id"]) if r.get("unit_id") else (
            r["source"], r["paper_id"], hash(r["chunk_text"])
        )
        if dedupe_key in seen:
            continue
        seen.add(dedupe_key)
        pid = r["paper_id"]
        if per_paper.get(pid, 0) >= 2:
            continue
        per_paper[pid] = per_paper.get(pid, 0) + 1
        merged.append(r)
        if len(merged) >= limit:
            break
    return merged

执行顺序

Step 1: PR 1最核心不可拆
  → 验证: body_units 表有行section_path 正确
  → 验证: rendered order ≠ structured order 时边界正确

Step 2: PR 2
  → 验证: rebuild 后 memory build 增量更新 body_units
  → 验证: content-discovery 返回正文结果

Step 3: PR 3依赖 PR 2
  → 验证: content-discovery 无结果时返回空 + coverage 提示

Step 4: PR 4
  → 验证: embed build 写入 paperforge_body
  → 验证: resume 正确跳过已嵌入的论文(通过 body_units_hash

Step 5: PR 5
  → 验证: retrieve 返回合并结果unit-level 去重 + per-paper cap