mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 17:00:23 +00:00
- 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
634 lines
22 KiB
Markdown
634 lines
22 KiB
Markdown
# 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 2,PR 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=True` → `RenderOutput` 传入 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 walk,`own_block_ids`,role helper |
|
||
| 修改 | `paperforge/memory/schema.py` | body_units 表加字段;CURRENT_SCHEMA_VERSION → 4 |
|
||
|
||
### 实现要点
|
||
|
||
#### 1.1 RenderOutput 与 render-map.json
|
||
|
||
**不直接改 `render_fulltext_markdown()` 返回类型**——当前返回 `str`,旧调用点很多。新增 `return_events=False` 参数:
|
||
|
||
```python
|
||
@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` 每条记录:
|
||
|
||
```json
|
||
{"line_number": 42, "markdown_level": 3, "title": "Study population",
|
||
"page": 2, "block_id": "b_10", "emitted_order": 7}
|
||
```
|
||
|
||
`emitted_block_events` 每条记录(每个真正进入正文阅读流的 block):
|
||
|
||
```json
|
||
{"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`:
|
||
|
||
```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) ← 此时无 heading/emitted 信息
|
||
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
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
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() 递归
|
||
|
||
```python
|
||
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 升级
|
||
|
||
```sql
|
||
-- 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 2:Memory Builder 增量
|
||
|
||
**目标:** memory build 不再被 canonical_index_hash 锁定,per-paper 检测 OCR 结果变化后增量重建 body_units。
|
||
|
||
### 文件变更
|
||
|
||
| 操作 | 文件 | 改动 |
|
||
|------|------|------|
|
||
| 修改 | `paperforge/memory/builder.py` | 结构化路径修正;不 early return;per-paper ocr_result_hash 增量;三级 fallback;schema v4 兼容 |
|
||
| 修改 | `paperforge/memory/schema.py` | ensure_schema 支持 v4 upgrade path |
|
||
|
||
### 实现要点
|
||
|
||
#### 2.1 路径修正
|
||
|
||
```python
|
||
# 改前
|
||
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
|
||
|
||
```python
|
||
# 改前
|
||
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()
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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。
|
||
|
||
```python
|
||
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 + FTS,DELETE 已在 `_rebuild_paper_units()` 完成:
|
||
|
||
```python
|
||
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 3:Content Discovery 行为
|
||
|
||
**目标:** body_units_fts 做 primary 检索,无结果时不静默 fallback。
|
||
|
||
**依赖:** PR 2 后做(依赖 body_units 表稳定 + indexable 语义稳定 + schema v4)。
|
||
|
||
### 文件变更
|
||
|
||
| 操作 | 文件 | 改动 |
|
||
|------|------|------|
|
||
| 修改 | `paperforge/retrieval/gateway.py` | `_run_body_unit_discovery()` 无结果时返回空 + coverage 提示 |
|
||
|
||
### 实现要点
|
||
|
||
```python
|
||
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 4:Embedding 双 collection
|
||
|
||
**目标:** 新建 `paperforge_body` collection,embed 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=)
|
||
|
||
```python
|
||
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 是唯一生产者),不重复检测文件。
|
||
|
||
```python
|
||
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 5:Retrieve 合并
|
||
|
||
**目标:** 同时查两个 collection,unit-level 去重,per-paper cap。
|
||
|
||
### 文件变更
|
||
|
||
| 操作 | 文件 | 改动 |
|
||
|------|------|------|
|
||
| 新增 | `paperforge/embedding/search.py` | `merge_retrieve()` |
|
||
| 修改 | `paperforge/commands/retrieve.py` | 调 `merge_retrieve()` 替代 `retrieve_chunks()` |
|
||
|
||
### 实现要点
|
||
|
||
```python
|
||
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
|
||
```
|